diff --git a/.gitignore b/.gitignore index 3c441754c6a..c4d8a91c01b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ nym-api/redocly/formatted-openapi.json **/settings.sql **/enter_db.sh +.beads +CLAUDE.md +docs \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 573e3538cab..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,686 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Nym is a privacy platform that uses mixnet technology to protect against metadata surveillance. The platform consists of several key components: -- Mixnet nodes (mixnodes) for packet mixing -- Gateways (entry/exit points for the network) -- Clients for interacting with the network -- Network monitoring tools -- Validators for network consensus -- Various service providers and integrations - -## Build Commands - -### Rust Components - -```bash -# Default build (debug) -cargo build - -# Release build -cargo build --release - -# Build a specific package -cargo build -p - -# Build main components -make build - -# Build release versions of main binaries and contracts -make build-release - -# Build specific binaries -make build-nym-cli -cargo build -p nym-node --release -cargo build -p nym-api --release -``` - -### Testing - -```bash -# Run clippy, unit tests, and formatting -make test - -# Run all tests including slow tests -make test-all - -# Run clippy on all workspaces -make clippy - -# Run unit tests for a specific package -cargo test -p - -# Run only expensive/ignored tests -cargo test --workspace -- --ignored - -# Run API tests -dotenv -f envs/sandbox.env -- cargo test --test public-api-tests - -# Run tests with specific log level -RUST_LOG=debug cargo test -p - -# Run specific test scripts -./nym-node/tests/test_apis.sh -./scripts/wireguard-exit-policy/exit-policy-tests.sh -``` - -### Linting and Formatting - -```bash -# Run rustfmt on all code -make fmt - -# Check formatting without modifying -cargo fmt --all -- --check - -# Run clippy with all targets -cargo clippy --workspace --all-targets -- -D warnings - -# TypeScript linting -yarn lint -yarn lint:fix -yarn types:lint:fix - -# Check dependencies for security/licensing issues -cargo deny check -``` - -### WASM Components - -```bash -# Build all WASM components -make sdk-wasm-build - -# Build TypeScript SDK -yarn build:sdk -npx lerna run --scope @nymproject/sdk build --stream - -# Build and test WASM components -make sdk-wasm - -# Build specific WASM packages -cd wasm/client && make -cd wasm/mix-fetch && make -cd wasm/node-tester && make -``` - -### Contract Development - -```bash -# Build all contracts -make contracts - -# Build contracts in release mode -make build-release-contracts - -# Generate contract schemas -make contract-schema - -# Run wasm-opt on contracts -make wasm-opt-contracts - -# Check contracts with cosmwasm-check -make cosmwasm-check-contracts -``` - -### Running Components - -```bash -# Run nym-node as a mixnode -cargo run -p nym-node -- run --mode mixnode - -# Run nym-node as a gateway -cargo run -p nym-node -- run --mode gateway - -# Run the network monitor -cargo run -p nym-network-monitor - -# Run the API server -cargo run -p nym-api - -# Run with specific environment -dotenv -f envs/sandbox.env -- cargo run -p nym-api - -# Start a local network -./scripts/localnet_start.sh -``` - -## Architecture - -The Nym platform consists of various components organized as a monorepo: - -1. **Core Mixnet Infrastructure**: - - `nym-node`: Core binary supporting mixnode and gateway modes - - `common/nymsphinx`: Implementation of the Sphinx packet format - - `common/topology`: Network topology management - - `common/types`: Shared data types across components - -2. **Network Monitoring**: - - `nym-network-monitor`: Monitors the network's reliability and performance - - `nym-api`: API server for network stats and monitoring data - - Metrics tracking for nodes, routes, and overall network health - -3. **Client Implementations**: - - `clients/native`: Native Rust client implementation - - `clients/socks5`: SOCKS5 proxy client for standard applications - - `wasm`: WebAssembly client implementations (for browsers) - - `nym-connect`: Desktop and mobile clients - -4. **Blockchain & Smart Contracts**: - - `common/cosmwasm-smart-contracts`: Smart contract implementations - - `contracts`: CosmWasm contracts for the Nym network - - `common/ledger`: Blockchain integration - -5. **Utilities & Tools**: - - `tools`: Various CLI tools and utilities - - `sdk`: SDKs for different languages and platforms - - `documentation`: Documentation generation and management - -## Packet System - -Nym uses a modified Sphinx packet format for its mixnet: - -1. **Message Chunking**: - - Messages are divided into "sets" and "fragments" - - Each fragment fits in a single Sphinx packet - - The `common/nymsphinx/chunking` module handles message fragmentation - -2. **Routing**: - - Packets traverse through 3 layers of mixnodes - - Routing information is encrypted in layers (onion routing) - - The final gateway receives and processes the messages - -3. **Monitoring**: - - Monitoring system tracks packet delivery through the network - - Routes are analyzed for reliability statistics - - Node performance metrics are collected - -## Network Protocol - -Nym implements the Loopix mixnet design with several key privacy features: - -1. **Continuous-time Mixing**: - - Each mixnode delays messages independently with an exponential distribution - - This creates random reordering of packets, destroying timing correlations - - Offers better anonymity properties than batch mixing approaches - -2. **Cover Traffic**: - - Clients and nodes generate dummy "loop" packets that circulate through the network - - These packets are indistinguishable from real traffic - - Creates a baseline level of traffic that hides actual communication patterns - - Provides unobservability (hiding when and how much real traffic is being sent) - -3. **Stratified Network Architecture**: - - Traffic flows through Entry Gateway → 3 Mixnode Layers → Exit Gateway - - Path selection is independent per-message (unlike Tor) - - Each node connects only to adjacent layers - -4. **Anonymous Replies**: - - Single-Use Reply Blocks (SURBs) allow receiving messages without revealing identity - - Enables bidirectional communication while maintaining privacy - -## Network Monitoring Architecture - -The network monitoring system is a core component that measures mixnet reliability: - -1. The `nym-network-monitor` sends test packets through the network -2. These packets follow predefined routes through multiple mixnodes -3. Metrics are collected about: - - Successful and failed packet deliveries - - Node reliability (percentage of successful packet handling) - - Route reliability (which specific route combinations work best) -4. Results are stored in the database and used by `nym-api` to: - - Present node performance statistics - - Determine network rewards - - Provide route selection guidance to clients - -In the current branch, metrics collection is being enhanced with a fanout approach to submit to multiple API endpoints. - -## Development Environment - -### Required Dependencies - -- Rust toolchain (stable, 1.80+) -- Node.js (v20+) and yarn for TypeScript components -- SQLite for local database development -- PostgreSQL for API database (optional, for full API functionality) -- CosmWasm tools for contract development -- For building contracts: `wasm-opt` tool from `binaryen` -- Python 3.8+ for some scripts -- Docker (optional, for containerized development) -- protoc (Protocol Buffers compiler) for some components - -### Environment Configurations - -The `envs/` directory contains pre-configured environments: - -#### Available Environments - -- **`local.env`**: Local development environment - - Points to local services (localhost) - - Uses test mnemonics and keys - - Ideal for testing without external dependencies - -- **`sandbox.env`**: Sandbox test network - - Public test network with real nodes - - Test tokens available from faucet - - Contract addresses for sandbox deployment - - API: https://sandbox-nym-api1.nymtech.net - -- **`mainnet.env`**: Production mainnet - - Real network with real tokens - - Production contract addresses - - API: https://validator.nymtech.net - - Use with caution! - -- **`canary.env`**: Canary deployment - - Pre-release testing environment - - Tests new features before mainnet - -- **`mainnet-local-api.env`**: Hybrid environment - - Uses mainnet contracts but local API - - Useful for API development against mainnet data - -#### Key Environment Variables - -```bash -# Network configuration -NETWORK_NAME=sandbox # Network identifier -BECH32_PREFIX=n # Address prefix (n for sandbox, n for mainnet) -NYM_API=https://sandbox-nym-api1.nymtech.net/api -NYXD=https://rpc.sandbox.nymtech.net -NYM_API_NETWORK=sandbox - -# Contract addresses (network-specific) -MIXNET_CONTRACT_ADDRESS=n1xr3rq8yvd7qplsw5yx90ftsr2zdhg4e9z60h5duusgxpv72hud3sjkxkav -VESTING_CONTRACT_ADDRESS=n1unyuj8qnmygvzuex3dwmg9yzt9alhvyeat0uu0jedg2wj33efl5qackslz -# ... other contract addresses - -# Mnemonic for testing (NEVER use in production) -MNEMONIC="clutch captain shoe salt awake harvest setup primary inmate ugly among become" - -# API Keys and tokens -IPINFO_API_TOKEN=your_token_here -AUTHENTICATOR_PASSWORD=password_here - -# Logging -RUST_LOG=info # Options: error, warn, info, debug, trace -RUST_BACKTRACE=1 # Enable backtraces - -# Database -DATABASE_URL=postgresql://user:pass@localhost/nym_api -``` - -#### Using Environment Files - -```bash -# Load environment and run command -dotenv -f envs/sandbox.env -- cargo run -p nym-api - -# Export to shell -source envs/sandbox.env - -# Use with make targets -dotenv -f envs/sandbox.env -- make run-api-tests -``` - -## Initial Setup - -### First Time Setup - -1. **Install Prerequisites** - ```bash - # Install Rust - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - - # Install Node.js and yarn - # Via nvm (recommended): - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash - nvm install 20 - npm install -g yarn - - # Install build tools - # Ubuntu/Debian: - sudo apt-get install build-essential pkg-config libssl-dev protobuf-compiler libpq-dev - - # macOS: - brew install protobuf postgresql - - # Install wasm-opt for contract builds - npm install -g wasm-opt - - # Add wasm target for Rust - rustup target add wasm32-unknown-unknown - ``` - -2. **Clone and Setup Repository** - ```bash - git clone https://github.com/nymtech/nym.git - cd nym/nym - - # Install JavaScript dependencies - yarn install - - # Build the project - make build - ``` - -3. **Database Setup (Optional, for API development)** - ```bash - # Install PostgreSQL - # Create database - createdb nym_api - - # Run migrations (from nym-api directory) - cd nym-api - sqlx migrate run - ``` - -### Quick Start - -```bash -# Run a mixnode locally -dotenv -f envs/sandbox.env -- cargo run -p nym-node -- run --mode mixnode --id my-mixnode - -# Run a gateway locally -dotenv -f envs/sandbox.env -- cargo run -p nym-node -- run --mode gateway --id my-gateway - -# Run the API server -dotenv -f envs/sandbox.env -- cargo run -p nym-api - -# Run a client -cargo run -p nym-client -- init --id my-client -cargo run -p nym-client -- run --id my-client -``` - -## CI/CD Pipeline - -The project uses GitHub Actions for CI/CD with several key workflows: - -1. **Build and Test**: - - `ci-build.yml`: Main build workflow for Rust components - - Tests are run on multiple platforms (Linux, Windows, macOS) - - Includes formatting check (rustfmt) and linting (clippy) - -2. **Release Process**: - - Binary artifacts are published on release tags - - Multiple platform builds are created - -3. **Documentation**: - - Documentation is automatically built and deployed - -## Database Structure - -The system uses SQLite databases with tables like: -- `mixnode_status`: Status information about mixnodes -- `gateway_status`: Status information about gateways -- `routes`: Route performance information (success/failure of specific paths) -- `monitor_run`: Information about monitoring test runs - -## Development Workflows - -### Running a Node - -To run the mixnode or gateway: - -```bash -# Run nym-node as a mixnode with specified identity -cargo run -p nym-node -- run --mode mixnode --id my-mixnode - -# Run nym-node as a gateway -cargo run -p nym-node -- run --mode gateway --id my-gateway -``` - -### Configuration - -Nodes can be configured with files in various locations: -- Command-line arguments -- Environment variables -- `.env` files specified with `--config-env-file` - -### Monitoring - -To monitor the health of your node: -- View logs for real-time information -- Use the node's HTTP API for status information -- Check the explorer for public node statistics - -## Common Libraries - -- `common/types`: Shared data types across all components -- `common/crypto`: Cryptographic primitives and wrappers -- `common/client-core`: Core client functionality -- `common/gateway-client`: Client-gateway communication -- `common/task`: Task management and concurrency utilities -- `common/nymsphinx`: Sphinx packet implementation for mixnet -- `common/topology`: Network topology management -- `common/credentials`: Credential system for privacy-preserving authentication -- `common/bandwidth-controller`: Bandwidth management and accounting - -## Code Conventions - -- Error handling: Use anyhow/thiserror for structured error handling -- Logging: Use the tracing framework for logging and diagnostics -- State management: Generally use Tokio/futures for async code -- Configuration: Use the config crate and env vars with defaults -- Database: Use sqlx for type-safe database queries -- Follow clippy recommendations and rustfmt formatting -- Use semantic commit messages: feat, fix, docs, refactor, test, chore - -## When Making Changes - -- Run `make test` before submitting PRs -- Follow Rust naming conventions -- Use `clippy` to check for common issues -- Update SQLx query caches when modifying DB queries: `cargo sqlx prepare` -- Consider backward compatibility for protocol changes -- Use lefthook pre-commit hooks for TypeScript formatting -- Run `cargo deny check` to verify dependency compliance -- Test against both sandbox and local environments when possible -- Update relevant documentation and CHANGELOG.md - -## Development Tools - -### Useful Cargo Commands - -```bash -# Check for outdated dependencies -cargo outdated - -# Analyze binary size -cargo bloat --release -p nym-node - -# Generate dependency graph -cargo tree -p nym-api - -# Run with instrumentation -cargo run --features profiling -p nym-node - -# Check for security advisories -cargo audit -``` - -### Database Tools - -```bash -# SQLx CLI for migrations -cargo install sqlx-cli - -# Create new migration -cd nym-api && sqlx migrate add - -# Prepare query metadata for offline compilation -cargo sqlx prepare --workspace - -# View database schema -./nym-api/enter_db.sh -``` - -### Development Scripts - -- `scripts/build_topology.py`: Generate network topology files -- `scripts/node_api_check.py`: Verify node API endpoints -- `scripts/network_tunnel_manager.sh`: Manage network tunnels -- `scripts/localnet_start.sh`: Start a local test network -- Various deployment scripts in `deployment/` for different environments - -## Debugging - -- Enable more verbose logging with the RUST_LOG environment variable: - ``` - RUST_LOG=debug,nym_node=trace cargo run -p nym-node -- run --mode mixnode - ``` -- Use the HTTP API endpoints for status information -- Check monitoring data in the database for network performance metrics -- For complex issues, use tracing tools to follow packet flow -- Enable backtraces: `RUST_BACKTRACE=full` -- For WASM debugging: Use browser developer tools with source maps - -## Deployment and Advanced Configurations - -### Deployment Structure - -The `deployment/` directory contains Ansible playbooks and configurations for various deployment scenarios: - -- **`aws/`**: AWS-specific deployment configurations -- **`mixnode/`**: Mixnode deployment playbooks -- **`gateway/`**: Gateway deployment playbooks -- **`validator/`**: Validator node deployment -- **`sandbox-v2/`**: Complete sandbox environment setup -- **`big-dipper-2/`**: Block explorer deployment - -### Sandbox V2 Deployment - -The sandbox-v2 deployment (`deployment/sandbox-v2/`) provides a complete test environment: - -```bash -# Key playbooks: -- deploy.yaml # Main deployment orchestrator -- deploy-mixnodes.yaml # Deploy mixnodes -- deploy-gateways.yaml # Deploy gateways -- deploy-validators.yaml # Deploy validator nodes -- deploy-nym-api.yaml # Deploy API services -``` - -### Custom Environment Setup - -To create a custom environment: - -1. Copy an existing env file: `cp envs/sandbox.env envs/custom.env` -2. Modify the network endpoints and contract addresses -3. Update the `NETWORK_NAME` to your identifier -4. Set appropriate mnemonics and keys (use fresh ones for production!) - -### Contract Addresses - -Contract addresses are network-specific and defined in environment files: -- Mixnet contract: Manages mixnode/gateway registry -- Vesting contract: Handles token vesting schedules -- Coconut contracts: Privacy-preserving credentials -- Name service: Human-readable address mapping -- Ecash contract: Electronic cash functionality - -### Local Network Setup - -For a completely local network: -```bash -# Start local chain -./scripts/localnet_start.sh - -# Deploy contracts -cd contracts -make deploy-local - -# Start nodes with local config -dotenv -f envs/local.env -- cargo run -p nym-node -- run --mode mixnode -``` - -## Common Issues and Troubleshooting - -### Database Issues - -- When modifying database queries, you must update SQLx query caches: - ```bash - cargo sqlx prepare - ``` -- If you see SQLx errors about missing query files, this is likely the cause -- For "database is locked" errors with SQLite, ensure only one process accesses the DB -- For PostgreSQL connection issues, verify DATABASE_URL and that the server is running - -### API Connection Issues - -- Check the environment variables pointing to the APIs (NYM_API, NYXD) -- Verify network connectivity and API health endpoints -- For authentication issues, check node keys and credentials -- Common endpoints to verify: - - API health: `$NYM_API/health` - - Chain status: `$NYXD/status` - - Contract info: `$NYXD/cosmwasm/wasm/v1/contract/$CONTRACT_ADDRESS` - -### Build Problems - -- Clean dependencies with `cargo clean` for a fresh build -- Check for compatible Rust version (1.80+ recommended) -- For smart contract builds, ensure wasm-opt is installed: `npm install -g wasm-opt` -- For cross-compilation issues, check target-specific dependencies -- WASM build issues: Ensure wasm32-unknown-unknown target is installed: - ```bash - rustup target add wasm32-unknown-unknown - ``` -- For "cannot find -lpq" errors, install PostgreSQL development files: - ```bash - # Ubuntu/Debian - sudo apt-get install libpq-dev - # macOS - brew install postgresql - ``` - -### Environment Issues - -- Contract address mismatches: Ensure you're using the correct environment file -- "Account sequence mismatch": The account nonce is out of sync, wait and retry -- Token decimal issues: Sandbox uses different decimal places than mainnet -- API version mismatches: Ensure your local API version matches the network -- "Insufficient funds": Get test tokens from faucet (sandbox) or check balance -- Gateway/mixnode bonding issues: Verify minimum stake requirements - -## Working with Routes and Monitoring - -1. Route monitoring metrics are stored in a `routes` table with: - - Layer node IDs (layer1, layer2, layer3, gw) - - Success flag (boolean) - - Timestamp - -2. To analyze routes: - - Check `NetworkAccount` and `AccountingRoute` in `nym-network-monitor/src/accounting.rs` - - View monitoring logic in `common/nymsphinx/chunking/monitoring.rs` - - Observe how routes are submitted to the database in the `submit_accounting_routes_to_db` function - -## Performance Optimization - -### Profiling and Benchmarking - -```bash -# Run benchmarks -cargo bench -p nym-node - -# Profile with perf (Linux) -cargo build --release --features profiling -perf record --call-graph=dwarf ./target/release/nym-node run --mode mixnode -perf report - -# Generate flamegraph -cargo install flamegraph -cargo flamegraph --bin nym-node -- run --mode mixnode -``` - -### Common Performance Considerations - -- Use bounded channels for backpressure -- Batch database operations where possible -- Monitor memory usage with `RUST_LOG=nym_node::metrics=debug` -- Use connection pooling for database connections -- Consider using `jemalloc` for better memory allocation performance \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7ec3531bb6d..11a1bfddf08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1259,6 +1274,16 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "classic-mceliece-rust" +version = "3.2.0" +source = "git+https://github.com/georgio/classic-mceliece-rust#f2f27048b621df103bbe64369a18174ffec04ae1" +dependencies = [ + "rand 0.9.2", + "sha3", + "zeroize", +] + [[package]] name = "coarsetime" version = "0.1.36" @@ -1432,6 +1457,16 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.9.2", +] + [[package]] name = "cosmos-sdk-proto" version = "0.26.1" @@ -1859,6 +1894,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", + "rand_core 0.6.4", "rustc_version 0.4.1", "serde", "subtle 2.6.1", @@ -3159,6 +3195,43 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "hax-lib" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -4107,6 +4180,15 @@ dependencies = [ "signature", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "keystream" version = "1.0.0" @@ -4185,6 +4267,213 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-ed25519" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "rand_core 0.9.3", + "tls_codec", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.2" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-psq" +version = "0.0.5" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-chacha20poly1305", + "libcrux-ecdh", + "libcrux-ed25519", + "libcrux-hkdf", + "libcrux-hmac", + "libcrux-kem", + "libcrux-ml-kem", + "libcrux-sha2", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-secrets", + "rand 0.9.2", +] + [[package]] name = "libm" version = "0.2.15" @@ -4814,6 +5103,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -5607,6 +5918,7 @@ dependencies = [ "nym-ecash-contract-common", "nym-gateway-requests", "nym-gateway-storage", + "nym-metrics", "nym-task", "nym-upgrade-mode-check", "nym-validator-client", @@ -5672,6 +5984,7 @@ dependencies = [ "bs58", "cipher", "ctr", + "curve25519-dalek", "digest 0.10.7", "ed25519-dalek", "generic-array 0.14.7", @@ -5804,6 +6117,7 @@ dependencies = [ "bincode", "bip39", "bs58", + "bytes", "dashmap", "defguard_wireguard_rs", "fastrand 2.3.0", @@ -5821,10 +6135,14 @@ dependencies = [ "nym-gateway-storage", "nym-id", "nym-ip-packet-router", + "nym-kcp", + "nym-lp", + "nym-metrics", "nym-mixnet-client", "nym-network-defaults", "nym-network-requester", "nym-node-metrics", + "nym-registration-common", "nym-sdk", "nym-service-provider-requests-common", "nym-sphinx", @@ -5898,6 +6216,7 @@ dependencies = [ "clap", "futures", "hex", + "nym-api-requests", "nym-authenticator-client", "nym-authenticator-requests", "nym-bandwidth-controller", @@ -5913,7 +6232,13 @@ dependencies = [ "nym-http-api-client-macro", "nym-ip-packet-client", "nym-ip-packet-requests", + "nym-lp", + "nym-mixnet-contract-common", + "nym-network-defaults", + "nym-node-requests", "nym-node-status-client", + "nym-registration-client", + "nym-registration-common", "nym-sdk", "nym-topology", "nym-validator-client", @@ -5922,6 +6247,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.12", + "time", "tokio", "tokio-util", "tracing", @@ -6204,6 +6530,48 @@ 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-kkt" +version = "0.1.0" +dependencies = [ + "aead", + "arc-swap", + "blake3", + "bytes", + "classic-mceliece-rust", + "criterion", + "curve25519-dalek", + "futures", + "libcrux-ecdh", + "libcrux-kem", + "libcrux-ml-kem", + "libcrux-psq", + "libcrux-sha3", + "libcrux-traits", + "nym-crypto", + "pin-project", + "rand 0.9.2", + "strum", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", + "zeroize", +] + [[package]] name = "nym-ledger" version = "0.1.0" @@ -6215,6 +6583,41 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "nym-lp" +version = "0.1.0" +dependencies = [ + "ansi_term", + "bincode", + "bs58", + "bytes", + "criterion", + "dashmap", + "libcrux-kem", + "libcrux-psq", + "libcrux-traits", + "num_enum", + "nym-crypto", + "nym-kkt", + "nym-lp-common", + "nym-sphinx", + "parking_lot", + "rand 0.8.5", + "rand 0.9.2", + "rand_chacha 0.3.1", + "serde", + "sha2 0.10.9", + "snow", + "thiserror 2.0.12", + "tls_codec", + "tracing", + "utoipa", +] + +[[package]] +name = "nym-lp-common" +version = "0.1.0" + [[package]] name = "nym-metrics" version = "0.1.0" @@ -6791,15 +7194,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", @@ -6812,10 +7221,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", ] @@ -7575,15 +7989,20 @@ dependencies = [ "defguard_wireguard_rs", "futures", "ip_network", + "ipnetwork", + "log", "nym-credential-verification", "nym-credentials-interface", "nym-crypto", "nym-gateway-requests", "nym-gateway-storage", + "nym-ip-packet-requests", + "nym-metrics", "nym-network-defaults", "nym-node-metrics", "nym-task", "nym-wireguard-types", + "rand 0.8.5", "thiserror 2.0.12", "tokio", "tokio-stream", @@ -8025,6 +8444,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "peg" version = "0.8.5" @@ -9770,6 +10195,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -10756,6 +11191,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tokio" version = "1.47.1" diff --git a/Cargo.toml b/Cargo.toml index 92f5fdbb7b9..609c443f913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,10 @@ members = [ "common/nym-cache", "common/nym-connection-monitor", "common/nym-id", + "common/nym-kcp", + "common/nym-lp", + "common/nym-lp-common", + "common/nym-kkt", "common/nym-metrics", "common/nym_offline_compact_ecash", "common/nymnoise", @@ -150,7 +154,7 @@ members = [ "tools/internal/contract-state-importer/importer-cli", "tools/internal/contract-state-importer/importer-contract", "tools/internal/mixnet-connectivity-check", -# "tools/internal/sdk-version-bump", + # "tools/internal/sdk-version-bump", "tools/internal/ssl-inject", "tools/internal/testnet-manager", "tools/internal/testnet-manager/dkg-bypass-contract", @@ -165,7 +169,7 @@ members = [ "wasm/mix-fetch", "wasm/node-tester", "wasm/zknym-lib", - "nym-gateway-probe" + "nym-gateway-probe", ] default-members = [ @@ -204,6 +208,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" @@ -243,6 +248,7 @@ criterion = "0.5" csv = "1.3.1" ctr = "0.9.1" cupid = "0.6.1" +curve25519-dalek = "4.1.3" dashmap = "5.5.3" # We want https://github.com/DefGuard/wireguard-rs/pull/64 , but there's no crates.io release being pushed out anymore defguard_wireguard_rs = { git = "https://github.com/DefGuard/wireguard-rs.git", rev = "v0.4.7" } @@ -282,7 +288,9 @@ inventory = "0.3.21" ip_network = "0.4.1" ipnetwork = "0.20" itertools = "0.14.0" -jwt-simple = { version = "0.12.12", default-features = false, features = ["pure-rust"] } +jwt-simple = { version = "0.12.12", default-features = false, features = [ + "pure-rust", +] } k256 = "0.13" lazy_static = "1.5.0" ledger-transport = "0.10.0" @@ -292,6 +300,7 @@ mime = "0.3.17" moka = { version = "0.12", features = ["future"] } nix = "0.27.1" notify = "5.1.0" +num_enum = "0.7.5" once_cell = "1.21.3" opentelemetry = "0.19.0" opentelemetry-jaeger = "0.18.0" @@ -338,6 +347,7 @@ test-with = { version = "0.15.4", default-features = false } tempfile = "3.20" thiserror = "2.0" time = "0.3.41" +tls_codec = "0.4.1" tokio = "1.47" tokio-postgres = "0.7" tokio-stream = "0.1.17" diff --git a/common/commands/src/validator/mixnet/operators/nymnode/bond_nymnode.rs b/common/commands/src/validator/mixnet/operators/nymnode/bond_nymnode.rs index 145e2458636..46c54417d39 100644 --- a/common/commands/src/validator/mixnet/operators/nymnode/bond_nymnode.rs +++ b/common/commands/src/validator/mixnet/operators/nymnode/bond_nymnode.rs @@ -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, + #[clap(long)] pub profit_margin_percent: Option, @@ -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); diff --git a/common/commands/src/validator/mixnet/operators/nymnode/nymnode_bonding_sign_payload.rs b/common/commands/src/validator/mixnet/operators/nymnode/nymnode_bonding_sign_payload.rs index e3b66e65bed..234c87b6bc0 100644 --- a/common/commands/src/validator/mixnet/operators/nymnode/nymnode_bonding_sign_payload.rs +++ b/common/commands/src/validator/mixnet/operators/nymnode/nymnode_bonding_sign_payload.rs @@ -25,6 +25,9 @@ pub struct Args { #[clap(long)] pub custom_http_api_port: Option, + #[clap(long, help = "LP (Lewes Protocol) listener port (default: 41264)")] + pub lp_port: Option, + #[clap(long)] pub profit_margin_percent: Option, @@ -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); diff --git a/common/commands/src/validator/mixnet/operators/nymnode/settings/update_config.rs b/common/commands/src/validator/mixnet/operators/nymnode/settings/update_config.rs index fb59924c9ea..c2f863e35a1 100644 --- a/common/commands/src/validator/mixnet/operators/nymnode/settings/update_config.rs +++ b/common/commands/src/validator/mixnet/operators/nymnode/settings/update_config.rs @@ -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, + + // 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 diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts index b39d3997e42..34c6534fdcc 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts +++ b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeConfigUpdate = { host: string | null, custom_http_port: number | null, restore_default_http_port: boolean, }; +export type NodeConfigUpdate = { host: string | null, custom_http_port: number | null, restore_default_http_port: boolean, +/** + * LP listener address for direct gateway connections (format: "host:port") + */ +lp_address: string | null, restore_default_lp_address: boolean, }; diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts index a1138279573..9cdd09cbb50 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts +++ b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts @@ -17,4 +17,9 @@ custom_http_port: number | null, /** * Base58-encoded ed25519 EdDSA public key. */ -identity_key: string, }; +identity_key: string, +/** + * 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" + */ +lp_address: string | null, }; diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/nym_node.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/nym_node.rs index 6fc172db09e..fbf6a21b3aa 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/nym_node.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/nym_node.rs @@ -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, // 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 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 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, + + // equivalent to setting `lp_address` to `None` + #[serde(default)] + pub restore_default_lp_address: bool, } #[cw_serde] diff --git a/common/credential-verification/Cargo.toml b/common/credential-verification/Cargo.toml index 06c45efdc55..07c41a49bfd 100644 --- a/common/credential-verification/Cargo.toml +++ b/common/credential-verification/Cargo.toml @@ -30,6 +30,7 @@ nym-crypto = { path = "../crypto", features = ["asymmetric"] } 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" } nym-upgrade-mode-check = { path = "../upgrade-mode-check" } diff --git a/common/credential-verification/src/ecash/mod.rs b/common/credential-verification/src/ecash/mod.rs index a5eac148676..fdd71c7e969 100644 --- a/common/credential-verification/src/ecash/mod.rs +++ b/common/credential-verification/src/ecash/mod.rs @@ -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) @@ -249,4 +253,8 @@ impl traits::EcashManager for MockEcashManager { } fn async_verify(&self, _ticket: ClientTicket) {} + + fn is_mock(&self) -> bool { + true + } } diff --git a/common/credential-verification/src/ecash/state.rs b/common/credential-verification/src/ecash/state.rs index 389ee98c68d..78cabf7a957 100644 --- a/common/credential-verification/src/ecash/state.rs +++ b/common/credential-verification/src/ecash/state.rs @@ -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(); diff --git a/common/credential-verification/src/ecash/traits.rs b/common/credential-verification/src/ecash/traits.rs index ae25016f193..fd0c7980a21 100644 --- a/common/credential-verification/src/ecash/traits.rs +++ b/common/credential-verification/src/ecash/traits.rs @@ -20,4 +20,10 @@ pub trait EcashManager { aggregated_verification_key: &VerificationKeyAuth, ) -> Result<(), EcashTicketError>; fn async_verify(&self, ticket: ClientTicket); + + /// Returns true if this is a mock ecash manager (for local testing). + /// Default implementation returns false. + fn is_mock(&self) -> bool { + false + } } diff --git a/common/credential-verification/src/lib.rs b/common/credential-verification/src/lib.rs index 430674c9916..cafc5bfcfdd 100644 --- a/common/credential-verification/src/lib.rs +++ b/common/credential-verification/src/lib.rs @@ -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::*; @@ -21,6 +22,10 @@ pub mod ecash; pub mod error; pub mod upgrade_mode; +// 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, @@ -64,6 +69,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(()) @@ -105,6 +111,9 @@ impl CredentialVerifier { } pub async fn verify(&mut self) -> Result { + let start = Instant::now(); + nym_metrics::inc!("ecash_verification_attempts"); + let received_at = OffsetDateTime::now_utc(); let spend_date = ecash_today(); @@ -113,15 +122,39 @@ 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); @@ -135,6 +168,8 @@ impl CredentialVerifier { .increase_bandwidth(bandwidth, cred_exp_date()) .await?; + nym_metrics::inc!("ecash_verification_success"); + Ok(self .bandwidth_storage_manager .client_bandwidth diff --git a/common/crypto/Cargo.toml b/common/crypto/Cargo.toml index 37a1e317e54..03b785494ce 100644 --- a/common/crypto/Cargo.toml +++ b/common/crypto/Cargo.toml @@ -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 } @@ -47,7 +48,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"] diff --git a/common/crypto/src/asymmetric/ed25519/mod.rs b/common/crypto/src/asymmetric/ed25519/mod.rs index 7862cf85bf6..d072b4f598d 100644 --- a/common/crypto/src/asymmetric/ed25519/mod.rs +++ b/common/crypto/src/asymmetric/ed25519/mod.rs @@ -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 { + 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); + } } diff --git a/common/crypto/src/kdf.rs b/common/crypto/src/kdf.rs new file mode 100644 index 00000000000..3edb2572990 --- /dev/null +++ b/common/crypto/src/kdf.rs @@ -0,0 +1,98 @@ +// Copyright 2025 - Nym Technologies SA +// 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); + } +} diff --git a/common/crypto/src/lib.rs b/common/crypto/src/lib.rs index 1dff7b82be0..3875fa7f81d 100644 --- a/common/crypto/src/lib.rs +++ b/common/crypto/src/lib.rs @@ -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; diff --git a/common/nym-kcp/Cargo.toml b/common/nym-kcp/Cargo.toml new file mode 100644 index 00000000000..beafab5ee91 --- /dev/null +++ b/common/nym-kcp/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "nym-kcp" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } + +[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" diff --git a/common/nym-kcp/bin/session/main.rs b/common/nym-kcp/bin/session/main.rs new file mode 100644 index 00000000000..ede675d5d7b --- /dev/null +++ b/common/nym-kcp/bin/session/main.rs @@ -0,0 +1,80 @@ +use bytes::BytesMut; +use log::info; +use nym_kcp::{packet::KcpPacket, session::KcpSession}; + +fn main() -> Result<(), Box> { + // 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(()) +} diff --git a/common/nym-kcp/bin/wire_format/main.rs b/common/nym-kcp/bin/wire_format/main.rs new file mode 100644 index 00000000000..6cde7c95c16 --- /dev/null +++ b/common/nym-kcp/bin/wire_format/main.rs @@ -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> { + // 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(()) +} diff --git a/common/nym-kcp/bin/wire_format/packets.txt b/common/nym-kcp/bin/wire_format/packets.txt new file mode 100644 index 00000000000..6cec9cd234c --- /dev/null +++ b/common/nym-kcp/bin/wire_format/packets.txt @@ -0,0 +1,10 @@ +packet 1 +packet 2 +packet 3 +packet 4 +packet 5 +packet 6 +packet 7 +packet 8 +packet 9 +packet 10 \ No newline at end of file diff --git a/common/nym-kcp/src/codec.rs b/common/nym-kcp/src/codec.rs new file mode 100644 index 00000000000..b6b69eee2b1 --- /dev/null +++ b/common/nym-kcp/src/codec.rs @@ -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, Self::Error> { + // We simply delegate to `KcpPacket::decode` + KcpPacket::decode(src).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } +} + +impl Encoder 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(()) + } +} diff --git a/common/nym-kcp/src/driver.rs b/common/nym-kcp/src/driver.rs new file mode 100644 index 00000000000..63fa32e8bcd --- /dev/null +++ b/common/nym-kcp/src/driver.rs @@ -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 { + Ok(self.session.conv) + } + + pub fn send(&mut self, data: &[u8]) { + self.session.send(data); + } + + pub fn input(&mut self, data: &[u8]) -> Result, 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 { + 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(), + } + } +} diff --git a/common/nym-kcp/src/error.rs b/common/nym-kcp/src/error.rs new file mode 100644 index 00000000000..c2bf415c978 --- /dev/null +++ b/common/nym-kcp/src/error.rs @@ -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), +} diff --git a/common/nym-kcp/src/lib.rs b/common/nym-kcp/src/lib.rs new file mode 100644 index 00000000000..27c71df5150 --- /dev/null +++ b/common/nym-kcp/src/lib.rs @@ -0,0 +1,5 @@ +pub mod codec; +pub mod driver; +pub mod error; +pub mod packet; +pub mod session; diff --git a/common/nym-kcp/src/packet.rs b/common/nym-kcp/src/packet.rs new file mode 100644 index 00000000000..0ab1c3b5953 --- /dev/null +++ b/common/nym-kcp/src/packet.rs @@ -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 for KcpCommand { + type Error = KcpError; + + fn try_from(value: u8) -> Result { + 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 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, +} + +#[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, + ) -> 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 { + 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, 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()); + } +} diff --git a/common/nym-kcp/src/session.rs b/common/nym-kcp/src/session.rs new file mode 100644 index 00000000000..f72cd19ef75 --- /dev/null +++ b/common/nym-kcp/src/session.rs @@ -0,0 +1,1778 @@ +use std::{ + cmp, + collections::VecDeque, + io::{self, Read, Write}, +}; + +use ansi_term::Color::Yellow; +use bytes::{Buf, BytesMut}; +use log::{debug, error, info, warn}; +use std::thread; + +use super::packet::{KcpCommand, KcpPacket}; + +/// Minimal KCP session that produces/consumes `KcpPacket`s +pub struct KcpSession { + pub conv: u32, + + // Basic send parameters + snd_nxt: u32, // next sequence to send + snd_una: u32, // first unacknowledged + snd_wnd: u16, // local send window + rmt_wnd: u16, // remote receive window (from packets) + snd_queue: VecDeque, + snd_buf: VecDeque, + + // Basic receive parameters + rcv_nxt: u32, // next sequence expected + rcv_wnd: u16, // local receive window + rcv_buf: VecDeque, + rcv_queue: VecDeque, + + // RTT calculation + rx_srtt: u32, + rx_rttval: u32, + rx_rto: u32, + rx_minrto: u32, + + // Timers + current: u32, // current clock (ms) + interval: u32, // flush interval + ts_flush: u32, // next flush timestamp + + // If you want to store outgoing packets from flush, do it here + out_pkts: Vec, + mtu: usize, + partial_read: Option, +} + +/// Internal segment type: similar to `KcpPacket` but includes metadata for retransmissions. +#[derive(Debug, Clone)] +struct Segment { + sn: u32, + frg: u8, + ts: u32, + resendts: u32, + rto: u32, + xmit: u32, // how many times sent + data: Vec, +} + +impl Segment { + #[allow(dead_code)] + fn new(sn: u32, frg: u8, data: Vec) -> Self { + Segment { + sn, + frg, + ts: 0, + resendts: 0, + rto: 0, + xmit: 0, + data, + } + } +} + +impl Default for KcpSession { + fn default() -> Self { + KcpSession { + conv: 0, + snd_nxt: 0, + snd_una: 0, + snd_wnd: 32, + rmt_wnd: 128, + snd_queue: VecDeque::new(), + snd_buf: VecDeque::new(), + + rcv_nxt: 0, + rcv_wnd: 128, + rcv_buf: VecDeque::new(), + rcv_queue: VecDeque::new(), + + rx_srtt: 0, + rx_rttval: 0, + rx_rto: 3000, + rx_minrto: 3000, + + current: 0, + interval: 100, + ts_flush: 100, + + out_pkts: Vec::new(), + mtu: 1376, + partial_read: None, + } + } +} + +impl KcpSession { + pub fn ts_current(&self) -> u32 { + self.current + } + + pub fn ts_flush(&self) -> u32 { + self.ts_flush + } + + fn available_send_segments(&self) -> usize { + // A naive approach: if `snd_queue` has length L + // and local window is `snd_wnd`, we can add `snd_wnd - L` more segments + let used = self.snd_queue.len(); + let allowed = self.snd_wnd as usize; + allowed.saturating_sub(used) + } + + /// Create a new KCP session with a specified conv ID and default MSS. + pub fn new(conv: u32) -> Self { + KcpSession { + conv, + ..Default::default() + } + } + + /// If you want to let the user set the mtu: + pub fn set_mtu(&mut self, mtu: usize) { + self.mtu = mtu; + } + + /// Set the update interval (flush interval) in milliseconds + pub fn set_interval(&mut self, interval: u32) { + let interval = interval.clamp(10, 5000); + self.interval = interval; + } + + /// Manually set the minimal RTO + pub fn set_min_rto(&mut self, rto: u32) { + self.rx_minrto = rto; + } + + pub fn send(&mut self, mut data: &[u8]) { + debug!("Sending data, len: {}", data.len()); + + if data.is_empty() { + return; + } + + // How many segments do we need? + // If data <= mss, it's 1; otherwise multiple. + let total_len = data.len(); + let count = if total_len <= self.mtu { + 1 + } else { + total_len.div_ceil(self.mtu) + }; + + debug!("Will send {} fragments", count); + + // Build each fragment + for i in 0..count { + let size = std::cmp::min(self.mtu, data.len()); + let chunk = &data[..size]; + + // KCP fragment numbering is REVERSED - last fragment has frg=0, + // first has frg=count-1. This allows receiver to know total count from first packet. + // In KCP, `frg` is set to the remaining fragments in reverse order. + // i.e., the last fragment has frg=0, the first has frg=count-1. + let frg = (count - i - 1) as u8; + + let seg = Segment { + sn: self.snd_nxt, + frg, + ts: 0, + resendts: 0, + rto: 0, + xmit: 0, + data: chunk.to_vec(), + }; + + debug!("Sending segment, sn: {}, frg: {}", seg.sn, seg.frg); + + self.snd_queue.push_back(seg); + debug!("snd_queue len: {}", self.snd_queue.len()); + + self.snd_nxt = self.snd_nxt.wrapping_add(1); + + // Advance the slice + data = &data[size..]; + + debug!("Remaining data, len: {}", data.len()); + } + } + + /// Input a newly received packet from the network (after decryption). + pub fn input(&mut self, pkt: &KcpPacket) { + debug!( + "[ConvID: {}, Thread: {:?}] input: Received packet - cmd: {:?}, sn: {}, frg: {}, wnd: {}, ts: {}, una: {}", + self.conv, + thread::current().id(), + pkt.cmd(), + pkt.sn(), + pkt.frg(), + pkt.wnd(), + pkt.ts(), + pkt.una() + ); + + // Check conv + if pkt.conv() != self.conv { + error!( + "Received packet with wrong conv: {} != {}", + pkt.conv(), + self.conv + ); + return; + } + + // Update remote window + self.rmt_wnd = pkt.wnd(); + + // Parse UNA first - crucial for clearing snd_buf before processing ACKs/data + self.parse_una(pkt.una()); + + // Log snd_buf state before specific command processing + let pre_cmd_sns: Vec = self.snd_buf.iter().map(|seg| seg.sn).collect(); + debug!( + "[ConvID: {}, Thread: {:?}] input: Pre-command processing snd_buf (len={}): {:?}", + self.conv, + thread::current().id(), + self.snd_buf.len(), + pre_cmd_sns + ); + + match pkt.cmd() { + KcpCommand::Ack => { + self.parse_ack(pkt.sn(), pkt.ts()); + } + KcpCommand::Push => { + debug!("Received push, sn: {}, frg: {}", pkt.sn(), pkt.frg()); + // Data + // self.ack_push(pkt.sn(), self.current); // Send ack eventually + self.ack_push(pkt.sn(), pkt.ts()); + self.parse_data(pkt); + } + KcpCommand::Wask => { + error!("Received window probe, this is unimplemented"); + // Window probe from remote -> we'll respond with Wins + // Not implemented in this minimal + } + KcpCommand::Wins => { + error!("Received window size, this is unimplemented"); + // Remote sends window size + // Not implemented in this minimal + } + } + } + + /// Update KCP state with `delta_ms` since the last call. + /// This increments `current` by `delta_ms` and performs any flushing logic if needed. + pub fn update(&mut self, delta_ms: u32) { + // 1) Advance our "current time" by delta_ms + self.current = self.current.saturating_add(delta_ms); + + // 2) Check if it's time to flush + if !self.should_flush() { + // not yet time to flush + return; + } + + self.ts_flush += self.interval; + if self.ts_flush < self.current { + self.ts_flush = self.current + self.interval; + } + + // 3) Move segments from snd_queue -> snd_buf if window allows + // debug!("send queue len: {}", self.snd_queue.len()); + self.move_queue_to_buf(); + // debug!("send buf len: {}", self.snd_buf.len()); + // 4) Check for retransmissions, produce outgoing packets + self.flush_outgoing(); + // debug!("send buf len: {}", self.snd_buf.len()); + } + + /// Retrieve any newly created packets that need sending (e.g., data or ack). + /// After calling `update`, call this to get the `KcpPacket`s. Then you can + /// encrypt them and actually write them out (UDP, file, etc.). + pub fn fetch_outgoing(&mut self) -> Vec { + let mut result = Vec::new(); + std::mem::swap(&mut result, &mut self.out_pkts); // take ownership + result + } + + pub fn fetch_incoming(&mut self) -> Vec { + let mut result = Vec::new(); + while let Some(message) = self.rcv_queue.pop_front() { + result.push(message); + } + result + } + + pub fn recv(&mut self, out: &mut [u8]) -> usize { + if out.is_empty() { + return 0; + } + + let mut read_bytes = 0; + + // 1) If there's leftover partial data, read from that first + if let Some(ref mut leftover) = self.partial_read { + let to_copy = std::cmp::min(out.len(), leftover.len()); + out[..to_copy].copy_from_slice(&leftover[..to_copy]); + read_bytes += to_copy; + // Remove the consumed portion from leftover + leftover.advance(to_copy); + + if leftover.is_empty() { + // If we've exhausted the leftover, clear it + self.partial_read = None; + } + + // If we've already filled 'out', return + if read_bytes == out.len() { + return read_bytes; + } + } + + // 2) If we still have space, consume messages from rcv_queue + while read_bytes < out.len() { + // If there's no complete message left, break + let mut msg = match self.rcv_queue.pop_front() { + None => break, + Some(m) => m, + }; + + let space_left = out.len() - read_bytes; + if msg.len() <= space_left { + // The entire message fits into 'out' + out[read_bytes..read_bytes + msg.len()].copy_from_slice(&msg); + read_bytes += msg.len(); + } else { + // msg is larger than what's left in 'out' + out[read_bytes..].copy_from_slice(&msg[..space_left]); + read_bytes += space_left; + + // Keep the leftover part of 'msg' in partial_read + msg.advance(space_left); + self.partial_read = Some(msg); + + // We've filled 'out', so stop + break; + } + } + + read_bytes + } + + //--------------------------------------------------------------------------------- + // Internal methods + + fn should_flush(&self) -> bool { + // flush if current >= ts_flush + // or if we've never updated + self.current >= self.ts_flush + } + + /// Move segments from `snd_queue` into `snd_buf` respecting window + fn move_queue_to_buf(&mut self) { + // Calculate the congestion window (cwnd) + let cwnd = std::cmp::min(self.snd_wnd, self.rmt_wnd); + + // In real KCP, we check against the number of unacknowledged segments: + // while self.snd_nxt < self.snd_una + cwnd { ... } + // Here, we approximate by checking the current length of snd_buf against cwnd. + while let Some(mut seg) = self.snd_queue.pop_front() { + // Check if adding this segment would exceed the congestion window + if (self.snd_buf.len() as u16) >= cwnd { + // Effective window is full + self.snd_queue.push_front(seg); // Put it back + break; + } + // init rto + seg.xmit = 0; + seg.rto = self.rx_rto; + seg.resendts = 0; // will set later + seg.ts = self.current; + self.snd_buf.push_back(seg); + } + } + + /// Build KcpPacket(s) for segments needing send or retransmit. + fn flush_outgoing(&mut self) { + // Log current snd_buf state before iterating + // let current_sns: Vec = self.snd_buf.iter().map(|seg| seg.sn).collect(); + // debug!( + // "[ConvID: {}, Thread: {:?}] flush_outgoing: Checking snd_buf (len={}): {:?}", + // self.conv, + // thread::current().id(), + // self.snd_buf.len(), + // current_sns + // ); + + for seg in &mut self.snd_buf { + let mut need_send = false; + if seg.xmit == 0 { + // never sent + need_send = true; + seg.xmit = 1; + seg.resendts = self.current + seg.rto; + } else if self.current >= seg.resendts { + // time to retransmit + need_send = true; + seg.xmit += 1; + // Exponential backoff: double RTO for this segment + seg.rto *= 2; + // Clamp to the session's maximum RTO (hardcoded as 60s for now) + const MAX_RTO: u32 = 60000; // Same as used in update_rtt + if seg.rto > MAX_RTO { + seg.rto = MAX_RTO; + } + seg.resendts = self.current + seg.rto; + info!( + "{}", + Yellow.paint(format!( + "Retransmit conv_id: {}, sn: {}, frg: {}", + self.conv, seg.sn, seg.frg + )) + ); + } + + if need_send { + // Make a KcpPacket + let pkt = KcpPacket::new( + self.conv, + KcpCommand::Push, + seg.frg, + self.rcv_wnd, + seg.ts, // original send timestamp + seg.sn, + self.rcv_nxt, // self.rcv_nxt for ack + seg.data.clone(), + ); + self.out_pkts.push(pkt); + + // if too many xmit => dead_link check, etc. + } + } + // Possibly build ack packets + // In real KCP, you'd track pending ack and flush them too. + // For minimal example, we skip that or do it inline in parse_data. + } + + fn parse_una(&mut self, una: u32) { + debug!( + "[ConvID: {}, Thread: {:?}] parse_una(una={})", + self.conv, + thread::current().id(), + una + ); + // Remove *all* segments in snd_buf where seg.sn < una + // KCP's UNA confirms receipt of all segments *before* it. + let original_len = self.snd_buf.len(); + { + let pre_retain_sns: Vec = self.snd_buf.iter().map(|seg| seg.sn).collect(); + debug!( + "[ConvID: {}, Thread: {:?}] parse_una: Pre-retain snd_buf (len={}): {:?}", + self.conv, + thread::current().id(), + original_len, + pre_retain_sns + ); + } + self.snd_buf.retain(|seg| seg.sn >= una); + let removed_count = original_len.saturating_sub(self.snd_buf.len()); + + // Log state *after* retain + let post_retain_sns: Vec = self.snd_buf.iter().map(|seg| seg.sn).collect(); + debug!( + "[ConvID: {}, Thread: {:?}] parse_una: Post-retain snd_buf (len={}): {:?}", + self.conv, + thread::current().id(), + self.snd_buf.len(), + post_retain_sns + ); + // Corrected format string arguments for the removed count log + debug!( + "[ConvID: {}, Thread: {:?}] parse_una(una={}): Removed {} segment(s) from snd_buf ({} -> {}). Remaining sns: {:?}", + self.conv, + thread::current().id(), + una, + removed_count, + original_len, + self.snd_buf.len(), + post_retain_sns + ); + + if removed_count > 0 { + // Use trace level if no segments were removed but buffer wasn't empty + debug!( + "[ConvID: {}, Thread: {:?}] parse_una(una={}): No segments removed from snd_buf (len={}). Remaining sns: {:?}", + self.conv, + thread::current().id(), + una, + original_len, + self.snd_buf.iter().map(|s| s.sn).collect::>() + ); + } + + // Update the known acknowledged sequence number. + // Use max to prevent out-of-order packets with older UNA from moving snd_una backwards. + self.snd_una = cmp::max(self.snd_una, una); + } + + fn parse_ack(&mut self, sn: u32, ts: u32) { + debug!( + "[ConvID: {}, Thread: {:?}] Parsing ack, sn: {}, ts: {}", + self.conv, + thread::current().id(), + sn, + ts + ); + // find the segment in snd_buf + if let Some(pos) = self.snd_buf.iter().position(|seg| seg.sn == sn) { + let seg = self.snd_buf.remove(pos).unwrap(); + debug!( + "[ConvID: {}, Thread: {:?}] Acked segment, sn: {}, frg: {}", + self.conv, + thread::current().id(), + sn, + seg.frg + ); + // update RTT + let rtt = self.current.saturating_sub(ts); + self.update_rtt(rtt); + } else { + // Log if the segment was NOT found + let current_sns: Vec = self.snd_buf.iter().map(|s| s.sn).collect(); + warn!( + "[ConvID: {}, Thread: {:?}] parse_ack: ACK received for sn={}, but segment not found in snd_buf (len={}): {:?}", + self.conv, + thread::current().id(), + sn, + self.snd_buf.len(), + current_sns + ); + } + } + + fn parse_data(&mut self, pkt: &KcpPacket) { + // Insert into rcv_buf if pkt.sn in [rcv_nxt .. rcv_nxt + rcv_wnd) + if pkt.sn() >= self.rcv_nxt + self.rcv_wnd as u32 { + // out of window + return; + } + if pkt.sn() < self.rcv_nxt { + // already got it, discard + return; + } + + // Check if we have it + let mut insert_idx = self.rcv_buf.len(); + for (i, seg) in self.rcv_buf.iter().enumerate() { + #[allow(clippy::comparison_chain)] + if pkt.sn() < seg.sn { + insert_idx = i; + break; + } else if pkt.sn() == seg.sn { + // duplicate + return; + } + } + + let seg = Segment { + sn: pkt.sn(), + frg: pkt.frg(), + ts: pkt.ts(), + resendts: 0, + rto: 0, + xmit: 0, + data: pkt.data().into(), + }; + self.rcv_buf.insert(insert_idx, seg); + + // Move ready segments from rcv_buf -> rcv_queue + self.move_buf_to_queue(); + } + + fn move_buf_to_queue(&mut self) { + // Loop as long as we can potentially extract a complete message from the front + loop { + // Check if the buffer starts with the next expected sequence number + if self.rcv_buf.is_empty() || self.rcv_buf[0].sn != self.rcv_nxt { + break; // Cannot start assembling a message now + } + + // Scan ahead in rcv_buf to find if a complete message exists contiguously + let mut end_segment_index = None; + let mut expected_sn = self.rcv_nxt; + let mut message_data_len = 0; + + for (idx, seg) in self.rcv_buf.iter().enumerate() { + if seg.sn != expected_sn { + // Found a gap before completing a message + end_segment_index = None; + break; + } + message_data_len += seg.data.len(); + if seg.frg == 0 { + // Found the last fragment of a message + end_segment_index = Some(idx); + break; + } + expected_sn = expected_sn.wrapping_add(1); + } + + // If we didn't find a complete message sequence at the front + if end_segment_index.is_none() { + break; + } + + let end_idx = end_segment_index.unwrap(); + + // We found a complete message spanning indices 0..=end_idx + // Assemble it and move to rcv_queue + let mut message_buf = BytesMut::with_capacity(message_data_len); + let mut final_sn = 0; + for _ in 0..=end_idx { + // pop_front is efficient for VecDeque + let seg = self.rcv_buf.pop_front().unwrap(); + message_buf.extend_from_slice(&seg.data); + final_sn = seg.sn; + } + + // Push the fully assembled message + self.rcv_queue.push_back(message_buf); + + // Update the next expected sequence number + self.rcv_nxt = final_sn.wrapping_add(1); + + // Loop again to see if the *next* message is also ready + } + } + + fn ack_push(&mut self, sn: u32, ts: u32) { + debug!("Acking, sn: {}, ts: {}", sn, ts); + let pkt = KcpPacket::new( + self.conv, + KcpCommand::Ack, + 0, + self.rcv_wnd, + ts, + sn, + self.rcv_nxt, // next expected + Vec::new(), + ); + self.out_pkts.push(pkt); + } + + fn update_rtt(&mut self, rtt: u32) { + if self.rx_srtt == 0 { + self.rx_srtt = rtt; + self.rx_rttval = rtt / 2; + } else { + let delta = rtt.abs_diff(self.rx_srtt); + self.rx_rttval = (3 * self.rx_rttval + delta) / 4; + self.rx_srtt = (7 * self.rx_srtt + rtt) / 8; + if self.rx_srtt < 1 { + self.rx_srtt = 1; + } + } + let rto = self.rx_srtt + cmp::max(self.interval, 4 * self.rx_rttval); + self.rx_rto = rto.clamp(self.rx_minrto, 60000); + } +} + +impl Read for KcpSession { + /// Reads data from the KCP session into `buf`. + /// + /// If there's no data in `rcv_queue`, it returns `Ok(0)`, + /// indicating no more data is currently available. + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.recv(buf); + // If `n == 0`, it means there's no data right now. + // For a standard `Read` trait, returning `Ok(0)` indicates EOF or no data available. + Ok(n) + } +} + +impl Write for KcpSession { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // If there's no data, trivially done + if buf.is_empty() { + return Ok(0); + } + + // 1) How many segments can we add right now? + let avail_segs = self.available_send_segments(); + if avail_segs == 0 { + // We have no space to queue even a single segment. + // Return a WouldBlock error so the caller knows they should retry later. + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "Send window is full", + )); + } + + // 2) How many segments would be needed to store all of `buf`? + // We have an `mtu` that we use in `send()` to break data up. + let needed_segs = buf.len().div_ceil(self.mtu); + + // 3) How many segments can we actually accept? + let accept_segs = needed_segs.min(avail_segs); + + // 4) If we accept N segments, that corresponds to `N * mtu` bytes (or the remainder if the buffer is smaller). + let max_bytes = accept_segs * self.mtu; + // But the buffer might be smaller than that, so clamp to `buf.len()`. + let to_write = max_bytes.min(buf.len()); + + // 5) If `to_write` is 0 but `avail_segs > 0`, that means + // the buffer is extremely small (less than 1?), or some edge case. + // Typically won't happen if `buf.len() > 0` and `avail_segs >= 1`. + if to_write == 0 { + return Ok(0); + } + + // 6) Actually queue that many bytes. + let data_slice = &buf[..to_write]; + self.send(data_slice); + + // 7) Return how many bytes we queued + Ok(to_write) + } + + fn flush(&mut self) -> io::Result<()> { + // KCP handles flush in `update()`, so no-op or + // force a flush if you want immediate + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::{KcpCommand, KcpPacket}; + use bytes::{Bytes, BytesMut}; + use env_logger; + use log::debug; + use std::io::Write; + + fn init_logger() { + let _ = env_logger::builder().is_test(true).try_init(); + } + + #[test] + fn test_out_of_order_delivery_completes_correctly() { + let conv_id = 12345; + let mut sender = KcpSession::new(conv_id); + let mut receiver = KcpSession::new(conv_id); + + // Set small MTU to force fragmentation + let mtu = 20; // Small enough to split our message + sender.set_mtu(mtu); + + // Message that will be fragmented + let message = b"This message requires multiple KCP segments"; + let message_len = message.len(); + + // Send the message + sender.send(message); + + // Trigger update to move segments to snd_buf and create packets + // Use the session's interval to ensure ts_flush is met + sender.update(sender.interval); + let packets = sender.fetch_outgoing(); + assert!(packets.len() > 1, "Message should have been fragmented"); + + // Simulate out-of-order delivery: Deliver first and last packets only + let first_packet = packets[0].clone(); + let last_packet = packets.last().unwrap().clone(); + + println!( + "Receiver state before any input: rcv_nxt={}, rcv_buf_len={}, rcv_queue_len={}", + receiver.rcv_nxt, + receiver.rcv_buf.len(), + receiver.rcv_queue.len() + ); + + println!("Inputting first packet (sn={})", first_packet.sn()); + receiver.input(&first_packet); + receiver.update(0); // Process input + println!( + "Receiver state after first packet: rcv_nxt={}, rcv_buf_len={}, rcv_queue_len={}", + receiver.rcv_nxt, + receiver.rcv_buf.len(), + receiver.rcv_queue.len() + ); + + // The original bug would potentially push the first fragment here. + // We assert that no complete message is available yet. + let mut recv_buffer = BytesMut::with_capacity(message_len + 100); + recv_buffer.resize(message_len + 100, 0); // Initialize buffer + let bytes_read_partial = receiver.recv(recv_buffer.as_mut()); + assert_eq!( + bytes_read_partial, 0, + "Receiver should not have data yet (only first fragment received)" + ); + assert!( + receiver.rcv_queue.is_empty(), + "Receive queue should be empty" + ); + + println!("Inputting last packet (sn={})", last_packet.sn()); + receiver.input(&last_packet); + receiver.update(0); // Process input + println!( + "Receiver state after last packet: rcv_nxt={}, rcv_buf_len={}, rcv_queue_len={}", + receiver.rcv_nxt, + receiver.rcv_buf.len(), + receiver.rcv_queue.len() + ); + + // Still no complete message should be available + let bytes_read_partial2 = receiver.recv(recv_buffer.as_mut()); + assert_eq!( + bytes_read_partial2, 0, + "Receiver should not have data yet (first and last fragments received, middle missing)" + ); + assert!( + receiver.rcv_queue.is_empty(), + "Receive queue should still be empty" + ); + + // Now, deliver the missing middle packets + let middle_packets = packets[1..packets.len() - 1].to_vec(); + if !middle_packets.is_empty() { + println!( + "Inputting middle packets (sn={:?})", + middle_packets.iter().map(|p| p.sn()).collect::>() + ); + for pkt in middle_packets { + receiver.input(&pkt); + } + receiver.update(0); // Process input + } + println!( + "Receiver state after middle packets: rcv_nxt={}, rcv_buf_len={}, rcv_queue_len={}", + receiver.rcv_nxt, + receiver.rcv_buf.len(), + receiver.rcv_queue.len() + ); + + // NOW the complete message should be available + let bytes_read_final = receiver.recv(recv_buffer.as_mut()); + assert_eq!( + bytes_read_final, message_len, + "Receiver should have the complete message now" + ); + assert_eq!( + &recv_buffer[..bytes_read_final], + message, + "Received message does not match sent message" + ); + + // Check if queue is empty after reading + assert!( + receiver.rcv_queue.is_empty(), + "Receive queue should be empty after reading the message" + ); + + // Verify no more data + let bytes_read_after = receiver.recv(recv_buffer.as_mut()); + assert_eq!( + bytes_read_after, 0, + "Receiver should have no more data after reading the message" + ); + } + + #[test] + fn test_congestion_window_limits_send_buffer() { + init_logger(); + let conv = 123; + let mut session = KcpSession::new(conv); + session.set_mtu(50); + + session.snd_wnd = 10; + session.rmt_wnd = 5; + let initial_cwnd = std::cmp::min(session.snd_wnd, session.rmt_wnd); + debug!( + "Initial state: snd_wnd={}, rmt_wnd={}, calculated cwnd={}", + session.snd_wnd, session.rmt_wnd, initial_cwnd + ); + + let data = Bytes::from(vec![1u8; 400]); + session.send(&data); + + assert_eq!( + session.snd_queue.len(), + 8, + "Should have 8 segments in queue initially" + ); + assert_eq!( + session.snd_buf.len(), + 0, + "Send buffer should be empty initially" + ); + + // Call update to move segments based on initial cwnd - *Use non-zero time* + session.update(session.interval); // Use interval to trigger flush + debug!( + "After update 1: snd_buf_len={}, snd_queue_len={}", + session.snd_buf.len(), + session.snd_queue.len() + ); + + assert_eq!( + session.snd_buf.len(), + initial_cwnd as usize, + "Send buffer should be limited by initial cwnd (5)" + ); + assert_eq!( + session.snd_queue.len(), + 8 - initial_cwnd as usize, + "Queue should have remaining 3 segments" + ); + + let new_rmt_wnd = 8; + let ack_packet = KcpPacket::new( + conv, + KcpCommand::Ack, + 0, + new_rmt_wnd, + 0, + 0, + session.rcv_nxt, + Vec::new(), + ); + session.input(&ack_packet); + assert_eq!( + session.rmt_wnd, new_rmt_wnd, + "Remote window should be updated to 8" + ); + + let new_cwnd = std::cmp::min(session.snd_wnd, session.rmt_wnd); + debug!( + "After ACK: snd_wnd={}, rmt_wnd={}, calculated cwnd={}", + session.snd_wnd, session.rmt_wnd, new_cwnd + ); + + // Call update again to move more segments based on the new cwnd - *Use non-zero time* + session.update(session.interval); // Use interval to trigger flush + debug!( + "After update 2: snd_buf_len={}, snd_queue_len={}", + session.snd_buf.len(), + session.snd_queue.len() + ); + + // Check that snd_buf now contains segments up to the new cwnd (8) + // The total number of segments should be 7 (initial 5 - 1 acked + 3 moved from queue) + let _expected_buf_len_after_ack = initial_cwnd as usize - 1 + (8 - initial_cwnd as usize); + assert_eq!( + session.snd_buf.len(), + 7, + "Send buffer should contain 7 segments after acking sn=0 and refilling" + ); + assert_eq!( + session.snd_queue.len(), + 0, + "Queue should be empty as all remaining segments were moved" + ); + + let mut session2 = KcpSession::new(conv + 1); + session2.set_mtu(50); + session2.snd_wnd = 3; + session2.rmt_wnd = 10; + let cwnd2 = std::cmp::min(session2.snd_wnd, session2.rmt_wnd); + debug!( + "Scenario 3: snd_wnd={}, rmt_wnd={}, calculated cwnd={}", + session2.snd_wnd, session2.rmt_wnd, cwnd2 + ); + + let data2 = Bytes::from(vec![5u8; 200]); + session2.send(&data2); + assert_eq!( + session2.snd_queue.len(), + 4, + "Session 2: Should have 4 segments in queue" + ); + + // Call update to move segments based on cwnd2 - *Use non-zero time* + session2.update(session2.interval); // Use interval to trigger flush + debug!( + "Scenario 3 After update: snd_buf_len={}, snd_queue_len={}", + session2.snd_buf.len(), + session2.snd_queue.len() + ); + + assert_eq!( + session2.snd_buf.len(), + cwnd2 as usize, + "Session 2: Send buffer should be limited by snd_wnd (3)" + ); + assert_eq!( + session2.snd_queue.len(), + 4 - cwnd2 as usize, + "Session 2: Queue should have remaining 1 segment" + ); + } + + #[test] + fn test_segment_retransmission_after_rto() { + init_logger(); + let conv = 456; + let mut session = KcpSession::new(conv); + session.set_mtu(50); + + let data = Bytes::from(vec![2u8; 30]); // Single segment + session.send(&data); + assert_eq!(session.snd_queue.len(), 1, "Should have 1 segment in queue"); + + // Initial update moves to snd_buf and prepares the first packet + session.update(session.interval); + assert_eq!(session.snd_buf.len(), 1, "Segment should be in send buffer"); + assert_eq!(session.snd_queue.len(), 0, "Queue should be empty"); + + // Check segment details + let segment = session + .snd_buf + .front() + .expect("Segment must be in buffer") + .clone(); // Clone for inspection + let initial_rto = session.rx_rto; + let _expected_resendts = session.current + initial_rto; + assert_eq!(segment.xmit, 1, "Initial transmit count should be 1"); + assert_eq!( + segment.rto, initial_rto, + "Segment RTO should match session RTO" + ); + // Note: The actual resendts is set *inside* flush_outgoing AFTER moving to buf. + // We need to call fetch_outgoing to ensure flush_outgoing ran fully. + + debug!( + "Initial state: current={}, interval={}, rto={}, segment_sn={}", + session.current, session.interval, initial_rto, segment.sn + ); + + // Fetch and discard the first packet (simulate loss) + let initial_packets = session.fetch_outgoing(); + assert_eq!( + initial_packets.len(), + 1, + "Should have fetched 1 packet initially" + ); + assert_eq!( + initial_packets[0].sn(), + segment.sn, + "Packet SN should match segment SN" + ); + debug!("Simulated loss of packet with sn={}", segment.sn); + + // We need the exact resend timestamp set by flush_outgoing + let segment_in_buf = session + .snd_buf + .front() + .expect("Segment must still be in buffer"); + let actual_resendts = segment_in_buf.resendts; + debug!("Segment resendts timestamp: {}", actual_resendts); + assert!( + actual_resendts > session.current, + "Resend timestamp should be in the future" + ); + + // Advance time to just before the retransmission timestamp + let time_to_advance_almost = actual_resendts + .saturating_sub(session.current) + .saturating_sub(1); + if time_to_advance_almost > 0 { + session.update(time_to_advance_almost); + debug!( + "Advanced time by {}, current is now {}", + time_to_advance_almost, session.current + ); + let packets_before_rto = session.fetch_outgoing(); + assert!( + packets_before_rto.is_empty(), + "Should not retransmit before RTO expires" + ); + } + + // Advance time past the retransmission timestamp + let time_to_advance_past_rto = session.interval; // Advance by interval to ensure flush happens + session.update(time_to_advance_past_rto); + debug!( + "Advanced time by {}, current is now {}, should be >= {}", + time_to_advance_past_rto, session.current, actual_resendts + ); + assert!( + session.current >= actual_resendts, + "Current time should now be past resendts" + ); + + // Fetch outgoing packets - should contain the retransmission + let retransmitted_packets = session.fetch_outgoing(); + assert_eq!( + retransmitted_packets.len(), + 1, + "Should have retransmitted 1 packet" + ); + assert_eq!( + retransmitted_packets[0].sn(), + segment.sn, + "Retransmitted packet SN should match original" + ); + + // Verify transmit count increased + let segment_after_retransmit = session + .snd_buf + .front() + .expect("Segment must still be in buffer after retransmit"); + assert_eq!( + segment_after_retransmit.xmit, 2, + "Transmit count (xmit) should be 2 after retransmission" + ); + debug!( + "Retransmission confirmed for sn={}, xmit={}", + segment_after_retransmit.sn, segment_after_retransmit.xmit + ); + } + + #[test] + fn test_ack_removes_segment_from_send_buffer() { + init_logger(); + let conv = 789; + let mut session = KcpSession::new(conv); + session.set_mtu(50); + + let data = Bytes::from(vec![3u8; 40]); // Single segment + session.send(&data); + assert_eq!(session.snd_queue.len(), 1, "Should have 1 segment in queue"); + + // Update to move to snd_buf + session.update(session.interval); + assert_eq!(session.snd_buf.len(), 1, "Segment should be in send buffer"); + assert_eq!(session.snd_queue.len(), 0, "Queue should be empty"); + + // Get segment details (sn and ts are needed for the ACK) + // Need ts from *after* flush_outgoing has run, which happens in update/fetch + let _initial_packet = session.fetch_outgoing(); // Clears out_pkts and ensures ts is set + assert_eq!(_initial_packet.len(), 1, "Should have created one packet"); + let segment_in_buf = session + .snd_buf + .front() + .expect("Segment should be in buffer"); + let sn_to_ack = segment_in_buf.sn; + let ts_for_ack = segment_in_buf.ts; // Timestamp when segment was originally sent + debug!( + "Segment sn={} ts={} is in snd_buf. Simulating ACK.", + sn_to_ack, ts_for_ack + ); + + // Create ACK packet + let ack_packet = KcpPacket::new( + conv, + KcpCommand::Ack, + 0, // frg (unused for ACK) + session.rcv_wnd, // Sender's current rcv_wnd (doesn't matter much for this test) + ts_for_ack, // ts must match the segment's ts for RTT calculation + sn_to_ack, // sn being acknowledged + session.rcv_nxt, // una (doesn't matter much for this test) + Vec::new(), // data (empty for ACK) + ); + + // Input the ACK + session.input(&ack_packet); + + // Verify the segment was removed from snd_buf + assert!( + session.snd_buf.is_empty(), + "snd_buf should be empty after ACK processing" + ); + debug!("ACK processed successfully, snd_buf is empty."); + } + + #[test] + fn test_ack_updates_rtt() { + init_logger(); + let conv = 101; + let mut session = KcpSession::new(conv); + session.set_mtu(50); + + let initial_rto = session.rx_rto; + debug!("Initial RTO: {}", initial_rto); + // Set rx_minrto low for this test to ensure the calculated RTO isn't clamped + // back to the initial_rto if the defaults were high. + session.rx_minrto = 100; // Ensure calculated RTO (likely ~150ms) is > minrto + + let data = Bytes::from(vec![4u8; 20]); // Single segment + session.send(&data); + + // Update to move to snd_buf and prepare packet + session.update(session.interval); + assert_eq!(session.snd_buf.len(), 1, "Segment should be in send buffer"); + + // Fetch packet to ensure ts is set correctly in the segment + let _packet = session.fetch_outgoing(); + assert_eq!(_packet.len(), 1, "Should have one packet"); + let segment_in_buf = session + .snd_buf + .front() + .expect("Segment should still be in buffer"); + let sn_to_ack = segment_in_buf.sn; + let ts_for_ack = segment_in_buf.ts; + + // Simulate RTT by advancing time *before* receiving ACK + let simulated_rtt = 50; // ms + session.update(simulated_rtt); + debug!( + "Advanced time by {}ms, current is now {}", + simulated_rtt, session.current + ); + + // Create ACK packet + let ack_packet = KcpPacket::new( + conv, + KcpCommand::Ack, + 0, // frg + session.rcv_wnd, + ts_for_ack, // Original timestamp from segment + sn_to_ack, // SN being acked + session.rcv_nxt, // una + Vec::new(), // data + ); + + // Input the ACK - this triggers parse_ack -> update_rtt + session.input(&ack_packet); + + // Verify RTO has changed + let new_rto = session.rx_rto; + debug!("New RTO after ACK: {}", new_rto); + assert_ne!( + new_rto, initial_rto, + "RTO should have been updated after receiving ACK with valid RTT" + ); + + // Verify segment is removed (as in previous test) + assert!( + session.snd_buf.is_empty(), + "Segment should be removed by ACK" + ); + } + + #[test] + fn test_una_clears_send_buffer() { + init_logger(); + let conv = 202; + let mut session = KcpSession::new(conv); + session.set_mtu(50); + + // Send 5 segments (SN 0, 1, 2, 3, 4) + session.send(&[1u8; 30]); // sn=0 + session.send(&[2u8; 30]); // sn=1 + session.send(&[3u8; 30]); // sn=2 + session.send(&[4u8; 30]); // sn=3 + session.send(&[5u8; 30]); // sn=4 + assert_eq!(session.snd_queue.len(), 5); + + // Move all to snd_buf + session.update(session.interval); + let _ = session.fetch_outgoing(); // Discard packets + assert_eq!( + session.snd_buf.len(), + 5, + "Should have 5 segments in snd_buf" + ); + assert_eq!(session.snd_queue.len(), 0); + debug!( + "snd_buf initial contents (SNs): {:?}", + session.snd_buf.iter().map(|s| s.sn).collect::>() + ); + + // Simulate receiving a packet with una=3 (acks SN 0, 1, 2) + let packet_with_una3 = KcpPacket::new( + conv, + KcpCommand::Ack, // Command type doesn't matter for UNA processing + 0, // frg + session.rcv_wnd, // wnd + 0, // ts (dummy) + 0, // sn (dummy) + 3, // una = 3 + Vec::new(), // data + ); + session.input(&packet_with_una3); + + // Verify segments < 3 are removed + assert_eq!( + session.snd_buf.len(), + 2, + "snd_buf should have 2 segments left after una=3" + ); + let remaining_sns: Vec = session.snd_buf.iter().map(|s| s.sn).collect(); + assert_eq!( + remaining_sns, + vec![3, 4], + "Remaining segments should be SN 3 and 4" + ); + debug!("snd_buf contents after una=3: {:?}", remaining_sns); + + // Simulate receiving another packet with una=5 (acks SN 3, 4) + let packet_with_una5 = KcpPacket::new( + conv, + KcpCommand::Push, // Try a different command type + 0, // frg + session.rcv_wnd, // wnd + 0, // ts (dummy) + 10, // sn (dummy data sn) + 5, // una = 5 + vec![9u8; 10], // dummy data + ); + session.input(&packet_with_una5); + + // Verify all segments < 5 are removed (buffer should be empty) + assert!( + session.snd_buf.is_empty(), + "snd_buf should be empty after una=5" + ); + debug!("snd_buf is empty after una=5"); + } + + #[test] + fn test_write_fills_send_queue_when_window_full() { + init_logger(); + let mut session = KcpSession::new(456); + session.set_mtu(100); + // Set small windows => cwnd = 5 + session.snd_wnd = 5; + session.rmt_wnd = 5; + let cwnd = std::cmp::min(session.snd_wnd, session.rmt_wnd) as usize; + + let data = vec![0u8; 600]; // Enough for 6 segments + let expected_bytes_written = cwnd * session.mtu; // write is limited by available_send_segments (based on snd_wnd) + + // Write the data - should accept only enough bytes for cwnd segments + match session.write(&data) { + Ok(n) => assert_eq!( + n, expected_bytes_written, + "Write should only accept {} bytes based on snd_wnd={}", + expected_bytes_written, session.snd_wnd + ), + Err(e) => panic!("Write failed unexpectedly: {:?}", e), + } + + // Check that only the accepted segments are initially in snd_queue + let expected_segments_in_queue = expected_bytes_written / session.mtu; + assert_eq!( + session.snd_queue.len(), + expected_segments_in_queue, + "snd_queue should contain {} segments initially", + expected_segments_in_queue + ); + assert_eq!( + session.snd_buf.len(), + 0, + "snd_buf should be empty initially" + ); + + // Update the session - this triggers move_queue_to_buf + session.update(session.interval); + + // Verify that all initially queued segments were moved to snd_buf (up to cwnd) + assert_eq!( + session.snd_buf.len(), + cwnd, + "snd_buf should contain cwnd ({}) segments after update", + cwnd + ); + assert_eq!( + session.snd_queue.len(), + 0, // All initially accepted segments should have moved + "snd_queue should be empty after update" + ); + + // Verify sequence numbers in snd_buf + for i in 0..cwnd { + assert_eq!(session.snd_buf[i].sn, i as u32); + } + // Since queue is empty, no need to check snd_queue[0].sn + // assert_eq!(session.snd_queue[0].sn, cwnd as u32); + } + + #[test] + fn test_ack_prevents_retransmission() { + init_logger(); + let conv = 303; + let mut session = KcpSession::new(conv); + session.set_mtu(50); + session.set_interval(10); // Use a short interval for easier time management + + let data = vec![5u8; 30]; // Single segment + session.send(&data); + + // Update to move to snd_buf and prepare first transmission + // We need to advance time to at least ts_flush to trigger the move + session.update(session.ts_flush()); + assert_eq!(session.snd_buf.len(), 1, "Segment should be in snd_buf"); + + // Fetch the initial packet and get segment details + let initial_packets = session.fetch_outgoing(); + assert_eq!( + initial_packets.len(), + 1, + "Should fetch one packet initially" + ); + let segment_in_buf = session.snd_buf.front().expect("Segment must be in buffer"); + let sn_to_ack = segment_in_buf.sn; + let ts_for_ack = segment_in_buf.ts; + let original_resendts = segment_in_buf.resendts; + debug!( + "Sent segment sn={}, ts={}, initial resendts={}", + sn_to_ack, ts_for_ack, original_resendts + ); + + // Ensure resendts is in the future relative to current time + assert!( + original_resendts > session.current, + "Original resendts should be in the future" + ); + + // --- Simulate receiving ACK before RTO expires --- // + + // Advance time slightly, but not past resendts + let time_to_advance = 10; + session.update(time_to_advance); + debug!( + "Advanced time by {}, current={}. Still before resendts.", + time_to_advance, session.current + ); + assert!( + session.current < original_resendts, + "Should still be before original resendts" + ); + + // Create and input the ACK packet + let ack_packet = KcpPacket::new( + conv, + KcpCommand::Ack, + 0, // frg + session.rcv_wnd, + ts_for_ack, // Original ts + sn_to_ack, // SN being acked + session.rcv_nxt, // una + Vec::new(), + ); + session.input(&ack_packet); + + // Verify the segment is now gone due to the ACK + assert!( + session.snd_buf.is_empty(), + "Segment should be removed by the ACK" + ); + debug!("Received ACK for sn={}, snd_buf is now empty.", sn_to_ack); + + // --- Advance time PAST the original retransmission time --- // + let time_to_advance_past_rto = original_resendts - session.current + session.interval; + session.update(time_to_advance_past_rto); + debug!( + "Advanced time by {}, current={}. Now past original resendts.", + time_to_advance_past_rto, session.current + ); + assert!( + session.current >= original_resendts, + "Current time should be past original resendts" + ); + + // --- Verify no retransmission packet was generated --- // + let packets_after_rto = session.fetch_outgoing(); + assert!( + packets_after_rto.is_empty(), + "No packets should be generated, as the segment was ACKed before RTO" + ); + debug!("Confirmed no retransmission occurred."); + } + + #[test] + fn test_duplicate_fragment_handling() { + init_logger(); + let conv = 505; + let mut sender = KcpSession::new(conv); + let mut receiver = KcpSession::new(conv); + + let mtu = 30; + sender.set_mtu(mtu); + receiver.set_mtu(mtu); // Receiver MTU doesn't strictly matter for input, but good practice + + let message = b"This is a message that will be fragmented into several parts."; + let message_len = message.len(); + + // Send the message + sender.send(message); + sender.update(sender.ts_flush()); + let packets = sender.fetch_outgoing(); + assert!(packets.len() > 1, "Message should have been fragmented"); + debug!("Sent {} fragments for the message.", packets.len()); + + // Simulate receiving all fragments correctly first + debug!("Simulating initial reception of all fragments..."); + for pkt in &packets { + receiver.input(pkt); + } + receiver.update(0); // Process inputs + + // Verify the message is assembled in the receive queue + assert_eq!( + receiver.rcv_queue.len(), + 1, + "Receive queue should have 1 complete message" + ); + assert_eq!( + receiver.rcv_buf.len(), + 0, + "Receive buffer should be empty after assembling message" + ); + let assembled_len = receiver.rcv_queue.front().map_or(0, |m| m.len()); + assert_eq!( + assembled_len, message_len, + "Assembled message length should match original" + ); + debug!("Message correctly assembled initially."); + + // --- Simulate receiving a duplicate fragment (e.g., the second fragment) --- // + assert!(packets.len() >= 2, "Test requires at least 2 fragments"); + let duplicate_packet = packets[1].clone(); // Clone the second fragment + debug!( + "Simulating reception of duplicate fragment sn={}", + duplicate_packet.sn() + ); + + // Ensure rcv_nxt has advanced past the duplicate packet's sn + assert!( + receiver.rcv_nxt > duplicate_packet.sn(), + "rcv_nxt should be past the duplicate sn" + ); + + receiver.input(&duplicate_packet); + receiver.update(0); // Process the duplicate input + + // --- Verify state after duplicate --- // + // 1. The receive buffer should still be empty (duplicate should be detected and discarded) + assert_eq!( + receiver.rcv_buf.len(), + 0, + "Receive buffer should remain empty after duplicate" + ); + // 2. The receive queue should still contain only the original complete message + assert_eq!( + receiver.rcv_queue.len(), + 1, + "Receive queue should still have only 1 complete message" + ); + let assembled_len_after_duplicate = receiver.rcv_queue.front().map_or(0, |m| m.len()); + assert_eq!( + assembled_len_after_duplicate, message_len, + "Assembled message length should be unchanged" + ); + debug!("Duplicate fragment correctly ignored."); + + // --- Verify reading the message works correctly --- // + let mut read_buffer = vec![0u8; message_len + 10]; + let bytes_read = receiver.recv(&mut read_buffer); + assert_eq!( + bytes_read, message_len, + "recv should return the full message length" + ); + assert_eq!( + &read_buffer[..bytes_read], + message, + "Received message content should match original" + ); + assert!( + receiver.rcv_queue.is_empty(), + "Receive queue should be empty after reading" + ); + debug!("Message read successfully after duplicate ignored."); + + // Verify no more data + let bytes_read_again = receiver.recv(&mut read_buffer); + assert_eq!(bytes_read_again, 0, "Subsequent recv should return 0 bytes"); + } + + #[test] + fn test_fragment_loss_and_reassembly() { + init_logger(); + let conv = 606; + let mut sender = KcpSession::new(conv); + let mut receiver = KcpSession::new(conv); + + let mtu = 40; // Reduced MTU to ensure >= 3 fragments for the message + sender.set_mtu(mtu); + sender.set_interval(10); + receiver.set_mtu(mtu); + receiver.set_interval(10); + + let message = b"Testing fragment loss requires a message split into at least three parts for clarity."; + let message_len = message.len(); + + // Send the message + sender.send(message); + sender.update(sender.ts_flush()); // Move to snd_buf, set initial rto/resendts + let packets = sender.fetch_outgoing(); + assert!( + packets.len() >= 3, + "Message should fragment into at least 3 parts for this test" + ); + let num_fragments = packets.len(); + debug!("Sent {} fragments for the message.", num_fragments); + + // --- Simulate losing the second fragment --- // + let lost_packet_sn = packets[1].sn(); + debug!("Simulating loss of fragment sn={}", lost_packet_sn); + + // Deliver all packets *except* the lost one + for (i, packet) in packets.iter().enumerate().take(num_fragments) { + if i != 1 { + receiver.input(packet); + } + } + receiver.update(0); // Process inputs + + // Verify message is incomplete + let mut read_buffer = vec![0u8; message_len + 10]; + let bytes_read = receiver.recv(&mut read_buffer); + assert_eq!( + bytes_read, 0, + "recv should return 0 as message is incomplete" + ); + assert!( + !receiver.rcv_buf.is_empty(), + "Receive buffer should contain the received fragments" + ); + assert!( + receiver.rcv_queue.is_empty(), + "Receive queue should be empty" + ); + debug!( + "Receiver state after initial partial delivery: rcv_buf size {}, rcv_queue size {}", + receiver.rcv_buf.len(), + receiver.rcv_queue.len() + ); + + // --- Simulate ACKs for received packets (sn=0, sn=2) going back to sender --- // + let receiver_acks = receiver.fetch_outgoing(); + debug!( + "Receiver generated {} ACK packets for received fragments.", + receiver_acks.len() + ); + for ack_pkt in receiver_acks { + // Ensure these are ACKs and have relevant SNs if needed for debugging + assert_eq!( + ack_pkt.cmd(), + KcpCommand::Ack, + "Packet from receiver should be an ACK" + ); + debug!( + "Sender processing ACK for sn={}, ts={}", + ack_pkt.sn(), + ack_pkt.ts() + ); + sender.input(&ack_pkt); + } + // After processing ACKs, sn=0 and sn=2 should be removed from sender's snd_buf + assert_eq!( + sender.snd_buf.len(), + 1, + "Sender snd_buf should only contain the unacked lost segment (sn=1)" + ); + assert_eq!( + sender.snd_buf[0].sn, lost_packet_sn, + "Remaining segment in sender snd_buf should be the lost one" + ); + + // --- Trigger retransmission on sender --- // + + // Find the segment corresponding to the lost packet in sender's buffer + let lost_segment = sender + .snd_buf + .iter() + .find(|seg| seg.sn == lost_packet_sn) + .expect("Lost segment must be in sender's snd_buf"); + let original_resendts = lost_segment.resendts; + let current_sender_time = sender.ts_current(); + debug!( + "Lost segment sn={} has original resendts={}, current sender time={}", + lost_packet_sn, original_resendts, current_sender_time + ); + assert!( + original_resendts > current_sender_time, + "resendts should be in the future" + ); + + // Advance time past the RTO + let time_to_advance = original_resendts - current_sender_time + sender.interval; + sender.update(time_to_advance); + debug!( + "Advanced sender time by {}, current={}. Now past original resendts.", + time_to_advance, + sender.ts_current() + ); + + // Fetch the retransmitted packet + let retransmit_packets = sender.fetch_outgoing(); + assert_eq!( + retransmit_packets.len(), + 1, + "Should have retransmitted exactly one packet" + ); + let retransmitted_packet = &retransmit_packets[0]; + assert_eq!( + retransmitted_packet.sn(), + lost_packet_sn, + "Retransmitted packet SN should match lost packet SN" + ); + assert_eq!( + retransmitted_packet.frg(), + packets[1].frg(), + "Retransmitted packet FRG should match lost packet FRG" + ); + debug!( + "Successfully fetched retransmitted packet sn={}", + retransmitted_packet.sn() + ); + + // --- Deliver retransmitted packet and verify reassembly --- // + receiver.input(retransmitted_packet); + receiver.update(0); // Process the retransmitted packet + + // Verify message is now complete + assert!( + receiver.rcv_buf.is_empty(), + "Receive buffer should be empty after receiving the missing fragment" + ); + assert_eq!( + receiver.rcv_queue.len(), + 1, + "Receive queue should now contain the complete message" + ); + let assembled_len = receiver.rcv_queue.front().map_or(0, |m| m.len()); + assert_eq!( + assembled_len, message_len, + "Assembled message length should match original" + ); + debug!("Message reassembled successfully after retransmission."); + + // Read the message + let bytes_read_final = receiver.recv(&mut read_buffer); + assert_eq!( + bytes_read_final, message_len, + "recv should return the full message length after reassembly" + ); + assert_eq!( + &read_buffer[..bytes_read_final], + message, + "Received message content should match original" + ); + assert!( + receiver.rcv_queue.is_empty(), + "Receive queue should be empty after reading" + ); + + // Verify no more data + let bytes_read_again = receiver.recv(&mut read_buffer); + assert_eq!(bytes_read_again, 0, "Subsequent recv should return 0 bytes"); + } +} diff --git a/common/nym-kkt/Cargo.toml b/common/nym-kkt/Cargo.toml new file mode 100644 index 00000000000..3c717d5d41a --- /dev/null +++ b/common/nym-kkt/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "nym-kkt" +version = "0.1.0" +authors = ["Georgio Nicolas "] +edition = { workspace = true } +license.workspace = true + +[dependencies] +arc-swap = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +pin-project = { workspace = true } +blake3 = { workspace = true } +aead = { workspace = true } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tokio-util = { workspace = true, features = ["codec"] } + + + +# internal +nym-crypto = { path = "../crypto", features = ["asymmetric", "serde"]} + +libcrux-traits = { git = "https://github.com/cryspen/libcrux" } +libcrux-kem = { git = "https://github.com/cryspen/libcrux" } +libcrux-psq = { git = "https://github.com/cryspen/libcrux", features = ["test-utils"] } +libcrux-sha3 = { git = "https://github.com/cryspen/libcrux" } +libcrux-ml-kem = { git = "https://github.com/cryspen/libcrux" } +libcrux-ecdh = { git = "https://github.com/cryspen/libcrux", features = ["codec"]} + +rand = "0.9.2" +curve25519-dalek = {version = "4.1.3", features = ["rand_core", "serde"] } +zeroize = { workspace = true, features = ["zeroize_derive"] } +classic-mceliece-rust = { git = "https://github.com/georgio/classic-mceliece-rust", features = ["mceliece460896f","zeroize"]} + + +[dev-dependencies] +criterion = {workspace = true} + +[[bench]] +name = "benches" +harness = false + +[lints] +workspace = true diff --git a/common/nym-kkt/benches/benches.rs b/common/nym-kkt/benches/benches.rs new file mode 100644 index 00000000000..8df4dd3e429 --- /dev/null +++ b/common/nym-kkt/benches/benches.rs @@ -0,0 +1,518 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use criterion::{Criterion, criterion_group, criterion_main}; + +use nym_crypto::asymmetric::ed25519; +use nym_kkt::{ + ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM, SignatureScheme}, + context::KKTMode, + frame::KKTFrame, + key_utils::{generate_keypair_libcrux, generate_keypair_mceliece, hash_encapsulation_key}, + session::{ + anonymous_initiator_process, initiator_ingest_response, initiator_process, + responder_ingest_message, responder_process, + }, +}; +use rand::prelude::*; + +pub fn gen_ed25519_keypair(c: &mut Criterion) { + c.bench_function("Generate Ed25519 Keypair", |b| { + b.iter(|| { + let mut s: [u8; 32] = [0u8; 32]; + rand::rng().fill_bytes(&mut s); + ed25519::KeyPair::from_secret(s, 0) + }); + }); +} + +pub fn gen_mlkem768_keypair(c: &mut Criterion) { + c.bench_function("Generate MlKem768 Keypair", |b| { + b.iter(|| { + libcrux_kem::key_gen(libcrux_kem::Algorithm::MlKem768, &mut rand::rng()).unwrap() + }); + }); +} + +pub fn kkt_benchmark(c: &mut Criterion) { + let mut rng = rand::rng(); + + // generate ed25519 keys + let mut secret_initiator: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_initiator); + let initiator_ed25519_keypair = ed25519::KeyPair::from_secret(secret_initiator, 0); + + let mut secret_responder: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_responder); + let responder_ed25519_keypair = ed25519::KeyPair::from_secret(secret_responder, 1); + for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] { + for hash_function in [ + HashFunction::Blake3, + HashFunction::SHA256, + HashFunction::SHAKE128, + HashFunction::SHAKE256, + ] { + let ciphersuite = Ciphersuite::resolve_ciphersuite( + kem, + hash_function, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // generate kem public keys + + let (responder_kem_public_key, initiator_kem_public_key) = match kem { + KEM::MlKem768 => ( + EncapsulationKey::MlKem768(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::MlKem768(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::XWing => ( + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::X25519 => ( + EncapsulationKey::X25519(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::X25519(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::McEliece => ( + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + ), + }; + + let i_kem_key_bytes = initiator_kem_public_key.encode(); + + let r_kem_key_bytes = responder_kem_public_key.encode(); + + let i_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &i_kem_key_bytes, + ); + + let r_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &r_kem_key_bytes, + ); + + // Anonymous Initiator, OneWay + { + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Generate Request", + kem, hash_function + ), + |b| { + b.iter(|| anonymous_initiator_process(&mut rng, ciphersuite).unwrap()); + }, + ); + + let (mut i_context, i_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Encode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| i_frame.to_bytes()), + ); + + let i_frame_bytes = i_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Decode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()), + ); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Responder Ingest Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap() + }); + }, + ); + + let (mut r_context, _) = + responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Responder Generate Response", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap() + }); + }, + ); + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Responder Encode Frame", + kem, hash_function + ), + |b| b.iter(|| r_frame.to_bytes()), + ); + + let r_bytes = r_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Initiator Ingest Response", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap() + }); + }, + ); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + // Initiator, OneWay + { + let (mut i_context, i_frame) = initiator_process( + &mut rng, + KKTMode::OneWay, + ciphersuite, + initiator_ed25519_keypair.private_key(), + None, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Generate Request", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_process( + &mut rng, + KKTMode::OneWay, + ciphersuite, + initiator_ed25519_keypair.private_key(), + None, + ) + .unwrap() + }); + }, + ); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Encode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| i_frame.to_bytes()), + ); + + let i_frame_bytes = i_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Decode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()), + ); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Responder Ingest Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + None, + &i_frame_r, + ) + .unwrap() + }); + }, + ); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + None, + &i_frame_r, + ) + .unwrap(); + + assert!(r_obtained_key.is_none()); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Responder Generate Response", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap() + }); + }, + ); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Responder Encode Frame", + kem, hash_function + ), + |b| { + b.iter(|| r_frame.to_bytes()); + }, + ); + + let r_bytes = r_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Initiator Ingest Response", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap() + }); + }, + ); + + let i_obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(i_obtained_key.encode(), r_kem_key_bytes) + } + + // Initiator, Mutual + { + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Generate Request", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_process( + &mut rng, + KKTMode::Mutual, + ciphersuite, + initiator_ed25519_keypair.private_key(), + Some(&initiator_kem_public_key), + ) + .unwrap() + }); + }, + ); + + let (mut i_context, i_frame) = initiator_process( + &mut rng, + KKTMode::Mutual, + ciphersuite, + initiator_ed25519_keypair.private_key(), + Some(&initiator_kem_public_key), + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Encode Frame - Request", + kem, hash_function + ), + |b| { + b.iter(|| i_frame.to_bytes()); + }, + ); + + let i_frame_bytes = i_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Decode Frame - Request", + kem, hash_function + ), + |b| { + b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()); + }, + ); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Responder Ingest Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + Some(&i_dir_hash), + &i_frame_r, + ) + .unwrap() + }); + }, + ); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + Some(&i_dir_hash), + &i_frame_r, + ) + .unwrap(); + + assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Responder Generate Response", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap() + }); + }, + ); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Responder Encode Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + r_frame.to_bytes(); + }); + }, + ); + + let r_bytes = r_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Initiator Ingest Response", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap() + }); + }, + ); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + } + } +} + +criterion_group!( + benches, + gen_ed25519_keypair, + gen_mlkem768_keypair, + kkt_benchmark +); +criterion_main!(benches); diff --git a/common/nym-kkt/src/ciphersuite.rs b/common/nym-kkt/src/ciphersuite.rs new file mode 100644 index 00000000000..cc7877e6903 --- /dev/null +++ b/common/nym-kkt/src/ciphersuite.rs @@ -0,0 +1,301 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use libcrux_kem::{Algorithm, MlKem768PublicKey}; +use nym_crypto::asymmetric::ed25519; + +use crate::error::KKTError; + +pub const HASH_LEN_256: u8 = 32; +pub const CIPHERSUITE_ENCODING_LEN: usize = 4; + +pub const CURVE25519_KEY_LEN: usize = 32; + +#[derive(Clone, Copy, Debug)] +pub enum HashFunction { + Blake3, + SHAKE128, + SHAKE256, + SHA256, +} +impl Display for HashFunction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + HashFunction::Blake3 => "Blake3", + HashFunction::SHAKE128 => "SHAKE128", + HashFunction::SHAKE256 => "SHAKE256", + HashFunction::SHA256 => "SHA256", + }) + } +} + +pub enum EncapsulationKey<'a> { + MlKem768(libcrux_kem::PublicKey), + XWing(libcrux_kem::PublicKey), + X25519(libcrux_kem::PublicKey), + McEliece(classic_mceliece_rust::PublicKey<'a>), +} + +pub enum DecapsulationKey<'a> { + MlKem768(libcrux_kem::PrivateKey), + XWing(libcrux_kem::PrivateKey), + X25519(libcrux_kem::PrivateKey), + McEliece(classic_mceliece_rust::SecretKey<'a>), +} +impl<'a> EncapsulationKey<'a> { + pub(crate) fn decode(kem: KEM, bytes: &[u8]) -> Result { + match kem { + KEM::McEliece => { + if bytes.len() != classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES { + Err(KKTError::KEMError { + info: "Received McEliece Encapsulation Key with Invalid Length", + }) + } else { + let mut public_key_bytes = + Box::new([0u8; classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES]); + // Size must be correct due to KKTFrame::from_bytes(message_bytes)? + public_key_bytes.clone_from_slice(bytes); + Ok(EncapsulationKey::McEliece( + classic_mceliece_rust::PublicKey::from(public_key_bytes), + )) + } + } + KEM::X25519 => Ok(EncapsulationKey::X25519(libcrux_kem::PublicKey::decode( + map_kem_to_libcrux_kem(kem), + bytes, + )?)), + KEM::MlKem768 => Ok(EncapsulationKey::MlKem768(libcrux_kem::PublicKey::decode( + map_kem_to_libcrux_kem(kem), + bytes, + )?)), + KEM::XWing => Ok(EncapsulationKey::XWing(libcrux_kem::PublicKey::decode( + map_kem_to_libcrux_kem(kem), + bytes, + )?)), + } + } + + pub fn encode(&self) -> Vec { + match self { + EncapsulationKey::XWing(public_key) + | EncapsulationKey::MlKem768(public_key) + | EncapsulationKey::X25519(public_key) => public_key.encode(), + EncapsulationKey::McEliece(public_key) => Vec::from(public_key.as_array()), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub enum SignatureScheme { + Ed25519, +} +impl Display for SignatureScheme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SignatureScheme::Ed25519 => "Ed25519", + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub enum KEM { + MlKem768, + XWing, + X25519, + McEliece, +} + +impl Display for KEM { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + KEM::MlKem768 => "MlKem768", + KEM::XWing => "XWing", + KEM::X25519 => "x25519", + KEM::McEliece => "McEliece", + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Ciphersuite { + hash_function: HashFunction, + signature_scheme: SignatureScheme, + kem: KEM, + hash_length: u8, + encapsulation_key_length: usize, + signing_key_length: usize, + verification_key_length: usize, + signature_length: usize, +} + +impl Ciphersuite { + pub fn kem_key_len(&self) -> usize { + self.encapsulation_key_length + } + + pub fn signature_len(&self) -> usize { + self.signature_length + } + pub fn signing_key_len(&self) -> usize { + self.signing_key_length + } + pub fn verification_key_len(&self) -> usize { + self.verification_key_length + } + pub fn hash_function(&self) -> HashFunction { + self.hash_function + } + pub fn kem(&self) -> KEM { + self.kem + } + pub fn signature_scheme(&self) -> SignatureScheme { + self.signature_scheme + } + pub fn hash_len(&self) -> usize { + self.hash_length as usize + } + + pub fn resolve_ciphersuite( + kem: KEM, + hash_function: HashFunction, + signature_scheme: SignatureScheme, + // This should be None 99.9999% of the time + custom_hash_length: Option, + ) -> Result { + let hash_len = match custom_hash_length { + Some(l) => { + if l < 16 { + return Err(KKTError::InsecureHashLen); + } else { + l + } + } + None => HASH_LEN_256, + }; + Ok(Self { + hash_function, + signature_scheme, + kem, + hash_length: hash_len, + encapsulation_key_length: match kem { + // 1184 bytes + KEM::MlKem768 => MlKem768PublicKey::len(), + // 1216 bytes = 1184 + 32 + KEM::XWing => MlKem768PublicKey::len() + CURVE25519_KEY_LEN, + // 32 bytes + KEM::X25519 => CURVE25519_KEY_LEN, + // 524160 bytes + KEM::McEliece => classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES, + }, + signing_key_length: match signature_scheme { + // 32 bytes + SignatureScheme::Ed25519 => ed25519::SECRET_KEY_LENGTH, + }, + verification_key_length: match signature_scheme { + // 32 bytes + SignatureScheme::Ed25519 => ed25519::PUBLIC_KEY_LENGTH, + }, + signature_length: match signature_scheme { + // 64 bytes + SignatureScheme::Ed25519 => ed25519::SIGNATURE_LENGTH, + }, + }) + } + pub fn encode(&self) -> [u8; 4] { + // [kem, hash, hashlen, sig] + [ + match self.kem { + KEM::XWing => 0, + KEM::MlKem768 => 1, + KEM::McEliece => 2, + KEM::X25519 => 255, + }, + match self.hash_function { + HashFunction::Blake3 => 0, + HashFunction::SHAKE256 => 1, + HashFunction::SHAKE128 => 2, + HashFunction::SHA256 => 3, + }, + match self.hash_length { + HASH_LEN_256 => 0, + _ => self.hash_length, + }, + match self.signature_scheme { + SignatureScheme::Ed25519 => 0, + }, + ] + } + pub fn decode(encoding: &[u8]) -> Result { + if encoding.len() == 4 { + let kem = match encoding[0] { + 0 => KEM::XWing, + 1 => KEM::MlKem768, + 2 => KEM::McEliece, + 255 => KEM::X25519, + _ => { + return Err(KKTError::CiphersuiteDecodingError { + info: format!("Undefined KEM: {}", encoding[0]), + }); + } + }; + let hash_function = match encoding[1] { + 0 => HashFunction::Blake3, + 1 => HashFunction::SHAKE256, + 2 => HashFunction::SHAKE128, + 3 => HashFunction::SHA256, + _ => { + return Err(KKTError::CiphersuiteDecodingError { + info: format!("Undefined Hash Function: {}", encoding[1]), + }); + } + }; + + let custom_hash_length = match encoding[2] { + 0 => None, + _ => Some(encoding[2]), + }; + + let signature_scheme = match encoding[3] { + 0 => SignatureScheme::Ed25519, + _ => { + return Err(KKTError::CiphersuiteDecodingError { + info: format!("Undefined Signature Scheme: {}", encoding[3]), + }); + } + }; + + Self::resolve_ciphersuite(kem, hash_function, signature_scheme, custom_hash_length) + } else { + Err(KKTError::CiphersuiteDecodingError { + info: format!( + "Incorrect Encoding Length: actual: {} != expected: {}", + encoding.len(), + CIPHERSUITE_ENCODING_LEN + ), + }) + } + } +} + +impl Display for Ciphersuite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + &format!( + "{}_{}({})_{}", + self.kem, self.hash_function, self.hash_length, self.signature_scheme + ) + .to_ascii_lowercase(), + ) + } +} + +pub const fn map_kem_to_libcrux_kem(kem: KEM) -> Algorithm { + match kem { + KEM::MlKem768 => Algorithm::MlKem768, + KEM::XWing => Algorithm::XWingKemDraft06, + KEM::X25519 => Algorithm::X25519, + KEM::McEliece => panic!("McEliece is not supported in libcrux_kem"), + } +} diff --git a/common/nym-kkt/src/context.rs b/common/nym-kkt/src/context.rs new file mode 100644 index 00000000000..da66bd3ae64 --- /dev/null +++ b/common/nym-kkt/src/context.rs @@ -0,0 +1,258 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use crate::{KKT_VERSION, ciphersuite::Ciphersuite, error::KKTError, frame::KKT_SESSION_ID_LEN}; + +pub const KKT_CONTEXT_LEN: usize = 7; + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KKTStatus { + Ok, + InvalidRequestFormat, + InvalidResponseFormat, + InvalidSignature, + UnsupportedCiphersuite, + UnsupportedKKTVersion, + InvalidKey, + Timeout, +} + +impl Display for KKTStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + KKTStatus::Ok => "Ok", + KKTStatus::InvalidRequestFormat => "Invalid Request Format", + KKTStatus::InvalidResponseFormat => "Invalid Response Format", + KKTStatus::InvalidSignature => "Invalid Signature", + KKTStatus::UnsupportedCiphersuite => "Unsupported Ciphersuite", + KKTStatus::UnsupportedKKTVersion => "Unsupported KKT Version", + KKTStatus::InvalidKey => "Invalid Key", + KKTStatus::Timeout => "Timeout", + }) + } +} +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KKTRole { + Initiator, + AnonymousInitiator, + Responder, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KKTMode { + OneWay, + Mutual, +} + +#[derive(Copy, Clone, Debug)] +pub struct KKTContext { + version: u8, + message_sequence: u8, + status: KKTStatus, + mode: KKTMode, + role: KKTRole, + ciphersuite: Ciphersuite, +} +impl KKTContext { + pub fn new(role: KKTRole, mode: KKTMode, ciphersuite: Ciphersuite) -> Result { + if role == KKTRole::AnonymousInitiator && mode != KKTMode::OneWay { + return Err(KKTError::IncompatibilityError { + info: "Anonymous Initiator can only use OneWay mode", + }); + } + Ok(Self { + version: KKT_VERSION, + message_sequence: 0, + status: KKTStatus::Ok, + mode, + role, + ciphersuite, + }) + } + + pub fn derive_responder_header(&self) -> Result { + let mut responder_header = *self; + + responder_header.increment_message_sequence_count()?; + responder_header.role = KKTRole::Responder; + + Ok(responder_header) + } + + pub fn increment_message_sequence_count(&mut self) -> Result<(), KKTError> { + if self.message_sequence + 1 < (1 << 4) { + self.message_sequence += 1; + Ok(()) + } else { + Err(KKTError::MessageCountLimitReached) + } + } + + pub fn update_status(&mut self, status: KKTStatus) { + self.status = status; + } + pub fn version(&self) -> u8 { + self.version + } + pub fn status(&self) -> KKTStatus { + self.status + } + pub fn ciphersuite(&self) -> Ciphersuite { + self.ciphersuite + } + pub fn role(&self) -> KKTRole { + self.role + } + pub fn mode(&self) -> KKTMode { + self.mode + } + + pub fn body_len(&self) -> usize { + if self.status != KKTStatus::Ok + || (self.mode == KKTMode::OneWay + && (self.role == KKTRole::Initiator || self.role == KKTRole::AnonymousInitiator)) + { + 0 + } else { + self.ciphersuite.kem_key_len() + } + } + + pub fn signature_len(&self) -> usize { + match self.role { + KKTRole::Initiator | KKTRole::Responder => self.ciphersuite.signature_len(), + KKTRole::AnonymousInitiator => 0, + } + } + + pub fn header_len(&self) -> usize { + KKT_CONTEXT_LEN + } + + pub fn session_id_len(&self) -> usize { + // match self.role { + // KKTRole::Initiator | KKTRole::Responder => SESSION_ID_LENGTH, + // It doesn't make sense to send a session_id if we send messages in the clear + // KKTRole::AnonymousInitiator => 0, + // } + KKT_SESSION_ID_LEN + } + + pub fn full_message_len(&self) -> usize { + self.body_len() + self.signature_len() + self.header_len() + self.session_id_len() + } + + pub fn encode(&self) -> Result, KKTError> { + let mut header_bytes: Vec = Vec::with_capacity(KKT_CONTEXT_LEN); + if self.message_sequence >= 1 << 4 { + return Err(KKTError::MessageCountLimitReached); + } + + header_bytes.push((KKT_VERSION << 4) + self.message_sequence); + + header_bytes.push( + match self.status { + KKTStatus::Ok => 0, + KKTStatus::InvalidRequestFormat => 0b0010_0000, + KKTStatus::InvalidResponseFormat => 0b0100_0000, + KKTStatus::InvalidSignature => 0b0110_0000, + KKTStatus::UnsupportedCiphersuite => 0b1000_0000, + KKTStatus::UnsupportedKKTVersion => 0b1010_0000, + KKTStatus::InvalidKey => 0b1100_0000, + KKTStatus::Timeout => 0b1110_0000, + } + match self.mode { + KKTMode::OneWay => 0, + KKTMode::Mutual => 0b0000_0100, + } + match self.role { + KKTRole::Initiator => 0, + KKTRole::Responder => 1, + KKTRole::AnonymousInitiator => 2, + }, + ); + + header_bytes.extend_from_slice(&self.ciphersuite.encode()); + header_bytes.push(0); + Ok(header_bytes) + } + + pub fn try_decode(header_bytes: &[u8]) -> Result { + if header_bytes.len() == KKT_CONTEXT_LEN { + let kkt_version = header_bytes[0] & 0b1111_0000; + + let message_sequence_counter = header_bytes[0] & 0b0000_1111; + + // We only check if stuff is valid here, not necessarily if it's compatible + + if (kkt_version >> 4) > KKT_VERSION { + return Err(KKTError::FrameDecodingError { + info: format!("Header - Invalid KKT Version: {}", kkt_version >> 4), + }); + } + + let status = match header_bytes[1] & 0b1110_0000 { + 0 => KKTStatus::Ok, + 0b0010_0000 => KKTStatus::InvalidRequestFormat, + 0b0100_0000 => KKTStatus::InvalidResponseFormat, + 0b0110_0000 => KKTStatus::InvalidSignature, + 0b1000_0000 => KKTStatus::UnsupportedCiphersuite, + 0b1010_0000 => KKTStatus::UnsupportedKKTVersion, + 0b1100_0000 => KKTStatus::InvalidKey, + 0b1110_0000 => KKTStatus::Timeout, + _ => { + return Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid KKT Status: {}", + header_bytes[1] & 0b1110_0000 + ), + }); + } + }; + + let role = match header_bytes[1] & 0b0000_0011 { + 0 => KKTRole::Initiator, + 1 => KKTRole::Responder, + 2 => KKTRole::AnonymousInitiator, + _ => { + return Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid KKT Role: {}", + header_bytes[1] & 0b0000_0011 + ), + }); + } + }; + + let mode = match (header_bytes[1] & 0b0001_1100) >> 2 { + 0 => KKTMode::OneWay, + 1 => KKTMode::Mutual, + _ => { + return Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid KKT Mode: {}", + (header_bytes[1] & 0b0001_1100) >> 2 + ), + }); + } + }; + + Ok(KKTContext { + version: kkt_version, + status, + mode, + role, + ciphersuite: Ciphersuite::decode(&header_bytes[2..6])?, + message_sequence: message_sequence_counter, + }) + } else { + Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid Header Length: actual: {} != expected: {}", + header_bytes.len(), + KKT_CONTEXT_LEN + ), + }) + } + } +} diff --git a/common/nym-kkt/src/encryption.rs b/common/nym-kkt/src/encryption.rs new file mode 100644 index 00000000000..65ac46f0ac5 --- /dev/null +++ b/common/nym-kkt/src/encryption.rs @@ -0,0 +1,95 @@ +use core::hash; + +use blake3::{Hash, Hasher}; +use curve25519_dalek::digest::DynDigest; +use libcrux_psq::traits::Ciphertext; +use nym_crypto::symmetric::aead::{AeadKey, Nonce}; +use nym_crypto::{ + aes::Aes256, + asymmetric::x25519::{self, PrivateKey, PublicKey}, + generic_array::GenericArray, + Aes256GcmSiv, +}; +// use rand::{CryptoRng, RngCore}; +use zeroize::Zeroize; + +use nym_crypto::aes::cipher::crypto_common::rand_core::{CryptoRng, RngCore}; + +use crate::error::KKTError; + +fn generate_round_trip_symmetric_key( + rng: &mut R, + remote_public_key: &PublicKey, +) -> ([u8; 64], [u8; 32]) +where + R: CryptoRng + RngCore, +{ + let mut s = x25519::PrivateKey::new(rng); + let gs = s.public_key(); + + let mut gbs = s.diffie_hellman(remote_public_key); + s.zeroize(); + + let mut message: [u8; 64] = [0u8; 64]; + message[0..32].clone_from_slice(gs.as_bytes()); + + let mut hasher = Hasher::new(); + + hasher.update(&gbs); + gbs.zeroize(); + let key: [u8; 32] = hasher.finalize().as_bytes().to_owned(); + + hasher.update(remote_public_key.as_bytes()); + hasher.update(gs.as_bytes()); + + hasher.finalize_into_reset(&mut message[32..64]); + + (message, key) +} + +fn extract_shared_secret(b: &PrivateKey, message: &[u8; 64]) -> Result<[u8; 32], KKTError> { + let gs = PublicKey::from_bytes(&message[0..32])?; + + let mut gsb = b.diffie_hellman(&gs); + + let mut hasher = Hasher::new(); + hasher.update(&gsb); + gsb.zeroize(); + let key: [u8; 32] = hasher.finalize().as_bytes().to_owned(); + + hasher.update(b.public_key().as_bytes()); + hasher.update(gs.as_bytes()); + + // This runs in constant time + if hasher.finalize() == message[32..64] { + Ok(key) + } else { + Err(KKTError::X25519Error { + info: format!("Symmetric Key Hash Validation Error"), + }) + } +} + +fn encrypt(mut key: [u8; 32], message: &[u8]) -> Result, KKTError> { + // The empty nonce is fine since we use the key once. + let nonce = Nonce::::from_slice(&[]); + + let ciphertext = + nym_crypto::symmetric::aead::encrypt::(&key.into(), nonce, message)?; + + key.zeroize(); + + Ok(ciphertext) +} + +fn decrypt(key: [u8; 32], ciphertext: Vec) -> Vec { + // The empty nonce is fine since we use the key once. + let nonce = Nonce::::from_slice(&[]); + + let ciphertext = + nym_crypto::symmetric::aead::encrypt::(&key.into(), nonce, message)?; + + key.zeroize(); + + Ok(ciphertext) +} diff --git a/common/nym-kkt/src/error.rs b/common/nym-kkt/src/error.rs new file mode 100644 index 00000000000..3e148d03e12 --- /dev/null +++ b/common/nym-kkt/src/error.rs @@ -0,0 +1,85 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use thiserror::Error; + +use crate::context::KKTStatus; + +#[derive(Error, Debug)] +pub enum KKTError { + #[error("Signature constructor error")] + SigConstructorError, + #[error("Signature verification error")] + SigVerifError, + #[error("Ciphersuite Decoding Error: {}", info)] + CiphersuiteDecodingError { info: String }, + #[error("Insecure Encapsulation Key Hash Length")] + InsecureHashLen, + + #[error("KKT Frame Decoding Error: {}", info)] + FrameDecodingError { info: String }, + + #[error("KKT Frame Encoding Error: {}", info)] + FrameEncodingError { info: String }, + + #[error("KKT Incompatibility Error: {}", info)] + IncompatibilityError { info: &'static str }, + + #[error("KKT Responder Flagged Error: {}", status)] + ResponderFlaggedError { status: KKTStatus }, + + #[error("KKT Message Count Limit Reached")] + MessageCountLimitReached, + + #[error("PSQ KEM Error: {}", info)] + KEMError { info: &'static str }, + + #[error("Local Function Input Error: {}", info)] + FunctionInputError { info: &'static str }, + + #[error("{}", info)] + X25519Error { info: &'static str }, + + #[error("Generic libcrux error")] + LibcruxError, +} + +impl From for KKTError { + fn from(err: libcrux_kem::Error) -> Self { + match err { + libcrux_kem::Error::EcDhError(_) => KKTError::KEMError { info: "ECDH Error" }, + libcrux_kem::Error::KeyGen => KKTError::KEMError { + info: "Key Generation Error", + }, + libcrux_kem::Error::Encapsulate => KKTError::KEMError { + info: "Encapsulation Error", + }, + libcrux_kem::Error::Decapsulate => KKTError::KEMError { + info: "Decapsulation Error", + }, + libcrux_kem::Error::UnsupportedAlgorithm => KKTError::KEMError { + info: "libcrux Unsupported Algorithm", + }, + libcrux_kem::Error::InvalidPrivateKey => KKTError::KEMError { + info: "Invalid Private Key", + }, + + libcrux_kem::Error::InvalidPublicKey => KKTError::KEMError { + info: "Invalid Public Key", + }, + libcrux_kem::Error::InvalidCiphertext => KKTError::KEMError { + info: "Invalid Ciphertext", + }, + } + } +} +impl From for KKTError { + fn from(err: libcrux_ecdh::Error) -> Self { + match err { + libcrux_ecdh::Error::InvalidPoint => KKTError::KEMError { + info: "Invalid Remote Public Key", + }, + _ => KKTError::LibcruxError, + } + } +} diff --git a/common/nym-kkt/src/frame.rs b/common/nym-kkt/src/frame.rs new file mode 100644 index 00000000000..1745b997f14 --- /dev/null +++ b/common/nym-kkt/src/frame.rs @@ -0,0 +1,129 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// | 0 | 1 | 2, 3, 4, 5 | 6 | 7 +// [0] => KKT version (4 bits) + Message Sequence Count (4 bits) +// [1] => Status (3 bits) + Mode (3 bits) + Role (2 bits) +// [2..=5] => Ciphersuite +// [6] => Reserved + +use crate::{ + context::{KKT_CONTEXT_LEN, KKTContext}, + error::KKTError, +}; + +pub const KKT_SESSION_ID_LEN: usize = 16; + +pub struct KKTFrame { + context: Vec, + session_id: Vec, + body: Vec, + signature: Vec, +} + +// if oneway and message coming from initiator => body is empty, signature contains signature of context + session id (64 bytes). +// if message coming from anonymous initiator => body is empty, there is no signature. +// if mutual and message coming from initiator => body has the initiator's kem public key and the signature is over the context + body + session_id. +// if coming from responder => body has the responder's kem public key and the signature is over the context + body + session_id. + +impl KKTFrame { + pub fn new(context: &[u8], body: &[u8], session_id: &[u8], signature: &[u8]) -> Self { + Self { + context: Vec::from(context), + body: Vec::from(body), + session_id: Vec::from(session_id), + signature: Vec::from(signature), + } + } + pub fn context_ref(&self) -> &[u8] { + &self.context + } + pub fn signature_ref(&self) -> &[u8] { + &self.signature + } + pub fn body_ref(&self) -> &[u8] { + &self.body + } + + pub fn session_id_ref(&self) -> &[u8] { + &self.session_id + } + pub fn signature_mut(&mut self) -> &mut [u8] { + &mut self.signature + } + pub fn body_mut(&mut self) -> &mut [u8] { + &mut self.body + } + + pub fn session_id_mut(&mut self) -> &mut [u8] { + &mut self.session_id + } + + pub fn frame_length(&self) -> usize { + self.context.len() + self.session_id.len() + self.body.len() + self.signature.len() + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(self.frame_length()); + bytes.extend_from_slice(&self.context); + bytes.extend_from_slice(&self.body); + bytes.extend_from_slice(&self.session_id); + bytes.extend_from_slice(&self.signature); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result<(Self, KKTContext), KKTError> { + if bytes.len() < KKT_CONTEXT_LEN { + Err(KKTError::FrameDecodingError { + info: format!( + "Frame is shorter than expected context length: actual {} != expected {}", + bytes.len(), + KKT_CONTEXT_LEN + ), + }) + } else { + let context_bytes = Vec::from(&bytes[0..KKT_CONTEXT_LEN]); + + let context = KKTContext::try_decode(&context_bytes)?; + + let (mut session_id, mut body, mut signature): (Vec, Vec, Vec) = + (vec![], vec![], vec![]); + + if bytes.len() == context.full_message_len() { + if context.body_len() > 0 { + body.extend_from_slice( + &bytes[KKT_CONTEXT_LEN..KKT_CONTEXT_LEN + context.body_len()], + ); + } + if context.session_id_len() > 0 { + session_id.extend_from_slice( + &bytes[KKT_CONTEXT_LEN + context.body_len() + ..KKT_CONTEXT_LEN + context.body_len() + context.session_id_len()], + ); + } + if context.signature_len() > 0 { + signature.extend_from_slice( + &bytes[KKT_CONTEXT_LEN + context.body_len() + context.session_id_len() + ..KKT_CONTEXT_LEN + + context.body_len() + + context.session_id_len() + + context.signature_len()], + ); + } + + Ok(( + KKTFrame::new(&context_bytes, &body, &session_id, &signature), + context, + )) + } else { + Err(KKTError::FrameDecodingError { + info: format!( + "Frame is shorter than expected: actual {} != expected {}", + bytes.len(), + context.full_message_len() + ), + }) + } + } + } +} diff --git a/common/nym-kkt/src/key_utils.rs b/common/nym-kkt/src/key_utils.rs new file mode 100644 index 00000000000..1ab18934e00 --- /dev/null +++ b/common/nym-kkt/src/key_utils.rs @@ -0,0 +1,107 @@ +use crate::{ + ciphersuite::{HashFunction, KEM}, + error::KKTError, +}; + +use classic_mceliece_rust::keypair_boxed; +use libcrux_kem::{Algorithm, key_gen}; + +use libcrux_sha3; +use rand::{CryptoRng, RngCore}; + +// (decapsulation_key, encapsulation_key) +pub fn generate_keypair_libcrux( + rng: &mut R, + kem: KEM, +) -> Result<(libcrux_kem::PrivateKey, libcrux_kem::PublicKey), KKTError> +where + R: RngCore + CryptoRng, +{ + match kem { + KEM::MlKem768 => Ok(key_gen(Algorithm::MlKem768, rng)?), + KEM::XWing => Ok(key_gen(Algorithm::XWingKemDraft06, rng)?), + KEM::X25519 => Ok(key_gen(Algorithm::X25519, rng)?), + _ => Err(KKTError::KEMError { + info: "Key Generation Error: Unsupported Libcrux Algorithm", + }), + } +} +// (decapsulation_key, encapsulation_key) +pub fn generate_keypair_mceliece<'a, R>( + rng: &mut R, +) -> ( + classic_mceliece_rust::SecretKey<'a>, + classic_mceliece_rust::PublicKey<'a>, +) +where + // this is annoying because mceliece lib uses rand 0.8.5... + R: RngCore + CryptoRng, +{ + let (encapsulation_key, decapsulation_key) = keypair_boxed(rng); + (decapsulation_key, encapsulation_key) +} + +pub fn hash_key_bytes( + hash_function: &HashFunction, + hash_length: usize, + key_bytes: &[u8], +) -> Vec { + let mut hashed_key: Vec = vec![0u8; hash_length]; + match hash_function { + HashFunction::Blake3 => { + let mut hasher = blake3::Hasher::new(); + hasher.update(key_bytes); + hasher.finalize_xof().fill(&mut hashed_key); + hasher.reset(); + } + HashFunction::SHAKE256 => { + libcrux_sha3::shake256_ema(&mut hashed_key, key_bytes); + } + HashFunction::SHAKE128 => { + libcrux_sha3::shake128_ema(&mut hashed_key, key_bytes); + } + HashFunction::SHA256 => { + libcrux_sha3::sha256_ema(&mut hashed_key, key_bytes); + } + } + + hashed_key +} + +/// This does NOT run in constant time. +// It's fine for KKT since we are comparing hashes. +fn compare_hashes(a: &[u8], b: &[u8]) -> bool { + a == b +} + +pub fn validate_encapsulation_key( + hash_function: &HashFunction, + hash_length: usize, + encapsulation_key: &[u8], + expected_hash_bytes: &[u8], +) -> bool { + compare_hashes( + &hash_encapsulation_key(hash_function, hash_length, encapsulation_key), + expected_hash_bytes, + ) +} + +pub fn validate_key_bytes( + hash_function: &HashFunction, + hash_length: usize, + key_bytes: &[u8], + expected_hash_bytes: &[u8], +) -> bool { + compare_hashes( + &hash_key_bytes(hash_function, hash_length, key_bytes), + expected_hash_bytes, + ) +} + +pub fn hash_encapsulation_key( + hash_function: &HashFunction, + hash_length: usize, + encapsulation_key: &[u8], +) -> Vec { + hash_key_bytes(hash_function, hash_length, encapsulation_key) +} diff --git a/common/nym-kkt/src/kkt.rs b/common/nym-kkt/src/kkt.rs new file mode 100644 index 00000000000..7fcef8d3e3e --- /dev/null +++ b/common/nym-kkt/src/kkt.rs @@ -0,0 +1,355 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Convenience wrappers around KKT protocol functions for easier integration. +//! +//! This module provides simplified APIs for the common use case of exchanging +//! KEM public keys between a client (initiator) and gateway (responder). +//! +//! The underlying KKT protocol is implemented in the `session` module. + +use nym_crypto::asymmetric::ed25519; +use rand::{CryptoRng, RngCore}; + +use crate::{ + ciphersuite::{Ciphersuite, EncapsulationKey}, + context::{KKTContext, KKTMode}, + error::KKTError, + frame::KKTFrame, +}; + +// Re-export core session functions for advanced use cases +pub use crate::session::{ + anonymous_initiator_process, initiator_ingest_response, initiator_process, + responder_ingest_message, responder_process, +}; + +/// Request a KEM public key from a responder (OneWay mode). +/// +/// This is the client-side operation that initiates a KKT exchange. +/// The request will be signed with the provided signing key. +/// +/// # Arguments +/// * `rng` - Random number generator +/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms) +/// * `signing_key` - Client's Ed25519 signing key for authentication +/// +/// # Returns +/// * `KKTContext` - Context to use when validating the response +/// * `KKTFrame` - Signed request frame to send to responder +/// +/// # Example +/// ```ignore +/// let (context, request_frame) = request_kem_key( +/// &mut rng, +/// ciphersuite, +/// client_signing_key, +/// )?; +/// // Send request_frame to gateway +/// ``` +pub fn request_kem_key( + rng: &mut R, + ciphersuite: Ciphersuite, + signing_key: &ed25519::PrivateKey, +) -> Result<(KKTContext, KKTFrame), KKTError> { + // OneWay mode: client only wants responder's KEM key + // None: client doesn't send their own KEM key + initiator_process(rng, KKTMode::OneWay, ciphersuite, signing_key, None) +} + +/// Validate a KKT response and extract the responder's KEM public key. +/// +/// This is the client-side operation that processes the gateway's response. +/// It verifies the signature and validates the key hash against the expected value +/// (typically retrieved from a directory service). +/// +/// # Arguments +/// * `context` - Context from the initial request +/// * `responder_vk` - Responder's Ed25519 verification key (from directory) +/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory) +/// * `response_bytes` - Serialized response frame from responder +/// +/// # Returns +/// * `EncapsulationKey` - Authenticated KEM public key of the responder +/// +/// # Example +/// ```ignore +/// let gateway_kem_key = validate_kem_response( +/// &mut context, +/// gateway_verification_key, +/// &expected_hash_from_directory, +/// &response_bytes, +/// )?; +/// // Use gateway_kem_key for PSQ +/// ``` +pub fn validate_kem_response<'a>( + context: &mut KKTContext, + responder_vk: &ed25519::PublicKey, + expected_key_hash: &[u8], + response_bytes: &[u8], +) -> Result, KKTError> { + initiator_ingest_response(context, responder_vk, expected_key_hash, response_bytes) +} + +/// Handle a KKT request and generate a signed response with the responder's KEM key. +/// +/// This is the gateway-side operation that processes a client's KKT request. +/// It validates the request signature (if authenticated) and responds with +/// the gateway's KEM public key, signed for authenticity. +/// +/// # Arguments +/// * `request_frame` - Request frame received from initiator +/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous) +/// * `responder_signing_key` - Gateway's Ed25519 signing key +/// * `responder_kem_key` - Gateway's KEM public key to send +/// +/// # Returns +/// * `KKTFrame` - Signed response frame containing the KEM public key +/// +/// # Example +/// ```ignore +/// let response_frame = handle_kem_request( +/// &request_frame, +/// Some(client_verification_key), // or None for anonymous +/// gateway_signing_key, +/// &gateway_kem_public_key, +/// )?; +/// // Send response_frame back to client +/// ``` +pub fn handle_kem_request<'a>( + request_frame: &KKTFrame, + initiator_vk: Option<&ed25519::PublicKey>, + responder_signing_key: &ed25519::PrivateKey, + responder_kem_key: &EncapsulationKey<'a>, +) -> Result { + // Parse context from the request frame + let request_bytes = request_frame.to_bytes(); + let (_, request_context) = KKTFrame::from_bytes(&request_bytes)?; + + // Validate the request (verifies signature if initiator_vk provided) + let (mut response_context, _) = responder_ingest_message( + &request_context, + initiator_vk, + None, // Not checking initiator's KEM key in OneWay mode + request_frame, + )?; + + // Generate signed response with our KEM public key + responder_process( + &mut response_context, + request_frame.session_id_ref(), + responder_signing_key, + responder_kem_key, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ciphersuite::{HashFunction, KEM, SignatureScheme}, + key_utils::{generate_keypair_libcrux, hash_encapsulation_key}, + }; + + #[test] + fn test_kkt_wrappers_oneway_authenticated() { + let mut rng = rand::rng(); + + // Generate Ed25519 keypairs for both parties + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Generate responder's KEM keypair (X25519 for testing) + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + // Create ciphersuite + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Hash the KEM key (simulating directory storage) + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Client: Request KEM key + let (mut context, request_frame) = + request_kem_key(&mut rng, ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway: Handle request + let response_frame = handle_kem_request( + &request_frame, + Some(initiator_keypair.public_key()), // Authenticated + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client: Validate response + let obtained_key = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &key_hash, + &response_frame.to_bytes(), + ) + .unwrap(); + + // Verify we got the correct KEM key + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_kkt_wrappers_anonymous() { + let mut rng = rand::rng(); + + // Only responder has keys + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Anonymous initiator + let (mut context, request_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + + // Gateway: Handle anonymous request + let response_frame = handle_kem_request( + &request_frame, + None, // Anonymous - no verification key + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Initiator: Validate response + let obtained_key = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &key_hash, + &response_frame.to_bytes(), + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_invalid_signature_rejected() { + let mut rng = rand::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Different keypair for wrong signature + let mut wrong_secret = [0u8; 32]; + rng.fill_bytes(&mut wrong_secret); + let wrong_keypair = ed25519::KeyPair::from_secret(wrong_secret, 2); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let (_context, request_frame) = + request_kem_key(&mut rng, ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway handles request but we provide WRONG verification key + let result = handle_kem_request( + &request_frame, + Some(wrong_keypair.public_key()), // Wrong key! + responder_keypair.private_key(), + &responder_kem_key, + ); + + // Should fail signature verification + assert!(result.is_err()); + } + + #[test] + fn test_hash_mismatch_rejected() { + let mut rng = rand::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Use WRONG hash + let wrong_hash = [0u8; 32]; + + let (mut context, request_frame) = + request_kem_key(&mut rng, ciphersuite, initiator_keypair.private_key()).unwrap(); + + let response_frame = handle_kem_request( + &request_frame, + Some(initiator_keypair.public_key()), + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client validates with WRONG hash + let result = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &wrong_hash, // Wrong! + &response_frame.to_bytes(), + ); + + // Should fail hash validation + assert!(result.is_err()); + } +} diff --git a/common/nym-kkt/src/lib.rs b/common/nym-kkt/src/lib.rs new file mode 100644 index 00000000000..348e8fb01ce --- /dev/null +++ b/common/nym-kkt/src/lib.rs @@ -0,0 +1,232 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod ciphersuite; +pub mod context; +// pub mod encryption; +pub mod error; +pub mod frame; +pub mod key_utils; +pub mod kkt; +pub mod session; + +// pub mod psq; + +// This must be less than 4 bits +pub const KKT_VERSION: u8 = 1; +const _: () = assert!(KKT_VERSION < 1 << 4); + +#[cfg(test)] +mod test { + use nym_crypto::asymmetric::ed25519; + use rand::prelude::*; + + use crate::{ + ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM}, + frame::KKTFrame, + key_utils::{generate_keypair_libcrux, generate_keypair_mceliece, hash_encapsulation_key}, + session::{ + anonymous_initiator_process, initiator_ingest_response, initiator_process, + responder_ingest_message, responder_process, + }, + }; + + #[test] + fn test_kkt_psq_e2e_clear() { + let mut rng = rand::rng(); + + // generate ed25519 keys + let mut secret_initiator: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_initiator); + let initiator_ed25519_keypair = ed25519::KeyPair::from_secret(secret_initiator, 0); + + let mut secret_responder: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_responder); + let responder_ed25519_keypair = ed25519::KeyPair::from_secret(secret_responder, 1); + for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] { + for hash_function in [ + HashFunction::Blake3, + HashFunction::SHA256, + HashFunction::SHAKE128, + HashFunction::SHAKE256, + ] { + let ciphersuite = Ciphersuite::resolve_ciphersuite( + kem, + hash_function, + crate::ciphersuite::SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // generate kem public keys + + let (responder_kem_public_key, initiator_kem_public_key) = match kem { + KEM::MlKem768 => ( + EncapsulationKey::MlKem768( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + EncapsulationKey::MlKem768( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + ), + KEM::XWing => ( + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::X25519 => ( + EncapsulationKey::X25519( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + EncapsulationKey::X25519( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + ), + KEM::McEliece => ( + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + ), + }; + + let i_kem_key_bytes = initiator_kem_public_key.encode(); + + let r_kem_key_bytes = responder_kem_public_key.encode(); + + let i_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &i_kem_key_bytes, + ); + + let r_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &r_kem_key_bytes, + ); + + // Anonymous Initiator, OneWay + { + let (mut i_context, i_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + + let i_frame_bytes = i_frame.to_bytes(); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + let (mut r_context, _) = + responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap(); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + let r_bytes = r_frame.to_bytes(); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + // Initiator, OneWay + { + let (mut i_context, i_frame) = initiator_process( + &mut rng, + crate::context::KKTMode::OneWay, + ciphersuite, + initiator_ed25519_keypair.private_key(), + None, + ) + .unwrap(); + + let i_frame_bytes = i_frame.to_bytes(); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + None, + &i_frame_r, + ) + .unwrap(); + + assert!(r_obtained_key.is_none()); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + let r_bytes = r_frame.to_bytes(); + + let i_obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(i_obtained_key.encode(), r_kem_key_bytes) + } + + // Initiator, Mutual + { + let (mut i_context, i_frame) = initiator_process( + &mut rng, + crate::context::KKTMode::Mutual, + ciphersuite, + initiator_ed25519_keypair.private_key(), + Some(&initiator_kem_public_key), + ) + .unwrap(); + + let i_frame_bytes = i_frame.to_bytes(); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + Some(&i_dir_hash), + &i_frame_r, + ) + .unwrap(); + + assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + let r_bytes = r_frame.to_bytes(); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + } + } + } +} diff --git a/common/nym-kkt/src/session.rs b/common/nym-kkt/src/session.rs new file mode 100644 index 00000000000..75492a6170e --- /dev/null +++ b/common/nym-kkt/src/session.rs @@ -0,0 +1,234 @@ +use nym_crypto::asymmetric::ed25519::{self, Signature}; +use rand::{CryptoRng, RngCore}; + +use crate::{ + ciphersuite::{Ciphersuite, EncapsulationKey}, + context::{KKTContext, KKTMode, KKTRole, KKTStatus}, + error::KKTError, + frame::{KKT_SESSION_ID_LEN, KKTFrame}, + key_utils::validate_encapsulation_key, +}; + +pub fn initiator_process<'a, R>( + rng: &mut R, + mode: KKTMode, + ciphersuite: Ciphersuite, + signing_key: &ed25519::PrivateKey, + own_encapsulation_key: Option<&EncapsulationKey<'a>>, +) -> Result<(KKTContext, KKTFrame), KKTError> +where + R: CryptoRng + RngCore, +{ + let context = KKTContext::new(KKTRole::Initiator, mode, ciphersuite)?; + + let context_bytes = context.encode()?; + + let mut session_id = [0; KKT_SESSION_ID_LEN]; + // Generate Session ID + rng.fill_bytes(&mut session_id); + + let body: &[u8] = match mode { + KKTMode::OneWay => &[], + KKTMode::Mutual => match own_encapsulation_key { + Some(encaps_key) => &encaps_key.encode(), + + // Missing key + None => { + return Err(KKTError::FunctionInputError { + info: "KEM Key Not Provided", + }); + } + }, + }; + + let mut bytes_to_sign = + Vec::with_capacity(context.full_message_len() - context.signature_len()); + bytes_to_sign.extend_from_slice(&context_bytes); + bytes_to_sign.extend_from_slice(body); + bytes_to_sign.extend_from_slice(&session_id); + + let signature = signing_key.sign(bytes_to_sign).to_bytes(); + + Ok(( + context, + KKTFrame::new(&context_bytes, body, &session_id, &signature), + )) +} + +pub fn anonymous_initiator_process( + rng: &mut R, + ciphersuite: Ciphersuite, +) -> Result<(KKTContext, KKTFrame), KKTError> +where + R: CryptoRng + RngCore, +{ + let context = KKTContext::new(KKTRole::AnonymousInitiator, KKTMode::OneWay, ciphersuite)?; + let context_bytes = context.encode()?; + + let mut session_id = [0u8; KKT_SESSION_ID_LEN]; + rng.fill_bytes(&mut session_id); + + Ok(( + context, + KKTFrame::new(&context_bytes, &[], &session_id, &[]), + )) +} + +pub fn initiator_ingest_response<'a>( + own_context: &mut KKTContext, + remote_verification_key: &ed25519::PublicKey, + expected_hash: &[u8], + message_bytes: &[u8], +) -> Result, KKTError> { + // sizes have to be correct + let (frame, remote_context) = KKTFrame::from_bytes(message_bytes)?; + + check_compatibility(own_context, &remote_context)?; + match remote_context.status() { + KKTStatus::Ok => { + let mut bytes_to_verify: Vec = Vec::with_capacity( + remote_context.full_message_len() - remote_context.signature_len(), + ); + bytes_to_verify.extend_from_slice(&remote_context.encode()?); + bytes_to_verify.extend_from_slice(frame.body_ref()); + bytes_to_verify.extend_from_slice(frame.session_id_ref()); + + match Signature::from_bytes(frame.signature_ref()) { + Ok(sig) => match remote_verification_key.verify(bytes_to_verify, &sig) { + Ok(()) => { + let received_encapsulation_key = EncapsulationKey::decode( + own_context.ciphersuite().kem(), + frame.body_ref(), + )?; + + match validate_encapsulation_key( + &own_context.ciphersuite().hash_function(), + own_context.ciphersuite().hash_len(), + frame.body_ref(), + expected_hash, + ) { + true => Ok(received_encapsulation_key), + + // The key does not match the hash obtained from the directory + false => Err(KKTError::KEMError { + info: "Hash of received encapsulation key does not match the value stored on the directory.", + }), + } + } + Err(_) => Err(KKTError::SigVerifError), + }, + Err(_) => Err(KKTError::SigConstructorError), + } + } + _ => Err(KKTError::ResponderFlaggedError { + status: remote_context.status(), + }), + } +} + +// todo: figure out how to handle errors using status codes + +pub fn responder_ingest_message<'a>( + remote_context: &KKTContext, + remote_verification_key: Option<&ed25519::PublicKey>, + expected_hash: Option<&[u8]>, + remote_frame: &KKTFrame, +) -> Result<(KKTContext, Option>), KKTError> { + let own_context = remote_context.derive_responder_header()?; + + match remote_context.role() { + KKTRole::AnonymousInitiator => Ok((own_context, None)), + + KKTRole::Initiator => { + match remote_verification_key { + Some(remote_verif_key) => { + let mut bytes_to_verify: Vec = Vec::with_capacity( + own_context.full_message_len() - own_context.signature_len(), + ); + bytes_to_verify.extend_from_slice(remote_frame.context_ref()); + bytes_to_verify.extend_from_slice(remote_frame.body_ref()); + bytes_to_verify.extend_from_slice(remote_frame.session_id_ref()); + + match Signature::from_bytes(remote_frame.signature_ref()) { + Ok(sig) => match remote_verif_key.verify(bytes_to_verify, &sig) { + Ok(()) => { + // using own_context here because maybe for whatever reason we want to ignore the remote kem key + match own_context.mode() { + KKTMode::OneWay => Ok((own_context, None)), + KKTMode::Mutual => { + match expected_hash { + Some(expected_hash) => { + let received_encapsulation_key = + EncapsulationKey::decode( + own_context.ciphersuite().kem(), + remote_frame.body_ref(), + )?; + if validate_encapsulation_key( + &own_context.ciphersuite().hash_function(), + own_context.ciphersuite().hash_len(), + remote_frame.body_ref(), + expected_hash, + ) { + Ok(( + own_context, + Some(received_encapsulation_key), + )) + } + // The key does not match the hash obtained from the directory + else { + Err(KKTError::KEMError { + info: "Hash of received encapsulation key does not match the value stored on the directory.", + }) + } + } + None => Err(KKTError::FunctionInputError { + info: "Expected hash of the remote encapsulation key is not provided.", + }), + } + } + } + } + Err(_) => Err(KKTError::SigVerifError), + }, + Err(_) => Err(KKTError::SigConstructorError), + } + } + None => Err(KKTError::FunctionInputError { + info: "Remote Signature Verification Key Not Provided", + }), + } + } + KKTRole::Responder => Err(KKTError::IncompatibilityError { + info: "Responder received a request from another responder.", + }), + } +} + +pub fn responder_process<'a>( + own_context: &mut KKTContext, + session_id: &[u8], + signing_key: &ed25519::PrivateKey, + encapsulation_key: &EncapsulationKey<'a>, +) -> Result { + let body = encapsulation_key.encode(); + + let context_bytes = own_context.encode()?; + + let mut bytes_to_sign = + Vec::with_capacity(own_context.full_message_len() - own_context.signature_len()); + bytes_to_sign.extend_from_slice(&own_context.encode()?); + bytes_to_sign.extend_from_slice(&body); + bytes_to_sign.extend_from_slice(session_id); + + let signature = signing_key.sign(bytes_to_sign).to_bytes(); + + Ok(KKTFrame::new(&context_bytes, &body, session_id, &signature)) +} + +fn check_compatibility( + _own_context: &KKTContext, + _remote_context: &KKTContext, +) -> Result<(), KKTError> { + // todo: check ciphersuite/context compatibility + Ok(()) +} diff --git a/common/nym-lp-common/Cargo.toml b/common/nym-lp-common/Cargo.toml new file mode 100644 index 00000000000..b70550c82bc --- /dev/null +++ b/common/nym-lp-common/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "nym-lp-common" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } + +[dependencies] diff --git a/common/nym-lp-common/src/lib.rs b/common/nym-lp-common/src/lib.rs new file mode 100644 index 00000000000..4b628789e0d --- /dev/null +++ b/common/nym-lp-common/src/lib.rs @@ -0,0 +1,28 @@ +use std::fmt; +use std::fmt::Write; + +pub fn format_debug_bytes(bytes: &[u8]) -> Result { + 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::() + )?; + + writeln!(out)?; + } + + Ok(out) +} diff --git a/common/nym-lp/Cargo.toml b/common/nym-lp/Cargo.toml new file mode 100644 index 00000000000..ea88885e81e --- /dev/null +++ b/common/nym-lp/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "nym-lp" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } + +[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 } +tracing = { workspace = true } +utoipa = { workspace = true, features = ["macros", "non_strict_integers"] } +rand = { workspace = true } +# rand 0.9 for KKT integration (nym-kkt uses rand 0.9) +rand09 = { package = "rand", version = "0.9.2" } + +nym-crypto = { path = "../crypto", features = ["hashing", "asymmetric"] } +nym-kkt = { path = "../nym-kkt" } +nym-lp-common = { path = "../nym-lp-common" } +nym-sphinx = { path = "../nymsphinx" } + +# libcrux dependencies for PSQ (Post-Quantum PSK derivation) +libcrux-psq = { git = "https://github.com/cryspen/libcrux", features = [ + "test-utils", +] } +libcrux-kem = { git = "https://github.com/cryspen/libcrux" } +libcrux-traits = { git = "https://github.com/cryspen/libcrux" } +tls_codec = { workspace = true } +num_enum = { workspace = true } + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +rand_chacha = "0.3" + + +[[bench]] +name = "replay_protection" +harness = false diff --git a/common/nym-lp/README.md b/common/nym-lp/README.md new file mode 100644 index 00000000000..a9fd7173d5a --- /dev/null +++ b/common/nym-lp/README.md @@ -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. \ No newline at end of file diff --git a/common/nym-lp/benches/replay_protection.rs b/common/nym-lp/benches/replay_protection.rs new file mode 100644 index 00000000000..562982e527e --- /dev/null +++ b/common/nym-lp/benches/replay_protection.rs @@ -0,0 +1,238 @@ +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +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 = (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 = (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 = (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); diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs new file mode 100644 index 00000000000..260d344ec6c --- /dev/null +++ b/common/nym-lp/src/codec.rs @@ -0,0 +1,575 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::LpError; +use crate::message::{ + ClientHelloData, EncryptedDataPayload, HandshakeData, KKTRequestData, KKTResponseData, + LpMessage, MessageType, +}; +use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; +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 { + // 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(HandshakeData(payload_slice.to_vec())) + } + MessageType::EncryptedData => { + // No size validation needed here for EncryptedData, it's variable + LpMessage::EncryptedData(EncryptedDataPayload(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) + } + MessageType::KKTRequest => { + // KKT request contains serialized KKTFrame bytes + LpMessage::KKTRequest(KKTRequestData(payload_slice.to_vec())) + } + MessageType::KKTResponse => { + // KKT response contains serialized KKTFrame bytes + LpMessage::KKTResponse(KKTResponseData(payload_slice.to_vec())) + } + }; + + // 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::LpError; + use crate::message::{EncryptedDataPayload, HandshakeData, LpMessage, MessageType}; + use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; + 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, + reserved: 0, + 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, + reserved: 0, + session_id: 42, + counter: 123, + }, + message: LpMessage::Handshake(HandshakeData(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, HandshakeData(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, + reserved: 0, + session_id: 42, + counter: 123, + }, + message: LpMessage::EncryptedData(EncryptedDataPayload(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, EncryptedDataPayload(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 client_ed25519_key = [43u8; 32]; + let salt = [99u8; 32]; + let hello_data = ClientHelloData { + client_lp_public_key: client_key, + client_ed25519_public_key: client_ed25519_key, + salt, + }; + + // Create a ClientHello message packet + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + 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.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 client_ed25519_key = [8u8; 32]; + let hello_data = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + + // Create a ClientHello message packet + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + 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.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], + client_ed25519_public_key: [version.wrapping_add(2); 32], + salt: [version.wrapping_add(1); 32], + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + 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.client_lp_public_key, [version; 32]); + } + _ => panic!("Expected ClientHello message for version {}", version), + } + } + } +} diff --git a/common/nym-lp/src/error.rs b/common/nym-lp/src/error.rs new file mode 100644 index 00000000000..456bc721546 --- /dev/null +++ b/common/nym-lp/src/error.rs @@ -0,0 +1,81 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{noise_protocol::NoiseError, replay::ReplayError}; +use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError; +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("KKT protocol error: {0}")] + KKTError(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 }, + + /// Ed25519 to X25519 conversion error. + #[error("Ed25519 key conversion error: {0}")] + Ed25519RecoveryError(#[from] Ed25519RecoveryError), +} diff --git a/common/nym-lp/src/keypair.rs b/common/nym-lp/src/keypair.rs new file mode 100644 index 00000000000..6f9546ba520 --- /dev/null +++ b/common/nym-lp/src/keypair.rs @@ -0,0 +1,200 @@ +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; + +#[derive(Clone)] +pub struct PrivateKey(SphinxPrivateKey); + +impl fmt::Debug for PrivateKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("PrivateKey").field(&"[REDACTED]").finish() + } +} + +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 { + let bytes: [u8; 32] = bs58::decode(s).into_vec()?.try_into().unwrap(); + Ok(PrivateKey(SphinxPrivateKey::from(bytes))) + } + + pub fn from_bytes(bytes: &[u8; 32]) -> Self { + PrivateKey(SphinxPrivateKey::from(*bytes)) + } + + pub fn public_key(&self) -> PublicKey { + let public_key = SphinxPublicKey::from(&self.0); + PublicKey(public_key) + } +} + +#[derive(Clone)] +pub struct PublicKey(SphinxPublicKey); + +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("PublicKey") + .field(&self.to_base58_string()) + .finish() + } +} + +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 { + 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 { + 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 from_private_key(private_key: PrivateKey) -> Self { + let public_key = private_key.public_key(); + Self { + private_key, + public_key, + } + } + + pub fn from_keys(private_key: PrivateKey, public_key: PublicKey) -> Self { + 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 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 { + 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::from_base58_string(&self.private) + } + + pub fn public_key(&self) -> Result { + PublicKey::from_base58_string(&self.public) + } +} diff --git a/common/nym-lp/src/kkt_orchestrator.rs b/common/nym-lp/src/kkt_orchestrator.rs new file mode 100644 index 00000000000..5999a9929f2 --- /dev/null +++ b/common/nym-lp/src/kkt_orchestrator.rs @@ -0,0 +1,468 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! KKT (Key Encapsulation Transport) orchestration for nym-lp sessions. +//! +//! This module provides functions to perform KKT key exchange before establishing +//! an nym-lp session. The KKT protocol allows secure distribution of post-quantum +//! KEM public keys, which are then used with PSQ to derive a strong pre-shared key +//! for the Noise protocol. +//! +//! # Protocol Flow +//! +//! 1. **Client (Initiator)**: +//! - Calls `create_request()` to generate a KKT request +//! - Sends `LpMessage::KKTRequest` to gateway +//! - Receives `LpMessage::KKTResponse` from gateway +//! - Calls `process_response()` to validate and extract gateway's KEM key +//! +//! 2. **Gateway (Responder)**: +//! - Receives `LpMessage::KKTRequest` from client +//! - Calls `handle_request()` to validate request and generate response +//! - Sends `LpMessage::KKTResponse` to client +//! +//! # Example +//! +//! ```ignore +//! use nym_lp::kkt_orchestrator::{create_request, process_response, handle_request}; +//! use nym_lp::message::{KKTRequestData, KKTResponseData}; +//! use nym-kkt::ciphersuite::{Ciphersuite, KEM, HashFunction, SignatureScheme, EncapsulationKey}; +//! +//! // Setup ciphersuite +//! let ciphersuite = Ciphersuite::resolve_ciphersuite( +//! KEM::X25519, +//! HashFunction::Blake3, +//! SignatureScheme::Ed25519, +//! None, +//! ).unwrap(); +//! +//! // Client: Create request +//! let (client_context, request_data) = create_request( +//! ciphersuite, +//! &client_signing_key, +//! ).unwrap(); +//! +//! // Gateway: Handle request +//! let response_data = handle_request( +//! &request_data, +//! Some(&client_verification_key), +//! &gateway_signing_key, +//! &gateway_kem_public_key, +//! ).unwrap(); +//! +//! // Client: Process response +//! let gateway_kem_key = process_response( +//! client_context, +//! &gateway_verification_key, +//! &expected_key_hash, +//! &response_data, +//! ).unwrap(); +//! ``` + +use crate::LpError; +use crate::message::{KKTRequestData, KKTResponseData}; +use nym_crypto::asymmetric::ed25519; +use nym_kkt::ciphersuite::{Ciphersuite, EncapsulationKey}; +use nym_kkt::context::KKTContext; +use nym_kkt::frame::KKTFrame; +use nym_kkt::kkt::{handle_kem_request, request_kem_key, validate_kem_response}; + +/// Creates a KKT request to obtain the responder's KEM public key. +/// +/// This is called by the **client (initiator)** to begin the KKT exchange. +/// The returned context must be used when processing the response. +/// +/// # Arguments +/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms) +/// * `signing_key` - Client's Ed25519 signing key for authentication +/// +/// # Returns +/// * `KKTContext` - Context to use when validating the response +/// * `KKTRequestData` - Serialized KKT request frame to send to gateway +/// +/// # Errors +/// Returns `LpError::KKTError` if KKT request generation fails. +pub fn create_request( + ciphersuite: Ciphersuite, + signing_key: &ed25519::PrivateKey, +) -> Result<(KKTContext, KKTRequestData), LpError> { + // Note: Uses rand 0.9's thread_rng() to match nym-kkt's rand version + let mut rng = rand09::rng(); + let (context, frame) = request_kem_key(&mut rng, ciphersuite, signing_key) + .map_err(|e| LpError::KKTError(e.to_string()))?; + + let request_bytes = frame.to_bytes(); + Ok((context, KKTRequestData(request_bytes))) +} + +/// Processes a KKT response and extracts the responder's KEM public key. +/// +/// This is called by the **client (initiator)** after receiving a KKT response +/// from the gateway. It verifies the signature and validates the key hash. +/// +/// # Arguments +/// * `context` - Context from the initial `create_request()` call +/// * `responder_vk` - Responder's Ed25519 verification key (from directory) +/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory) +/// * `response_data` - Serialized KKT response frame from responder +/// +/// # Returns +/// * `EncapsulationKey` - Authenticated KEM public key of the responder +/// +/// # Errors +/// Returns `LpError::KKTError` if: +/// - Response deserialization fails +/// - Signature verification fails +/// - Key hash doesn't match expected value +pub fn process_response<'a>( + mut context: KKTContext, + responder_vk: &ed25519::PublicKey, + expected_key_hash: &[u8], + response_data: &KKTResponseData, +) -> Result, LpError> { + validate_kem_response( + &mut context, + responder_vk, + expected_key_hash, + &response_data.0, + ) + .map_err(|e| LpError::KKTError(e.to_string())) +} + +/// Handles a KKT request and generates a signed response with the responder's KEM key. +/// +/// This is called by the **gateway (responder)** when receiving a KKT request +/// from a client. It validates the request signature (if authenticated) and +/// responds with the gateway's KEM public key, signed for authenticity. +/// +/// # Arguments +/// * `request_data` - Serialized KKT request frame from initiator +/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous) +/// * `responder_signing_key` - Gateway's Ed25519 signing key +/// * `responder_kem_key` - Gateway's KEM public key to send +/// +/// # Returns +/// * `KKTResponseData` - Signed response frame containing the KEM public key +/// +/// # Errors +/// Returns `LpError::KKTError` if: +/// - Request deserialization fails +/// - Signature verification fails (if authenticated) +/// - Response generation fails +pub fn handle_request<'a>( + request_data: &KKTRequestData, + initiator_vk: Option<&ed25519::PublicKey>, + responder_signing_key: &ed25519::PrivateKey, + responder_kem_key: &EncapsulationKey<'a>, +) -> Result { + // Deserialize request frame + let (request_frame, _) = KKTFrame::from_bytes(&request_data.0) + .map_err(|e| LpError::KKTError(format!("Failed to parse KKT request: {}", e)))?; + + // Handle the request and generate response + let response_frame = handle_kem_request( + &request_frame, + initiator_vk, + responder_signing_key, + responder_kem_key, + ) + .map_err(|e| LpError::KKTError(e.to_string()))?; + + let response_bytes = response_frame.to_bytes(); + Ok(KKTResponseData(response_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_kkt::ciphersuite::{HashFunction, KEM, SignatureScheme}; + use nym_kkt::key_utils::{generate_keypair_libcrux, hash_encapsulation_key}; + use rand09::RngCore; + + #[test] + fn test_kkt_roundtrip_authenticated() { + let mut rng = rand09::rng(); + + // Generate Ed25519 keypairs for both parties + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Generate responder's KEM keypair (X25519 for testing) + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + // Create ciphersuite + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Hash the KEM key (simulating directory storage) + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Client: Create request + let (context, request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway: Handle request + let response_data = handle_request( + &request_data, + Some(initiator_keypair.public_key()), + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client: Process response + let obtained_key = process_response( + context, + responder_keypair.public_key(), + &key_hash, + &response_data, + ) + .unwrap(); + + // Verify we got the correct KEM key + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_kkt_roundtrip_anonymous() { + let mut rng = rand09::rng(); + + // Only responder has keys (anonymous initiator) + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Anonymous initiator - use anonymous_initiator_process directly + use nym_kkt::kkt::anonymous_initiator_process; + let (mut context, request_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + let request_data = KKTRequestData(request_frame.to_bytes()); + + // Gateway: Handle anonymous request + let response_data = handle_request( + &request_data, + None, // Anonymous - no verification key + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Initiator: Validate response + let obtained_key = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &key_hash, + &response_data.0, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_invalid_signature_rejected() { + let mut rng = rand09::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Different keypair for wrong signature + let mut wrong_secret = [0u8; 32]; + rng.fill_bytes(&mut wrong_secret); + let wrong_keypair = ed25519::KeyPair::from_secret(wrong_secret, 2); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let (_context, request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway handles request but we provide WRONG verification key + let result = handle_request( + &request_data, + Some(wrong_keypair.public_key()), // Wrong key! + responder_keypair.private_key(), + &responder_kem_key, + ); + + // Should fail signature verification + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } + + #[test] + fn test_hash_mismatch_rejected() { + let mut rng = rand09::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Use WRONG hash + let wrong_hash = [0u8; 32]; + + let (context, request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + let response_data = handle_request( + &request_data, + Some(initiator_keypair.public_key()), + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client validates with WRONG hash + let result = process_response( + context, + responder_keypair.public_key(), + &wrong_hash, // Wrong! + &response_data, + ); + + // Should fail hash validation + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } + + #[test] + fn test_malformed_request_rejected() { + let mut rng = rand09::rng(); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + // Create malformed request data (invalid bytes) + let malformed_request = KKTRequestData(vec![0xFF; 100]); + + let result = handle_request( + &malformed_request, + None, + responder_keypair.private_key(), + &responder_kem_key, + ); + + // Should fail to parse + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } + + #[test] + fn test_malformed_response_rejected() { + let mut rng = rand09::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let (context, _request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Create malformed response data + let malformed_response = KKTResponseData(vec![0xFF; 100]); + let key_hash = [0u8; 32]; + + let result = process_response( + context, + responder_keypair.public_key(), + &key_hash, + &malformed_response, + ); + + // Should fail to parse + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } +} diff --git a/common/nym-lp/src/lib.rs b/common/nym-lp/src/lib.rs new file mode 100644 index 00000000000..c23fec53413 --- /dev/null +++ b/common/nym-lp/src/lib.rs @@ -0,0 +1,379 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod codec; +pub mod error; +pub mod keypair; +pub mod kkt_orchestrator; +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 replay::{ReceivingKeyCounterValidator, ReplayError}; +pub use session::{LpSession, generate_fresh_salt}; +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}; + use nym_crypto::asymmetric::ed25519; + + // X25519 keypairs for Noise protocol + let keypair_1 = Keypair::default(); + let keypair_2 = Keypair::default(); + let id = make_lp_id(keypair_1.public_key(), keypair_2.public_key()); + + // Ed25519 keypairs for PSQ authentication (placeholders for testing) + let ed25519_keypair_1 = ed25519::KeyPair::from_secret([1u8; 32], 0); + let ed25519_keypair_2 = ed25519::KeyPair::from_secret([2u8; 32], 1); + + // Use consistent salt for deterministic tests + let salt = [1u8; 32]; + + // PSQ will always derive the PSK during handshake using X25519 as DHKEM + + let initiator_session = LpSession::new( + id, + true, + ( + ed25519_keypair_1.private_key(), + ed25519_keypair_1.public_key(), + ), + keypair_1.private_key(), + ed25519_keypair_2.public_key(), + keypair_2.public_key(), + &salt, + ) + .expect("Test session creation failed"); + + let responder_session = LpSession::new( + id, + false, + ( + ed25519_keypair_2.private_key(), + ed25519_keypair_2.public_key(), + ), + keypair_2.private_key(), + ed25519_keypair_1.public_key(), + keypair_1.public_key(), + &salt, + ) + .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::PublicKey; + use crate::message::LpMessage; + use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; + use crate::session_manager::SessionManager; + use crate::{LpError, make_lp_id, sessions_for_tests}; + 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, + reserved: 0, + 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, + reserved: 0, + 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, + reserved: 0, + 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() { + use nym_crypto::asymmetric::ed25519; + + // Create session manager + let local_manager = SessionManager::new(); + let remote_manager = SessionManager::new(); + + // Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_local = ed25519::KeyPair::from_secret([8u8; 32], 0); + let ed25519_keypair_remote = ed25519::KeyPair::from_secret([9u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_local = ed25519_keypair_local + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_remote = ed25519_keypair_remote + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_local = PublicKey::from_bytes(x25519_pub_local.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_remote = PublicKey::from_bytes(x25519_pub_remote.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_local, &lp_pub_remote); + + // Test salt + let salt = [46u8; 32]; + + // Create a session via manager + let _ = local_manager + .create_session_state_machine( + ( + ed25519_keypair_local.private_key(), + ed25519_keypair_local.public_key(), + ), + ed25519_keypair_remote.public_key(), + true, + &salt, + ) + .unwrap(); + + let _ = remote_manager + .create_session_state_machine( + ( + ed25519_keypair_remote.private_key(), + ed25519_keypair_remote.public_key(), + ), + ed25519_keypair_local.public_key(), + false, + &salt, + ) + .unwrap(); + // === Packet 1 (Counter 0 - Should succeed) === + let packet1 = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + 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, + reserved: 0, + 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, + reserved: 0, + 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 + } +} diff --git a/common/nym-lp/src/message.rs b/common/nym-lp/src/message.rs new file mode 100644 index 00000000000..bbc0ea99040 --- /dev/null +++ b/common/nym-lp/src/message.rs @@ -0,0 +1,277 @@ +use std::fmt::{self, Display}; + +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 +use bytes::{BufMut, BytesMut}; +use num_enum::{IntoPrimitive, TryFromPrimitive}; +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) - derived from Ed25519 key + pub client_lp_public_key: [u8; 32], + /// Client's Ed25519 public key (32 bytes) - for PSQ authentication + pub client_ed25519_public_key: [u8; 32], + /// 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 (derived from Ed25519) + /// * `client_ed25519_public_key` - Client's Ed25519 public key (for PSQ authentication) + pub fn new_with_fresh_salt( + client_lp_public_key: [u8; 32], + client_ed25519_public_key: [u8; 32], + ) -> 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, + client_ed25519_public_key, + 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, IntoPrimitive, TryFromPrimitive)] +#[repr(u16)] +pub enum MessageType { + Busy = 0x0000, + Handshake = 0x0001, + EncryptedData = 0x0002, + ClientHello = 0x0003, + KKTRequest = 0x0004, + KKTResponse = 0x0005, +} + +impl MessageType { + pub(crate) fn from_u16(value: u16) -> Option { + MessageType::try_from(value).ok() + } + + pub fn to_u16(&self) -> u16 { + u16::from(*self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandshakeData(pub Vec); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedDataPayload(pub Vec); + +/// KKT request frame data (serialized KKTFrame bytes) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KKTRequestData(pub Vec); + +/// KKT response frame data (serialized KKTFrame bytes) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KKTResponseData(pub Vec); + +#[derive(Debug, Clone)] +pub enum LpMessage { + Busy, + Handshake(HandshakeData), + EncryptedData(EncryptedDataPayload), + ClientHello(ClientHelloData), + KKTRequest(KKTRequestData), + KKTResponse(KKTResponseData), +} + +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"), + LpMessage::KKTRequest(_) => write!(f, "KKTRequest"), + LpMessage::KKTResponse(_) => write!(f, "KKTResponse"), + } + } +} + +impl LpMessage { + pub fn payload(&self) -> &[u8] { + match self { + LpMessage::Busy => &[], + LpMessage::Handshake(payload) => payload.0.as_slice(), + LpMessage::EncryptedData(payload) => payload.0.as_slice(), + LpMessage::ClientHello(_) => unimplemented!(), // Structured data, serialized in encode_content + LpMessage::KKTRequest(payload) => payload.0.as_slice(), + LpMessage::KKTResponse(payload) => payload.0.as_slice(), + } + } + + pub fn is_empty(&self) -> bool { + match self { + LpMessage::Busy => true, + LpMessage::Handshake(payload) => payload.0.is_empty(), + LpMessage::EncryptedData(payload) => payload.0.is_empty(), + LpMessage::ClientHello(_) => false, // Always has data + LpMessage::KKTRequest(payload) => payload.0.is_empty(), + LpMessage::KKTResponse(payload) => payload.0.is_empty(), + } + } + + pub fn len(&self) -> usize { + match self { + LpMessage::Busy => 0, + LpMessage::Handshake(payload) => payload.0.len(), + LpMessage::EncryptedData(payload) => payload.0.len(), + LpMessage::ClientHello(_) => 97, // 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt + 1 byte bincode overhead + LpMessage::KKTRequest(payload) => payload.0.len(), + LpMessage::KKTResponse(payload) => payload.0.len(), + } + } + + pub fn typ(&self) -> MessageType { + match self { + LpMessage::Busy => MessageType::Busy, + LpMessage::Handshake(_) => MessageType::Handshake, + LpMessage::EncryptedData(_) => MessageType::EncryptedData, + LpMessage::ClientHello(_) => MessageType::ClientHello, + LpMessage::KKTRequest(_) => MessageType::KKTRequest, + LpMessage::KKTResponse(_) => MessageType::KKTResponse, + } + } + + pub fn encode_content(&self, dst: &mut BytesMut) { + match self { + LpMessage::Busy => { /* No content */ } + LpMessage::Handshake(payload) => { + dst.put_slice(&payload.0); + } + LpMessage::EncryptedData(payload) => { + dst.put_slice(&payload.0); + } + LpMessage::ClientHello(data) => { + // Serialize ClientHelloData using bincode + let serialized = + bincode::serialize(data).expect("Failed to serialize ClientHelloData"); + dst.put_slice(&serialized); + } + LpMessage::KKTRequest(payload) => { + dst.put_slice(&payload.0); + } + LpMessage::KKTResponse(payload) => { + dst.put_slice(&payload.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LpPacket; + use crate::packet::{LpHeader, TRAILER_LEN}; + + #[test] + fn encoding() { + let message = LpMessage::EncryptedData(EncryptedDataPayload(vec![11u8; 124])); + + let resp_header = LpHeader { + protocol_version: 1, + reserved: 0, + 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, EncryptedDataPayload(vec![11u8; 124])); + } + _ => panic!("Wrong message type"), + } + } + + #[test] + fn test_client_hello_salt_generation() { + let client_key = [1u8; 32]; + let client_ed25519_key = [2u8; 32]; + let hello1 = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + let hello2 = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + + // 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 client_ed25519_key = [3u8; 32]; + let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + + 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 client_ed25519_key = [4u8; 32]; + let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + + // 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); + } +} diff --git a/common/nym-lp/src/noise_protocol.rs b/common/nym-lp/src/noise_protocol.rs new file mode 100644 index 00000000000..42b5e0308f2 --- /dev/null +++ b/common/nym-lp/src/noise_protocol.rs @@ -0,0 +1,327 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Sans-IO Noise protocol state machine, adapted from noise-psq. + +use snow::{TransportState, params::NoiseParams}; +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("attempted transport mode operation without real PSK injection")] + PskNotInjected, + + #[error("Other Noise-related error: {0}")] + Other(String), +} + +impl From 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), + + /// 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, + // write_buffer: Vec, +} + +/// 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), + /// 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 { + // 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, 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, 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(_)) + } + + /// Inject a PSK into the Noise HandshakeState. + /// + /// This allows dynamic PSK injection after HandshakeState construction, + /// which is required for PSQ (Post-Quantum Secure PSK) integration where + /// the PSK is derived during the handshake process. + /// + /// # Arguments + /// * `index` - PSK index (typically 3 for XKpsk3 pattern) + /// * `psk` - The pre-shared key bytes to inject + /// + /// # Errors + /// Returns an error if: + /// - Not in handshake state + /// - The underlying snow library rejects the PSK + pub fn set_psk(&mut self, index: u8, psk: &[u8]) -> Result<(), NoiseError> { + match &mut self.state { + NoiseProtocolState::Handshaking(handshake_state) => { + handshake_state + .set_psk(index as usize, psk) + .map_err(NoiseError::ProtocolError)?; + Ok(()) + } + _ => Err(NoiseError::IncorrectStateError), + } + } +} + +pub fn create_noise_state( + local_private_key: &[u8], + remote_public_key: &[u8], + psk: &[u8], +) -> Result { + 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 { + 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)) +} diff --git a/common/nym-lp/src/packet.rs b/common/nym-lp/src/packet.rs new file mode 100644 index 00000000000..193fa0704d6 --- /dev/null +++ b/common/nym-lp/src/packet.rs @@ -0,0 +1,197 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::LpError; +use crate::message::LpMessage; +use crate::replay::ReceivingKeyCounterValidator; +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 { + 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>, + ) -> 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>, + ) -> 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 reserved: u16, + 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, + reserved: 0, + 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 { + 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, + reserved: 0, + 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 diff --git a/common/nym-lp/src/psk.rs b/common/nym-lp/src/psk.rs new file mode 100644 index 00000000000..f4f416b3a77 --- /dev/null +++ b/common/nym-lp/src/psk.rs @@ -0,0 +1,702 @@ +// Copyright 2025 - Nym Technologies SA +// 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. +//! +//! Two approaches are supported: +//! - **Legacy ECDH-only** (`derive_psk`) - Simple but no post-quantum security +//! - **PSQ-enhanced** (`derive_psk_with_psq_*`) - Combines ECDH with post-quantum KEM +//! +//! ## Error Handling Strategy +//! +//! **PSQ failures always abort the handshake cleanly with no retry or fallback.** +//! +//! ### Rationale +//! +//! PSQ errors indicate: +//! - **Authentication failures** (CredError) - Potential attack or misconfiguration +//! - **Timing failures** (TimestampElapsed) - Replay attacks or clock skew +//! - **Crypto failures** (CryptoError) - Library bugs or hardware faults +//! - **Serialization failures** (Serialization) - Protocol violations or corruption +//! +//! None of these are transient errors that benefit from retry. Falling back to +//! ECDH-only PSK would silently degrade post-quantum security. +//! +//! ### Error Recovery Behavior +//! +//! On any PSQ error: +//! 1. Function returns `Err(LpError)` immediately +//! 2. Session state remains unchanged (dummy PSK, clean Noise state) +//! 3. Handshake aborts - caller must start fresh connection +//! 4. Error is logged with diagnostic context +//! +//! ### State Guarantees on Error +//! +//! - **`psq_state`**: Remains in `NotStarted` (initiator) or `ResponderWaiting` (responder) +//! - **Noise `HandshakeState`**: PSK slot 3 = dummy `[0u8; 32]` (not modified on error) +//! - **No partial data**: All allocations are stack-local to failed function +//! - **No cleanup needed**: No state was mutated + +use crate::LpError; +use crate::keypair::{PrivateKey, PublicKey}; +use libcrux_psq::v1::cred::{Authenticator, Ed25519}; +use libcrux_psq::v1::impls::X25519 as PsqX25519; +use libcrux_psq::v1::psk_registration::{Initiator, InitiatorMsg, Responder}; +use libcrux_psq::v1::traits::{Ciphertext as PsqCiphertext, PSQ}; +use nym_crypto::asymmetric::ed25519; +use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey}; +use std::time::Duration; +use tls_codec::{Deserialize as TlsDeserializeTrait, Serialize as TlsSerializeTrait}; + +/// Context string for Blake3 KDF domain separation (PSQ-enhanced). +const PSK_PSQ_CONTEXT: &str = "nym-lp-psk-psq-v1"; + +/// Session context for PSQ protocol. +const PSQ_SESSION_CONTEXT: &[u8] = b"nym-lp-psq-session"; + +/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Initiator side. +/// +/// This function combines classical ECDH with post-quantum KEM to provide forward secrecy +/// and HNDL (Harvest-Now, Decrypt-Later) resistance. +/// +/// # Formula +/// ```text +/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public) +/// (psq_psk, ct) = PSQ_Encapsulate(remote_kem_public, session_context) +/// psk = Blake3_derive_key( +/// context="nym-lp-psk-psq-v1", +/// input=ecdh_secret || psq_psk || salt +/// ) +/// ``` +/// +/// # Arguments +/// * `local_x25519_private` - Initiator's X25519 private key (for Noise) +/// * `remote_x25519_public` - Responder's X25519 public key (for Noise) +/// * `remote_kem_public` - Responder's KEM public key (obtained via KKT) +/// * `salt` - 32-byte salt for session binding +/// +/// # Returns +/// * `Ok((psk, ciphertext))` - PSK and ciphertext to send to responder +/// * `Err(LpError)` - If PSQ encapsulation fails +/// +/// # Example +/// ```ignore +/// // Client side (after KKT exchange) +/// let (psk, ciphertext) = derive_psk_with_psq_initiator( +/// client_x25519_private, +/// gateway_x25519_public, +/// &gateway_kem_key, // from KKT +/// &salt +/// )?; +/// // Send ciphertext to gateway +/// ``` +pub fn derive_psk_with_psq_initiator( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + remote_kem_public: &EncapsulationKey, + salt: &[u8; 32], +) -> Result<([u8; 32], Vec), LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: PSQ encapsulation for post-quantum security + // Extract X25519 public key from EncapsulationKey + let kem_pk = match remote_kem_public { + EncapsulationKey::X25519(pk) => pk, + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + let mut rng = rand09::rng(); + let (psq_psk, ciphertext) = + PsqX25519::encapsulate_psq(kem_pk, PSQ_SESSION_CONTEXT, &mut rng) + .map_err(|e| LpError::Internal(format!("PSQ encapsulation failed: {:?}", e)))?; + + // Step 3: Combine ECDH + PSQ via Blake3 KDF + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need & + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + // Serialize ciphertext using TLS encoding for transport + let ct_bytes = ciphertext + .tls_serialize_detached() + .map_err(|e| LpError::Internal(format!("Ciphertext serialization failed: {:?}", e)))?; + + Ok((final_psk, ct_bytes)) +} + +/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Responder side. +/// +/// This function decapsulates the ciphertext from the initiator and combines it with +/// ECDH to derive the same PSK. +/// +/// # Formula +/// ```text +/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public) +/// psq_psk = PSQ_Decapsulate(local_kem_keypair, ciphertext, session_context) +/// psk = Blake3_derive_key( +/// context="nym-lp-psk-psq-v1", +/// input=ecdh_secret || psq_psk || salt +/// ) +/// ``` +/// +/// # Arguments +/// * `local_x25519_private` - Responder's X25519 private key (for Noise) +/// * `remote_x25519_public` - Initiator's X25519 public key (for Noise) +/// * `local_kem_keypair` - Responder's KEM keypair (decapsulation key, public key) +/// * `ciphertext` - PSQ ciphertext from initiator +/// * `salt` - 32-byte salt for session binding +/// +/// # Returns +/// * `Ok(psk)` - Derived PSK +/// * `Err(LpError)` - If PSQ decapsulation fails +/// +/// # Example +/// ```ignore +/// // Gateway side (after receiving ciphertext) +/// let psk = derive_psk_with_psq_responder( +/// gateway_x25519_private, +/// client_x25519_public, +/// (&gateway_kem_sk, &gateway_kem_pk), +/// &ciphertext, // from client +/// &salt +/// )?; +/// ``` +pub fn derive_psk_with_psq_responder( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + local_kem_keypair: (&DecapsulationKey, &EncapsulationKey), + ciphertext: &[u8], + salt: &[u8; 32], +) -> Result<[u8; 32], LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey + let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) { + (DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk), + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + // Step 3: Deserialize ciphertext using TLS decoding + let ct = PsqCiphertext::::tls_deserialize(&mut &ciphertext[..]) + .map_err(|e| LpError::Internal(format!("Ciphertext deserialization failed: {:?}", e)))?; + + // Step 4: PSQ decapsulation for post-quantum security + let psq_psk = PsqX25519::decapsulate_psq(kem_sk, kem_pk, &ct, PSQ_SESSION_CONTEXT) + .map_err(|e| LpError::Internal(format!("PSQ decapsulation failed: {:?}", e)))?; + + // Step 5: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator) + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need & + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + Ok(final_psk) +} + +/// PSQ protocol wrapper for initiator (client) side. +/// +/// Creates a PSQ initiator message with Ed25519 authentication, following the protocol: +/// 1. Encapsulate PSK using responder's KEM key +/// 2. Derive PSK and AEAD keys from K_pq +/// 3. Sign the encapsulation with Ed25519 +/// 4. AEAD encrypt (timestamp || signature || public_key) +/// +/// Returns (PSK, serialized_payload) where payload includes enc_pq and encrypted auth data. +/// +/// # Arguments +/// * `local_x25519_private` - Client's X25519 private key (for hybrid ECDH) +/// * `remote_x25519_public` - Gateway's X25519 public key (for hybrid ECDH) +/// * `remote_kem_public` - Gateway's PQ KEM public key (from KKT) +/// * `client_ed25519_sk` - Client's Ed25519 signing key +/// * `client_ed25519_pk` - Client's Ed25519 public key (credential) +/// * `salt` - Session salt +/// * `session_context` - Context bytes for PSQ (e.g., b"nym-lp-psq-session") +/// +/// # Returns +/// `(psk, psq_payload_bytes)` - PSK for Noise and serialized PSQ payload to embed +pub fn psq_initiator_create_message( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + remote_kem_public: &EncapsulationKey, + client_ed25519_sk: &ed25519::PrivateKey, + client_ed25519_pk: &ed25519::PublicKey, + salt: &[u8; 32], + session_context: &[u8], +) -> Result<([u8; 32], Vec), LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: PSQ v1 with Ed25519 authentication + // Extract X25519 KEM key from EncapsulationKey + let kem_pk = match remote_kem_public { + EncapsulationKey::X25519(pk) => pk, + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + // Convert nym Ed25519 keys to libcrux format + type Ed25519VerificationKey = ::VerificationKey; + let ed25519_sk_bytes = client_ed25519_sk.to_bytes(); + let ed25519_pk_bytes = client_ed25519_pk.to_bytes(); + let ed25519_verification_key = Ed25519VerificationKey::from_bytes(ed25519_pk_bytes); + + // Use PSQ v1 API with Ed25519 authentication + let mut rng = rand09::rng(); + let (state, initiator_msg) = Initiator::send_initial_message::( + session_context, + Duration::from_secs(3600), // 1 hour expiry + kem_pk, + &ed25519_sk_bytes, + &ed25519_verification_key, + &mut rng, + ) + .map_err(|e| { + tracing::error!( + "PSQ initiator failed - KEM encapsulation or signing error: {:?}", + e + ); + LpError::Internal(format!("PSQ v1 send_initial_message failed: {:?}", e)) + })?; + + // Extract PSQ shared secret (unregistered PSK) + let psq_psk = state.unregistered_psk(); + + // Step 3: Combine ECDH + PSQ via Blake3 KDF + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(psq_psk); // psq_psk is already a &[u8; 32] + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + // Serialize InitiatorMsg with TLS encoding for transport + let msg_bytes = initiator_msg + .tls_serialize_detached() + .map_err(|e| LpError::Internal(format!("InitiatorMsg serialization failed: {:?}", e)))?; + + Ok((final_psk, msg_bytes)) +} + +/// PSQ protocol wrapper for responder (gateway) side. +/// +/// Processes a PSQ initiator message, verifies authentication, and derives PSK. +/// Follows the protocol: +/// 1. Decapsulate to get K_pq +/// 2. Derive AEAD keys and verify encrypted auth data +/// 3. Verify Ed25519 signature +/// 4. Check timestamp validity +/// 5. Derive PSK +/// +/// # Arguments +/// * `local_x25519_private` - Gateway's X25519 private key (for hybrid ECDH) +/// * `remote_x25519_public` - Client's X25519 public key (for hybrid ECDH) +/// * `local_kem_keypair` - Gateway's PQ KEM keypair +/// * `initiator_ed25519_pk` - Client's Ed25519 public key (for signature verification) +/// * `psq_payload` - Serialized PSQ payload from initiator +/// * `salt` - Session salt (must match initiator's) +/// * `session_context` - Context bytes for PSQ +/// +/// # Returns +/// `psk` - Derived PSK for Noise +/// Processes a PSQ initiator message and generates a PSK with encrypted handle. +/// +/// Returns a tuple of (derived_psk, responder_msg_bytes) where responder_msg_bytes +/// contains the encrypted PSK handle (ctxt_B) that should be sent to the initiator. +pub fn psq_responder_process_message( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + local_kem_keypair: (&DecapsulationKey, &EncapsulationKey), + initiator_ed25519_pk: &ed25519::PublicKey, + psq_payload: &[u8], + salt: &[u8; 32], + session_context: &[u8], +) -> Result<([u8; 32], Vec), LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey + let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) { + (DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk), + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + // Step 3: Deserialize InitiatorMsg using TLS decoding + let initiator_msg = InitiatorMsg::::tls_deserialize(&mut &psq_payload[..]) + .map_err(|e| LpError::Internal(format!("InitiatorMsg deserialization failed: {:?}", e)))?; + + // Step 4: Convert nym Ed25519 public key to libcrux VerificationKey format + type Ed25519VerificationKey = ::VerificationKey; + let initiator_ed25519_pk_bytes = initiator_ed25519_pk.to_bytes(); + let initiator_verification_key = Ed25519VerificationKey::from_bytes(initiator_ed25519_pk_bytes); + + // Step 5: PSQ v1 responder processing with Ed25519 verification + let (registered_psk, responder_msg) = Responder::send::( + b"nym-lp-handle", // PSK storage handle + Duration::from_secs(3600), // 1 hour expiry (must match initiator) + session_context, // Must match initiator's session_context + kem_pk, // Responder's public key + kem_sk, // Responder's secret key + &initiator_verification_key, // Initiator's Ed25519 public key for verification + &initiator_msg, // InitiatorMsg to verify and process + ) + .map_err(|e| { + use libcrux_psq::v1::Error as PsqError; + match e { + PsqError::CredError => { + tracing::warn!( + "PSQ responder auth failure - invalid Ed25519 signature (potential attack)" + ); + } + PsqError::TimestampElapsed | PsqError::RegistrationError => { + tracing::warn!( + "PSQ responder timing failure - TTL expired (potential replay attack)" + ); + } + _ => { + tracing::error!("PSQ responder failed - {:?}", e); + } + } + LpError::Internal(format!("PSQ v1 responder send failed: {:?}", e)) + })?; + + // Extract the PSQ PSK from the registered PSK + let psq_psk = registered_psk.psk; + + // Step 6: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator) + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need & + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + // Step 7: Serialize ResponderMsg (contains ctxt_B - encrypted PSK handle) + use tls_codec::Serialize; + let responder_msg_bytes = responder_msg + .tls_serialize_detached() + .map_err(|e| LpError::Internal(format!("ResponderMsg serialization failed: {:?}", e)))?; + + Ok((final_psk, responder_msg_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keypair::Keypair; + + #[test] + fn test_psk_derivation_is_symmetric() { + let keypair_1 = Keypair::default(); + let keypair_2 = Keypair::default(); + let salt = [2u8; 32]; + + let mut rng = &mut rand09::rng(); + let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + let dec_key = DecapsulationKey::X25519(_kem_sk); + + // Client derives PSK + let (client_psk, ciphertext) = derive_psk_with_psq_initiator( + keypair_1.private_key(), + keypair_2.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + // Gateway derives PSK from their perspective + let gateway_psk = derive_psk_with_psq_responder( + keypair_2.private_key(), + keypair_1.public_key(), + (&dec_key, &enc_key), + &ciphertext, + &salt, + ) + .unwrap(); + + 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 mut rng = &mut rand09::rng(); + let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + + let psk1 = derive_psk_with_psq_initiator( + keypair_1.private_key(), + keypair_2.public_key(), + &enc_key, + &salt1, + ) + .unwrap(); + let psk2 = derive_psk_with_psq_initiator( + keypair_1.private_key(), + keypair_2.public_key(), + &enc_key, + &salt2, + ) + .unwrap(); + + 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 mut rng = &mut rand09::rng(); + let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + + let psk1 = derive_psk_with_psq_initiator( + keypair_1.private_key(), + keypair_2.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + let psk2 = derive_psk_with_psq_initiator( + keypair_1.private_key(), + keypair_3.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + assert_ne!( + psk1, psk2, + "Different remote keys should produce different PSKs" + ); + } + + // PSQ-enhanced PSK tests + use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey, KEM}; + use nym_kkt::key_utils::generate_keypair_libcrux; + + #[test] + fn test_psq_derivation_deterministic() { + let mut rng = rand09::rng(); + + // Generate X25519 keypairs for Noise + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + // Generate KEM keypair for PSQ + let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + let dec_key = DecapsulationKey::X25519(kem_sk); + + let salt = [1u8; 32]; + + // Derive PSK twice with same inputs (initiator side) + let (_psk1, ct1) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + let (_psk2, _ct2) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + // PSKs will be different due to randomness in PSQ, but ciphertexts too + // This test verifies the function is deterministic given the SAME ciphertext + let psk_responder1 = derive_psk_with_psq_responder( + gateway_keypair.private_key(), + client_keypair.public_key(), + (&dec_key, &enc_key), + &ct1, + &salt, + ) + .unwrap(); + + let psk_responder2 = derive_psk_with_psq_responder( + gateway_keypair.private_key(), + client_keypair.public_key(), + (&dec_key, &enc_key), + &ct1, // Same ciphertext + &salt, + ) + .unwrap(); + + assert_eq!( + psk_responder1, psk_responder2, + "Same ciphertext should produce same PSK" + ); + } + + #[test] + fn test_psq_derivation_symmetric() { + let mut rng = rand09::rng(); + + // Generate X25519 keypairs for Noise + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + // Generate KEM keypair for PSQ + let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + let dec_key = DecapsulationKey::X25519(kem_sk); + + let salt = [2u8; 32]; + + // Client derives PSK (initiator) + let (client_psk, ciphertext) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + // Gateway derives PSK from ciphertext (responder) + let gateway_psk = derive_psk_with_psq_responder( + gateway_keypair.private_key(), + client_keypair.public_key(), + (&dec_key, &enc_key), + &ciphertext, + &salt, + ) + .unwrap(); + + assert_eq!( + client_psk, gateway_psk, + "Both sides should derive identical PSK via PSQ" + ); + } + + #[test] + fn test_different_kem_keys_different_psk() { + let mut rng = rand09::rng(); + + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + // Two different KEM keypairs + let (_, kem_pk1) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let (_, kem_pk2) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + + let enc_key1 = EncapsulationKey::X25519(kem_pk1); + let enc_key2 = EncapsulationKey::X25519(kem_pk2); + + let salt = [3u8; 32]; + + let (psk1, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key1, + &salt, + ) + .unwrap(); + + let (psk2, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key2, + &salt, + ) + .unwrap(); + + assert_ne!( + psk1, psk2, + "Different KEM keys should produce different PSKs" + ); + } + + #[test] + fn test_psq_psk_output_length() { + let mut rng = rand09::rng(); + + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + + let salt = [4u8; 32]; + + let (psk, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + assert_eq!(psk.len(), 32, "PSQ PSK should be exactly 32 bytes"); + } + + #[test] + fn test_psq_different_salts_different_psks() { + let mut rng = rand09::rng(); + + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + + let salt1 = [1u8; 32]; + let salt2 = [2u8; 32]; + + let (psk1, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt1, + ) + .unwrap(); + + let (psk2, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt2, + ) + .unwrap(); + + assert_ne!(psk1, psk2, "Different salts should produce different PSKs"); + } +} diff --git a/common/nym-lp/src/replay/error.rs b/common/nym-lp/src/replay/error.rs new file mode 100644 index 00000000000..6422eb86131 --- /dev/null +++ b/common/nym-lp/src/replay/error.rs @@ -0,0 +1,64 @@ +// Copyright 2025 - Nym Technologies SA +// 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 = Result; + +#[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 = ReplayError::InvalidCounter; + + assert!(ok_result.is_ok()); + assert!(matches!(err, ReplayError::InvalidCounter)); + } +} diff --git a/common/nym-lp/src/replay/mod.rs b/common/nym-lp/src/replay/mod.rs new file mode 100644 index 00000000000..6363600b4ca --- /dev/null +++ b/common/nym-lp/src/replay/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2025 - Nym Technologies SA +// 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; diff --git a/common/nym-lp/src/replay/simd/arm.rs b/common/nym-lp/src/replay/simd/arm.rs new file mode 100644 index 00000000000..cdf0302d6c7 --- /dev/null +++ b/common/nym-lp/src/replay/simd/arm.rs @@ -0,0 +1,281 @@ +// Copyright 2025 - Nym Technologies SA +// 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 = unsafe { 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 + unsafe { + 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; + } + } +} diff --git a/common/nym-lp/src/replay/simd/mod.rs b/common/nym-lp/src/replay/simd/mod.rs new file mode 100644 index 00000000000..3537725f601 --- /dev/null +++ b/common/nym-lp/src/replay/simd/mod.rs @@ -0,0 +1,71 @@ +// Copyright 2025 - Nym Technologies SA +// 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 diff --git a/common/nym-lp/src/replay/simd/scalar.rs b/common/nym-lp/src/replay/simd/scalar.rs new file mode 100644 index 00000000000..9da15f8cb71 --- /dev/null +++ b/common/nym-lp/src/replay/simd/scalar.rs @@ -0,0 +1,114 @@ +// Copyright 2025 - Nym Technologies SA +// 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; + } + } +} diff --git a/common/nym-lp/src/replay/simd/x86.rs b/common/nym-lp/src/replay/simd/x86.rs new file mode 100644 index 00000000000..6d9fda71ac2 --- /dev/null +++ b/common/nym-lp/src/replay/simd/x86.rs @@ -0,0 +1,489 @@ +// Copyright 2025 - Nym Technologies SA +// 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; + } + } +} diff --git a/common/nym-lp/src/replay/validator.rs b/common/nym-lp/src/replay/validator.rs new file mode 100644 index 00000000000..1d842b4913f --- /dev/null +++ b/common/nym-lp/src/replay/validator.rs @@ -0,0 +1,879 @@ +// Copyright 2025 - Nym Technologies SA +// 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::() * 2 + // next + receive_cnt + size_of::() * 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 [64usize, 128, 256, 512, 1024, 2048] { + let words_needed = window_size.div_ceil(WORD_SIZE); + let memory_needed = size_of::() * 2 + size_of::() * 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() { + // Set a very large next value, close to u64::MAX + let mut validator = ReceivingKeyCounterValidator { + next: u64::MAX - 1000, + ..Default::default() + }; + + // 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) + )); + } +} diff --git a/common/nym-lp/src/session.rs b/common/nym-lp/src/session.rs new file mode 100644 index 00000000000..e58a2200f1a --- /dev/null +++ b/common/nym-lp/src/session.rs @@ -0,0 +1,1898 @@ +// Copyright 2025 - Nym Technologies SA +// 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::keypair::{PrivateKey, PublicKey}; +use crate::message::{EncryptedDataPayload, HandshakeData}; +use crate::noise_protocol::{NoiseError, NoiseProtocol, ReadResult}; +use crate::packet::LpHeader; +use crate::psk::{psq_initiator_create_message, psq_responder_process_message}; +use crate::replay::ReceivingKeyCounterValidator; +use crate::{LpError, LpMessage, LpPacket}; +use nym_crypto::asymmetric::ed25519; +use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey}; +use parking_lot::Mutex; +use snow::Builder; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +/// KKT (KEM Key Transfer) exchange state. +/// +/// Tracks the KKT protocol for obtaining the responder's KEM public key +/// before PSQ can begin. This allows post-quantum KEM algorithms to be +/// used even when keys are not pre-published. +/// +/// # State Transitions +/// +/// **Initiator path:** +/// ```text +/// NotStarted → InitiatorWaiting → Completed +/// ``` +/// +/// **Responder path:** +/// ```text +/// NotStarted → ResponderProcessed +/// ``` +pub enum KKTState { + /// KKT exchange not started. + NotStarted, + + /// Initiator sent KKT request and is waiting for responder's KEM key. + InitiatorWaiting { + /// KKT context for verifying the response + context: nym_kkt::context::KKTContext, + }, + + /// KKT exchange completed (initiator received and validated KEM key). + Completed { + /// Responder's KEM public key for PSQ encapsulation + kem_pk: Box>, + }, + + /// Responder processed a KKT request and sent response. + /// Responder uses their own KEM keypair, not the one from KKT. + ResponderProcessed, +} + +impl std::fmt::Debug for KKTState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotStarted => write!(f, "KKTState::NotStarted"), + Self::InitiatorWaiting { context } => f + .debug_struct("KKTState::InitiatorWaiting") + .field("context", context) + .finish(), + Self::Completed { .. } => write!(f, "KKTState::Completed {{ kem_pk: }}"), + Self::ResponderProcessed => write!(f, "KKTState::ResponderProcessed"), + } + } +} + +/// PSQ (Post-Quantum Secure PSK) handshake state. +/// +/// Tracks the PSQ protocol state machine through the session lifecycle. +/// +/// # State Transitions +/// +/// **Initiator path:** +/// ```text +/// NotStarted → InitiatorWaiting → Completed +/// ``` +/// +/// **Responder path:** +/// ```text +/// NotStarted → ResponderWaiting → Completed +/// ``` +#[derive(Debug)] +pub enum PSQState { + /// PSQ handshake not yet started. + NotStarted, + + /// Initiator has sent PSQ ciphertext and is waiting for confirmation. + /// Stores the ciphertext that was sent. + InitiatorWaiting { ciphertext: Vec }, + + /// Responder is ready to receive and decapsulate PSQ ciphertext. + ResponderWaiting, + + /// PSQ handshake completed successfully. + /// The PSK has been derived and registered with the Noise protocol. + Completed { + /// The derived post-quantum PSK + psk: [u8; 32], + }, +} + +/// 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. +/// +/// ## PSK Injection Lifecycle +/// +/// 1. Session created with dummy PSK `[0u8; 32]` in Noise HandshakeState +/// 2. During handshake, PSQ runs and derives real post-quantum PSK +/// 3. Real PSK injected via `set_psk()` - `psk_injected` flag set to `true` +/// 4. Handshake completes, transport mode available +/// 5. Transport operations (`encrypt_data`/`decrypt_data`) check `psk_injected` flag for safety +#[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, + + /// KKT (KEM Key Transfer) exchange state + kkt_state: Mutex, + + /// PSQ (Post-Quantum Secure PSK) handshake state + psq_state: Mutex, + + /// PSK handle from responder (ctxt_B) for future re-registration + psk_handle: Mutex>>, + + /// Counter for outgoing packets + sending_counter: AtomicU64, + + /// Validator for incoming packet counters to prevent replay attacks + receiving_counter: Mutex, + + /// Safety flag: `true` if real PSK was injected via PSQ, `false` if still using dummy PSK. + /// This prevents transport mode operations from running with the insecure dummy `[0u8; 32]` PSK. + psk_injected: AtomicBool, + + // PSQ-related keys stored for handshake + /// Local Ed25519 private key for PSQ authentication + local_ed25519_private: ed25519::PrivateKey, + + /// Local Ed25519 public key for PSQ authentication + local_ed25519_public: ed25519::PublicKey, + + /// Remote Ed25519 public key for PSQ authentication + remote_ed25519_public: ed25519::PublicKey, + + /// Local X25519 private key (Noise static key) + local_x25519_private: PrivateKey, + + /// Remote X25519 public key (Noise static key) + remote_x25519_public: PublicKey, + + /// Salt for PSK derivation + salt: [u8; 32], +} + +/// Generates a fresh salt for PSK derivation. +/// +/// Salt format: 8 bytes timestamp (u64 LE) + 24 bytes random nonce +/// +/// This ensures each session derives a unique PSK, even with the same key pairs. +/// The timestamp provides temporal uniqueness while the random nonce prevents collisions. +/// +/// # Returns +/// A 32-byte array containing fresh salt material +pub fn generate_fresh_salt() -> [u8; 32] { + use rand::RngCore; + use std::time::{SystemTime, UNIX_EPOCH}; + + 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 + rand::thread_rng().fill_bytes(&mut salt[8..]); + + salt +} + +impl LpSession { + pub fn id(&self) -> u32 { + self.id + } + + pub fn noise_state(&self) -> &Mutex { + &self.noise_state + } + + /// Returns true if this session was created as the initiator. + pub fn is_initiator(&self) -> bool { + self.is_initiator + } + + /// Returns the local X25519 public key derived from the private key. + /// + /// This is used for KKT protocol when the responder needs to send their + /// KEM public key in the KKT response. + pub fn local_x25519_public(&self) -> PublicKey { + self.local_x25519_private.public_key() + } + + /// Creates a new session and initializes the Noise protocol state. + /// + /// PSQ always runs during the handshake to derive the real PSK from X25519 DHKEM. + /// The Noise protocol is initialized with a dummy PSK that gets replaced during handshake. + /// + /// # Arguments + /// + /// * `id` - Session identifier + /// * `is_initiator` - True if this side initiates the Noise handshake. + /// * `local_ed25519_keypair` - This side's Ed25519 keypair for PSQ authentication + /// * `local_x25519_key` - This side's X25519 private key for Noise protocol and DHKEM + /// * `remote_ed25519_key` - Peer's Ed25519 public key for PSQ authentication + /// * `remote_x25519_key` - Peer's X25519 public key for Noise protocol and DHKEM + /// * `salt` - Salt for PSK derivation + pub fn new( + id: u32, + is_initiator: bool, + local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), + local_x25519_key: &PrivateKey, + remote_ed25519_key: &ed25519::PublicKey, + remote_x25519_key: &PublicKey, + salt: &[u8; 32], + ) -> Result { + // 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 local_key_bytes = local_x25519_key.to_bytes(); + let builder = builder.local_private_key(&local_key_bytes); + + let remote_key_bytes = remote_x25519_key.to_bytes(); + let builder = builder.remote_public_key(&remote_key_bytes); + + // Initialize with dummy PSK - real PSK will be injected via set_psk() during handshake + // when PSQ runs using X25519 as DHKEM + let dummy_psk = [0u8; 32]; + let builder = builder.psk(psk_index, &dummy_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); + + // Initialize KKT state - both roles start at NotStarted + let kkt_state = KKTState::NotStarted; + + // Initialize PSQ state based on role + let psq_state = if is_initiator { + PSQState::NotStarted + } else { + PSQState::ResponderWaiting + }; + + Ok(Self { + id, + is_initiator, + noise_state: Mutex::new(noise_protocol), + kkt_state: Mutex::new(kkt_state), + psq_state: Mutex::new(psq_state), + psk_handle: Mutex::new(None), + sending_counter: AtomicU64::new(0), + receiving_counter: Mutex::new(ReceivingKeyCounterValidator::default()), + psk_injected: AtomicBool::new(false), + // Ed25519 keys don't impl Clone, so convert to bytes and reconstruct + local_ed25519_private: ed25519::PrivateKey::from_bytes( + &local_ed25519_keypair.0.to_bytes(), + ) + .expect("Valid ed25519 private key"), + local_ed25519_public: ed25519::PublicKey::from_bytes( + &local_ed25519_keypair.1.to_bytes(), + ) + .expect("Valid ed25519 public key"), + remote_ed25519_public: ed25519::PublicKey::from_bytes(&remote_ed25519_key.to_bytes()) + .expect("Valid ed25519 public key"), + local_x25519_private: local_x25519_key.clone(), + remote_x25519_public: remote_x25519_key.clone(), + salt: *salt, + }) + } + + pub fn next_packet(&self, message: LpMessage) -> Result { + 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() + } + + /// Returns the stored PSK handle (ctxt_B) if available. + /// + /// The PSK handle is received from the responder during handshake and can be + /// used for future PSK re-registration without running KEM encapsulation again. + /// + /// # Returns + /// + /// * `Some(Vec)` - The encrypted PSK handle from the responder + /// * `None` - PSK handle not yet received or session is initiator before handshake completion + pub fn get_psk_handle(&self) -> Option> { + self.psk_handle.lock().clone() + } + + /// Prepares a KKT (KEM Key Transfer) request message. + /// + /// This should be called by the initiator before starting the Noise handshake + /// to obtain the responder's KEM public key. The KKT protocol authenticates + /// the exchange using Ed25519 signatures. + /// + /// **Protocol Flow:** + /// 1. Initiator creates KKT request with Ed25519 signature + /// 2. Responder validates signature and responds with KEM public key + signature + /// 3. Initiator validates response and stores KEM key for PSQ + /// + /// # Returns + /// + /// * `Some(Ok(LpMessage::KKTRequest))` - KKT request ready to send + /// * `Some(Err(LpError))` - Error creating KKT request + /// * `None` - KKT not applicable (responder, or already completed) + pub fn prepare_kkt_request(&self) -> Option> { + use nym_kkt::{ + ciphersuite::{Ciphersuite, HashFunction, KEM, SignatureScheme}, + kkt::request_kem_key, + }; + + let mut kkt_state = self.kkt_state.lock(); + + // Only initiator creates KKT requests, and only when not started + if !self.is_initiator || !matches!(*kkt_state, KKTState::NotStarted) { + return None; + } + + // Use X25519 as KEM for now (can extend to ML-KEM-768 later) + let ciphersuite = match Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) { + Ok(cs) => cs, + Err(e) => { + return Some(Err(LpError::Internal(format!( + "KKT ciphersuite error: {:?}", + e + )))); + } + }; + + let mut rng = rand09::rng(); + match request_kem_key(&mut rng, ciphersuite, &self.local_ed25519_private) { + Ok((context, request_frame)) => { + // Store context for response validation + *kkt_state = KKTState::InitiatorWaiting { context }; + + // Serialize KKT frame to bytes + let request_bytes = request_frame.to_bytes(); + Some(Ok(LpMessage::KKTRequest(crate::message::KKTRequestData( + request_bytes, + )))) + } + Err(e) => Some(Err(LpError::Internal(format!( + "KKT request creation failed: {:?}", + e + )))), + } + } + + /// Processes a KKT response from the responder. + /// + /// Validates the responder's signature and stores the authenticated KEM public key + /// for use in PSQ encapsulation. + /// + /// # Arguments + /// + /// * `response_bytes` - Raw KKT response message from responder + /// * `expected_key_hash` - Optional expected hash of responder's KEM key. + /// - `Some(hash)`: Full KKT validation (signature + hash) - use when directory service available + /// - `None`: Signature-only validation (hash computed from received key) - temporary mode + /// + /// # Returns + /// + /// * `Ok(())` - KKT exchange completed, KEM key stored + /// * `Err(LpError)` - Signature verification failed, hash mismatch, or invalid state + /// + /// # Note + /// + /// When None is passed, the function computes the hash from the received key and validates against + /// that (effectively signature-only mode). This allows easy upgrade: just pass Some(directory_hash) + /// when directory service becomes available. The full KKT protocol with hash pinning provides + /// protection against key substitution attacks. + pub fn process_kkt_response( + &self, + response_bytes: &[u8], + expected_key_hash: Option<&[u8]>, + ) -> Result<(), LpError> { + use nym_kkt::key_utils::hash_encapsulation_key; + use nym_kkt::kkt::validate_kem_response; + + let mut kkt_state = self.kkt_state.lock(); + + // Extract context from waiting state + let mut context = match &*kkt_state { + KKTState::InitiatorWaiting { context } => *context, + _ => { + return Err(LpError::Internal( + "KKT response received in invalid state".to_string(), + )); + } + }; + + // Determine hash to validate against + let hash_for_validation: Vec; + let hash_ref = match expected_key_hash { + Some(hash) => hash, + None => { + // Signature-only mode: extract key from response and compute its hash + // This effectively bypasses hash validation while keeping signature validation + use nym_kkt::frame::KKTFrame; + + let (frame, _) = KKTFrame::from_bytes(response_bytes).map_err(|e| { + LpError::Internal(format!("Failed to parse KKT response: {:?}", e)) + })?; + + hash_for_validation = hash_encapsulation_key( + &context.ciphersuite().hash_function(), + context.ciphersuite().hash_len(), + frame.body_ref(), + ); + &hash_for_validation + } + }; + + // Validate response and extract KEM key + let kem_pk = validate_kem_response( + &mut context, + &self.remote_ed25519_public, + hash_ref, + response_bytes, + ) + .map_err(|e| LpError::Internal(format!("KKT response validation failed: {:?}", e)))?; + + // Store the authenticated KEM key + *kkt_state = KKTState::Completed { + kem_pk: Box::new(kem_pk), + }; + + Ok(()) + } + + /// Processes a KKT request from the initiator and prepares a signed response. + /// + /// Validates the initiator's signature and creates a response containing this + /// responder's KEM public key, signed with Ed25519. + /// + /// # Arguments + /// + /// * `request_bytes` - Raw KKT request message from initiator + /// * `responder_kem_pk` - This responder's KEM public key to send + /// + /// # Returns + /// + /// * `Ok(LpMessage::KKTResponse)` - Signed KKT response ready to send + /// * `Err(LpError)` - Signature verification failed or invalid request + pub fn process_kkt_request( + &self, + request_bytes: &[u8], + responder_kem_pk: &EncapsulationKey, + ) -> Result { + use nym_kkt::{frame::KKTFrame, kkt::handle_kem_request}; + + let mut kkt_state = self.kkt_state.lock(); + + // Deserialize request frame + let (request_frame, _) = KKTFrame::from_bytes(request_bytes).map_err(|e| { + LpError::Internal(format!("KKT request deserialization failed: {:?}", e)) + })?; + + // Handle request and create signed response + let response_frame = handle_kem_request( + &request_frame, + Some(&self.remote_ed25519_public), // Verify initiator signature + &self.local_ed25519_private, // Sign response + responder_kem_pk, + ) + .map_err(|e| LpError::Internal(format!("KKT request handling failed: {:?}", e)))?; + + // Mark KKT as processed + // Responder doesn't store the kem_pk since they already have their own KEM keypair + *kkt_state = KKTState::ResponderProcessed; + + // Serialize response frame + let response_bytes = response_frame.to_bytes(); + + Ok(LpMessage::KKTResponse(crate::message::KKTResponseData( + response_bytes, + ))) + } + + /// 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. + /// + /// For initiators, PSQ always runs on the first message: + /// 1. Converts X25519 keys to DHKEM format + /// 2. Generates PSQ payload and derives PSK + /// 3. Injects PSK into Noise HandshakeState + /// 4. Embeds PSQ payload in first handshake message as: [u16 len][psq_payload][noise_msg] + /// + /// # Returns + /// + /// * `Ok(None)` if no message needs to be sent currently (e.g., waiting for peer, or handshake complete). + /// * `Err(LpError)` if there's an error within the Noise protocol or PSQ. + pub fn prepare_handshake_message(&self) -> Option> { + let mut noise_state = self.noise_state.lock(); + + // PSQ always runs for initiator on first message + let mut psq_state = self.psq_state.lock(); + + if self.is_initiator && matches!(*psq_state, PSQState::NotStarted) { + // Extract KEM public key from completed KKT exchange + // PSQ requires the authenticated KEM key obtained via KKT protocol + let kkt_state = self.kkt_state.lock(); + let remote_kem = match &*kkt_state { + KKTState::Completed { kem_pk } => kem_pk, + _ => { + return Some(Err(LpError::KKTError( + "PSQ handshake requires completed KKT exchange".to_string(), + ))); + } + }; + + // Generate PSQ payload and PSK using KKT-authenticated KEM key + let session_context = self.id.to_le_bytes(); + + let (psk, psq_payload) = match psq_initiator_create_message( + &self.local_x25519_private, + &self.remote_x25519_public, + remote_kem, + &self.local_ed25519_private, + &self.local_ed25519_public, + &self.salt, + &session_context, + ) { + Ok(result) => result, + Err(e) => { + tracing::error!("PSQ handshake preparation failed, aborting: {:?}", e); + return Some(Err(e)); + } + }; + + // Inject PSK into Noise HandshakeState + if let Err(e) = noise_state.set_psk(3, &psk) { + return Some(Err(LpError::NoiseError(e))); + } + // Mark PSK as injected for safety checks in transport mode + self.psk_injected.store(true, Ordering::Release); + + // Get the Noise handshake message + let noise_msg = match noise_state.get_bytes_to_send() { + Some(Ok(msg)) => msg, + Some(Err(e)) => return Some(Err(LpError::NoiseError(e))), + None => return None, // Should not happen if is_my_turn, but handle gracefully + }; + + // Combine: [u16 psq_len][psq_payload][noise_msg] + let psq_len = psq_payload.len() as u16; + let mut combined = Vec::with_capacity(2 + psq_payload.len() + noise_msg.len()); + combined.extend_from_slice(&psq_len.to_le_bytes()); + combined.extend_from_slice(&psq_payload); + combined.extend_from_slice(&noise_msg); + + // Update PSQ state to InitiatorWaiting + *psq_state = PSQState::InitiatorWaiting { + ciphertext: psq_payload, + }; + + return Some(Ok(LpMessage::Handshake(HandshakeData(combined)))); + } + + // Normal flow (no PSQ, or PSQ already completed) + drop(psq_state); // Release lock + + if let Some(message) = noise_state.get_bytes_to_send() { + match message { + Ok(noise_msg) => { + // Check if we have a PSK handle (ctxt_B) to embed (responder message 2 only) + // Only the responder should embed the handle, never the initiator + if !self.is_initiator { + let mut psk_handle_guard = self.psk_handle.lock(); + if let Some(handle_bytes) = psk_handle_guard.take() { + // Embed PSK handle in message: [u16 handle_len][handle_bytes][noise_msg] + let handle_len = handle_bytes.len() as u16; + let mut combined = + Vec::with_capacity(2 + handle_bytes.len() + noise_msg.len()); + combined.extend_from_slice(&handle_len.to_le_bytes()); + combined.extend_from_slice(&handle_bytes); + combined.extend_from_slice(&noise_msg); + + tracing::debug!( + "Embedding PSK handle ({} bytes) in handshake message 2", + handle_bytes.len() + ); + + return Some(Ok(LpMessage::Handshake(HandshakeData(combined)))); + } + } + // No PSK handle to embed, send noise message as-is + Some(Ok(LpMessage::Handshake(HandshakeData(noise_msg)))) + } + 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. + /// + /// For responders, PSQ always runs on the first message: + /// 1. Extracts PSQ payload from the first handshake message: [u16 len][psq_payload][noise_msg] + /// 2. Converts X25519 keys to DHKEM format + /// 3. Decapsulates PSK from PSQ payload + /// 4. Injects PSK into Noise HandshakeState + /// 5. Processes the remaining Noise handshake message + /// + /// # Arguments + /// + /// * `message` - The LP message received from the peer, expected to be a Handshake message. + /// + /// # Returns + /// + /// * `Ok(ReadResult)` detailing the outcome (e.g., handshake complete, no-op). + /// * `Err(LpError)` if the message is invalid or causes a Noise/PSQ protocol error. + pub fn process_handshake_message(&self, message: &LpMessage) -> Result { + let mut noise_state = self.noise_state.lock(); + let mut psq_state = self.psq_state.lock(); + + match message { + LpMessage::Handshake(HandshakeData(payload)) => { + // PSQ always runs for responder on first message + if !self.is_initiator && matches!(*psq_state, PSQState::ResponderWaiting) { + // Extract PSQ payload: [u16 psq_len][psq_payload][noise_msg] + if payload.len() < 2 { + return Err(LpError::NoiseError(NoiseError::Other( + "Payload too short for PSQ extraction".to_string(), + ))); + } + + let psq_len = u16::from_le_bytes([payload[0], payload[1]]) as usize; + + if payload.len() < 2 + psq_len { + return Err(LpError::NoiseError(NoiseError::Other( + "Payload length mismatch for PSQ extraction".to_string(), + ))); + } + + let psq_payload = &payload[2..2 + psq_len]; + let noise_payload = &payload[2 + psq_len..]; + + // Convert X25519 local keys to DecapsulationKey/EncapsulationKey (DHKEM) + let local_private_bytes = &self.local_x25519_private.to_bytes(); + let libcrux_private_key = libcrux_kem::PrivateKey::decode( + libcrux_kem::Algorithm::X25519, + local_private_bytes, + ) + .map_err(|e| { + LpError::KKTError(format!( + "Failed to convert X25519 private key to libcrux PrivateKey: {:?}", + e + )) + })?; + let dec_key = DecapsulationKey::X25519(libcrux_private_key); + + let local_public_key = self.local_x25519_private.public_key(); + let local_public_bytes = local_public_key.as_bytes(); + let libcrux_public_key = libcrux_kem::PublicKey::decode( + libcrux_kem::Algorithm::X25519, + local_public_bytes, + ) + .map_err(|e| { + LpError::KKTError(format!( + "Failed to convert X25519 public key to libcrux PublicKey: {:?}", + e + )) + })?; + let enc_key = EncapsulationKey::X25519(libcrux_public_key); + + // Decapsulate PSK from PSQ payload using X25519 as DHKEM + let session_context = self.id.to_le_bytes(); + + let (psk, responder_msg_bytes) = match psq_responder_process_message( + &self.local_x25519_private, + &self.remote_x25519_public, + (&dec_key, &enc_key), + &self.remote_ed25519_public, + psq_payload, + &self.salt, + &session_context, + ) { + Ok(result) => result, + Err(e) => { + tracing::error!("PSQ handshake processing failed, aborting: {:?}", e); + return Err(e); + } + }; + + // Store the PSK handle (ctxt_B) for transmission in next message + { + let mut psk_handle = self.psk_handle.lock(); + *psk_handle = Some(responder_msg_bytes); + } + + // Inject PSK into Noise HandshakeState + noise_state.set_psk(3, &psk)?; + // Mark PSK as injected for safety checks in transport mode + self.psk_injected.store(true, Ordering::Release); + + // Update PSQ state to Completed + *psq_state = PSQState::Completed { psk }; + + // Process the Noise handshake message (without PSQ prefix) + drop(psq_state); // Release lock before processing + return noise_state + .read_message(noise_payload) + .map_err(LpError::NoiseError); + } + + // Check if initiator should extract PSK handle from message 2 + if self.is_initiator && matches!(*psq_state, PSQState::InitiatorWaiting { .. }) { + // Extract PSK handle: [u16 handle_len][handle_bytes][noise_msg] + if payload.len() >= 2 { + let handle_len = u16::from_le_bytes([payload[0], payload[1]]) as usize; + + if handle_len > 0 && payload.len() >= 2 + handle_len { + // Extract and store the PSK handle + let handle_bytes = &payload[2..2 + handle_len]; + let noise_payload = &payload[2 + handle_len..]; + + tracing::debug!( + "Extracted PSK handle ({} bytes) from message 2", + handle_len + ); + + { + let mut psk_handle = self.psk_handle.lock(); + *psk_handle = Some(handle_bytes.to_vec()); + } + + // Release psq_state lock before processing + drop(psq_state); + + // Process only the Noise message part + return noise_state + .read_message(noise_payload) + .map_err(LpError::NoiseError); + } + } + // If no valid handle found, fall through to normal processing + } + + // The sans-io NoiseProtocol::read_message expects only the payload. + noise_state + .read_message(payload) + .map_err(LpError::NoiseError) + } + _ => Err(LpError::NoiseError(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)` 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 { + let mut noise_state = self.noise_state.lock(); + // Safety: Prevent transport mode with dummy PSK + if !self.psk_injected.load(Ordering::Acquire) { + return Err(NoiseError::PskNotInjected); + } + // 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(EncryptedDataPayload(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)` 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, NoiseError> { + let mut noise_state = self.noise_state.lock(); + // Safety: Prevent transport mode with dummy PSK + if !self.psk_injected.load(Ordering::Acquire) { + return Err(NoiseError::PskNotInjected); + } + // 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), + } + } + + /// Test-only method to set KKT state to Completed with a mock KEM key. + /// This allows tests to bypass KKT exchange and directly test PSQ handshake. + #[cfg(test)] + pub(crate) fn set_kkt_completed_for_test(&self, remote_x25519_pub: &PublicKey) { + // Convert remote X25519 public key to EncapsulationKey for testing + let remote_kem_bytes = remote_x25519_pub.as_bytes(); + let libcrux_public_key = + libcrux_kem::PublicKey::decode(libcrux_kem::Algorithm::X25519, remote_kem_bytes) + .expect("Test KEM key conversion failed"); + let kem_pk = EncapsulationKey::X25519(libcrux_public_key); + + let mut kkt_state = self.kkt_state.lock(); + *kkt_state = KKTState::Completed { + kem_pk: Box::new(kem_pk), + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{replay::ReplayError, sessions_for_tests}; + + // Helper function to generate keypairs for tests + fn generate_keypair() -> crate::keypair::Keypair { + crate::keypair::Keypair::default() + } + + // Helper function to create a session with real keys for handshake tests + fn create_handshake_test_session( + is_initiator: bool, + local_keys: &crate::keypair::Keypair, + remote_pub_key: &crate::keypair::PublicKey, + ) -> LpSession { + use nym_crypto::asymmetric::ed25519; + + // Compute the shared lp_id from both keypairs (order-independent) + let lp_id = crate::make_lp_id(local_keys.public_key(), remote_pub_key); + + // Create Ed25519 keypairs that correspond to initiator/responder roles + // Initiator uses [1u8], Responder uses [2u8] + let (local_ed25519_seed, remote_ed25519_seed) = if is_initiator { + ([1u8; 32], [2u8; 32]) + } else { + ([2u8; 32], [1u8; 32]) + }; + + let local_ed25519 = ed25519::KeyPair::from_secret(local_ed25519_seed, 0); + let remote_ed25519 = ed25519::KeyPair::from_secret(remote_ed25519_seed, 1); + + let salt = [0u8; 32]; // Test salt + + // PSQ will derive the PSK during handshake using X25519 as DHKEM + let session = LpSession::new( + lp_id, + is_initiator, + (local_ed25519.private_key(), local_ed25519.public_key()), + local_keys.private_key(), + remote_ed25519.public_key(), + remote_pub_key, + &salt, + ) + .expect("Test session creation failed"); + + // Initialize KKT state to Completed for tests (bypasses KKT exchange) + // This simulates having already received the remote party's KEM key via KKT + session.set_kkt_completed_for_test(remote_pub_key); + + session + } + + #[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); + } + + // NOTE: These tests are obsolete after removing optional KEM parameters. + // PSQ now always runs using X25519 keys internally converted to KEM format. + // The new tests at the end of this file (test_psq_*) cover PSQ integration. + /* + #[test] + fn test_session_creation_with_psq_state_initiator() { + // OLD API - REMOVED + } + + #[test] + fn test_session_creation_with_psq_state_responder() { + // OLD API - REMOVED + } + */ + + #[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 initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = create_handshake_test_session( + false, + &responder_keys, + initiator_keys.public_key(), // Responder also needs initiator's key for XK + ); + + // 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 initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // 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 initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + 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 initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // 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 initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + + 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::PskNotInjected => {} // Expected - PSK check comes before handshake check + e => panic!("Expected PskNotInjected, 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( + EncryptedDataPayload(dummy_ciphertext), + )); + assert!(result_decrypt.is_err()); + match result_decrypt.unwrap_err() { + NoiseError::PskNotInjected => {} // Expected - PSK check comes before handshake check + e => panic!("Expected PskNotInjected, 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"), + // } + } + */ + + // ==================================================================== + // PSQ Handshake Integration Tests + // ==================================================================== + + /// Test that PSQ runs during handshake and derives a PSK + #[test] + fn test_psq_handshake_runs_with_psk_injection() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Drive the handshake + let mut i_msg = initiator_session + .prepare_handshake_message() + .expect("Initiator should have message") + .expect("Message prep should succeed"); + + // The first message should contain PSQ payload embedded + // Verify message is not empty and has reasonable size + assert!(!i_msg.is_empty(), "Initiator message should not be empty"); + assert!( + i_msg.len() > 100, + "Message should contain PSQ payload (actual: {})", + i_msg.len() + ); + + // Responder processes message (which includes PSQ decapsulation) + responder_session + .process_handshake_message(&i_msg) + .expect("Responder should process first message"); + + // Continue handshake + let r_msg = responder_session + .prepare_handshake_message() + .expect("Responder should have message") + .expect("Responder message prep should succeed"); + + initiator_session + .process_handshake_message(&r_msg) + .expect("Initiator should process responder message"); + + i_msg = initiator_session + .prepare_handshake_message() + .expect("Initiator should have final message") + .expect("Final message prep should succeed"); + + responder_session + .process_handshake_message(&i_msg) + .expect("Responder should process final message"); + + // Verify handshake completed + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Verify encryption works (implicitly tests PSK was correctly injected) + let plaintext = b"PSQ test message"; + let encrypted = initiator_session + .encrypt_data(plaintext) + .expect("Encryption should work after handshake"); + + let decrypted = responder_session + .decrypt_data(&encrypted) + .expect("Decryption should work with PSQ-derived PSK"); + + assert_eq!(decrypted, plaintext); + } + + /// Test that X25519 keys are correctly converted to KEM format + #[test] + fn test_x25519_to_kem_conversion() { + use nym_kkt::ciphersuite::EncapsulationKey; + + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Verify we can convert X25519 public key to KEM format (as done in session.rs) + let x25519_public_bytes = responder_keys.public_key().as_bytes(); + let libcrux_public_key = + libcrux_kem::PublicKey::decode(libcrux_kem::Algorithm::X25519, x25519_public_bytes) + .expect("X25519 public key should convert to libcrux PublicKey"); + + let _kem_key = EncapsulationKey::X25519(libcrux_public_key); + + // Verify we can convert X25519 private key to KEM format + let x25519_private_bytes = initiator_keys.private_key().to_bytes(); + let _libcrux_private_key = + libcrux_kem::PrivateKey::decode(libcrux_kem::Algorithm::X25519, &x25519_private_bytes) + .expect("X25519 private key should convert to libcrux PrivateKey"); + + // Successful conversion is sufficient - actual encapsulation is tested in psk.rs + // (libcrux_kem::PrivateKey is an enum with no len() method, conversion success is enough) + } + + /// Test that PSQ actually derives a different PSK (not using dummy) + #[test] + fn test_psq_derived_psk_differs_from_dummy() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create sessions - they start with dummy PSK [0u8; 32] + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Prepare first message (initiator runs PSQ and injects PSK) + let i_msg = initiator_session + .prepare_handshake_message() + .expect("Initiator should have message") + .expect("Message prep should succeed"); + + // Verify message is not empty (PSQ runs successfully) + assert!( + !i_msg.is_empty(), + "First message should contain PSQ payload" + ); + + // Complete handshake + responder_session + .process_handshake_message(&i_msg) + .expect("Responder should process message"); + + let r_msg = responder_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + initiator_session.process_handshake_message(&r_msg).unwrap(); + + let final_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + responder_session + .process_handshake_message(&final_msg) + .unwrap(); + + // Test that encryption produces non-trivial ciphertext + // (would fail if using dummy PSK incorrectly) + let plaintext = b"test"; + let encrypted = initiator_session.encrypt_data(plaintext).unwrap(); + + // Decrypt should work + let decrypted = responder_session.decrypt_data(&encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + + // Verify ciphertext is not just plaintext (basic encryption sanity) + if let LpMessage::EncryptedData(payload) = encrypted { + assert_ne!( + &payload.0[..plaintext.len()], + plaintext, + "Ciphertext should differ from plaintext" + ); + } else { + panic!("Expected EncryptedData message"); + } + } + + /// Test full end-to-end handshake with PSQ integration + #[test] + fn test_handshake_with_psq_end_to_end() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Verify initial state + assert!(!initiator_session.is_handshake_complete()); + assert!(!responder_session.is_handshake_complete()); + assert!(initiator_session.is_initiator()); + assert!(!responder_session.is_initiator()); + + // Round 1: Initiator -> Responder (contains PSQ encapsulation) + let msg1 = initiator_session + .prepare_handshake_message() + .expect("Initiator should prepare message") + .expect("Message should succeed"); + + assert!(!msg1.is_empty()); + assert!(!initiator_session.is_handshake_complete()); + + responder_session + .process_handshake_message(&msg1) + .expect("Responder should process PSQ message"); + + assert!(!responder_session.is_handshake_complete()); + + // Round 2: Responder -> Initiator + let msg2 = responder_session + .prepare_handshake_message() + .expect("Responder should prepare message") + .expect("Message should succeed"); + + initiator_session + .process_handshake_message(&msg2) + .expect("Initiator should process message"); + + // Round 3: Initiator -> Responder (final) + let msg3 = initiator_session + .prepare_handshake_message() + .expect("Initiator should prepare final message") + .expect("Message should succeed"); + + responder_session + .process_handshake_message(&msg3) + .expect("Responder should process final message"); + + // Verify both sides completed + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Test bidirectional encrypted communication + let msg_i_to_r = b"Hello from initiator"; + let encrypted_i = initiator_session + .encrypt_data(msg_i_to_r) + .expect("Initiator encryption"); + let decrypted_i = responder_session + .decrypt_data(&encrypted_i) + .expect("Responder decryption"); + assert_eq!(decrypted_i, msg_i_to_r); + + let msg_r_to_i = b"Hello from responder"; + let encrypted_r = responder_session + .encrypt_data(msg_r_to_i) + .expect("Responder encryption"); + let decrypted_r = initiator_session + .decrypt_data(&encrypted_r) + .expect("Initiator decryption"); + assert_eq!(decrypted_r, msg_r_to_i); + + // Successfully completed end-to-end test with PSQ + } + + /// Test that Ed25519 keys are used in PSQ authentication + #[test] + fn test_psq_handshake_uses_ed25519_authentication() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create sessions with explicit Ed25519 keys + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Verify sessions store Ed25519 keys + // (Internal verification - keys are used in PSQ calls) + assert_eq!(initiator_session.id(), responder_session.id()); + + // Complete handshake + let msg1 = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&msg1).unwrap(); + + let msg2 = responder_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + initiator_session.process_handshake_message(&msg2).unwrap(); + + let msg3 = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&msg3).unwrap(); + + // If Ed25519 authentication failed, handshake would not complete + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Verify encrypted communication works (proof of successful PSQ with auth) + let test_data = b"Authentication test"; + let encrypted = initiator_session.encrypt_data(test_data).unwrap(); + let decrypted = responder_session.decrypt_data(&encrypted).unwrap(); + assert_eq!(decrypted, test_data); + } + + #[test] + fn test_psq_deserialization_failure() { + // Test that corrupted PSQ payload causes clean abort + let responder_keys = generate_keypair(); + let initiator_keys = generate_keypair(); + + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Create a handshake message with corrupted PSQ payload + let corrupted_psq_data = vec![0xFF; 128]; // Random garbage + let bad_message = LpMessage::Handshake(HandshakeData(corrupted_psq_data)); + + // Attempt to process corrupted message - should fail + let result = responder_session.process_handshake_message(&bad_message); + + // Should return error (PSQ deserialization will fail) + assert!(result.is_err(), "Expected error for corrupted PSQ payload"); + + // Verify session state is unchanged + // PSQ state should still be ResponderWaiting (not modified) + // Noise PSK should still be dummy [0u8; 32] + assert!(!responder_session.is_handshake_complete()); + } + + #[test] + fn test_handshake_abort_on_psq_failure() { + // Test that Ed25519 auth failure causes handshake abort + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create sessions with MISMATCHED Ed25519 keys + // This simulates authentication failure + let initiator_ed25519 = ed25519::KeyPair::from_secret([1u8; 32], 0); + let wrong_ed25519 = ed25519::KeyPair::from_secret([99u8; 32], 99); // Different key! + + let lp_id = crate::make_lp_id(initiator_keys.public_key(), responder_keys.public_key()); + let salt = [0u8; 32]; + + let initiator_session = LpSession::new( + lp_id, + true, + ( + initiator_ed25519.private_key(), + initiator_ed25519.public_key(), + ), + initiator_keys.private_key(), + wrong_ed25519.public_key(), // Responder expects THIS key + responder_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + initiator_session.set_kkt_completed_for_test(responder_keys.public_key()); + + let responder_ed25519 = ed25519::KeyPair::from_secret([2u8; 32], 1); + + let responder_session = LpSession::new( + lp_id, + false, + ( + responder_ed25519.private_key(), + responder_ed25519.public_key(), + ), + responder_keys.private_key(), + wrong_ed25519.public_key(), // Expects WRONG key (not initiator's) + initiator_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + responder_session.set_kkt_completed_for_test(initiator_keys.public_key()); + + // Initiator prepares message (should succeed - signing works) + let msg1 = initiator_session + .prepare_handshake_message() + .expect("Initiator should prepare message") + .expect("Initiator should have message"); + + // Responder processes message - should FAIL (signature verification fails) + let result = responder_session.process_handshake_message(&msg1); + + // Should return CredError due to Ed25519 signature mismatch + assert!( + result.is_err(), + "Expected error for Ed25519 authentication failure" + ); + + // Verify handshake aborted cleanly + assert!(!initiator_session.is_handshake_complete()); + assert!(!responder_session.is_handshake_complete()); + } + + #[test] + fn test_psq_invalid_signature() { + // Test Ed25519 signature validation specifically + // Setup with matching X25519 keys but mismatched Ed25519 keys + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Initiator uses Ed25519 key [1u8] + let initiator_ed25519 = ed25519::KeyPair::from_secret([1u8; 32], 0); + + // Responder expects Ed25519 key [99u8] (wrong!) + let wrong_ed25519_keypair = ed25519::KeyPair::from_secret([99u8; 32], 99); + let wrong_ed25519_public = wrong_ed25519_keypair.public_key(); + + let lp_id = crate::make_lp_id(initiator_keys.public_key(), responder_keys.public_key()); + let salt = [0u8; 32]; + + let initiator_session = LpSession::new( + lp_id, + true, + ( + initiator_ed25519.private_key(), + initiator_ed25519.public_key(), + ), + initiator_keys.private_key(), + wrong_ed25519_public, // This doesn't matter for initiator + responder_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + initiator_session.set_kkt_completed_for_test(responder_keys.public_key()); + + let responder_ed25519 = ed25519::KeyPair::from_secret([2u8; 32], 1); + + let responder_session = LpSession::new( + lp_id, + false, + ( + responder_ed25519.private_key(), + responder_ed25519.public_key(), + ), + responder_keys.private_key(), + wrong_ed25519_public, // Responder expects WRONG key + initiator_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + responder_session.set_kkt_completed_for_test(initiator_keys.public_key()); + + // Initiator creates message with valid signature (signed with [1u8]) + let msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + // Responder tries to verify with wrong public key [99u8] + // This should fail Ed25519 signature verification + let result = responder_session.process_handshake_message(&msg); + + assert!(result.is_err(), "Expected signature verification to fail"); + + // Verify error is related to PSQ/authentication + match result.unwrap_err() { + LpError::Internal(msg) if msg.contains("PSQ") => { + // Expected - PSQ v1 responder send failed due to CredError + } + e => panic!("Unexpected error type: {:?}", e), + } + } + + #[test] + fn test_psq_state_unchanged_on_error() { + // Verify that PSQ errors leave session in clean state + let responder_keys = generate_keypair(); + let initiator_keys = generate_keypair(); + + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Capture initial PSQ state (should be ResponderWaiting) + // (We can't directly access psq_state, but we can verify behavior) + + // Send corrupted data + let corrupted_message = LpMessage::Handshake(HandshakeData(vec![0xFF; 100])); + + // Process should fail + let result = responder_session.process_handshake_message(&corrupted_message); + assert!(result.is_err()); + + // After error, session should still be in handshake mode (not complete) + assert!(!responder_session.is_handshake_complete()); + + // Session should still be functional - can process valid messages + // Create a proper initiator to send valid message + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + + let valid_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + // After the error, responder should still be able to process valid messages + let result2 = responder_session.process_handshake_message(&valid_msg); + + // Should succeed (session state was not corrupted by previous error) + assert!( + result2.is_ok(), + "Session should still be functional after PSQ error" + ); + } + + #[test] + fn test_transport_fails_without_psk_injection() { + // This test verifies the safety mechanism that prevents transport mode operations + // from running with the dummy PSK if PSQ injection fails or is skipped. + + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create session but don't complete handshake (no PSK injection will occur) + let session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + + // Verify session was created successfully + assert!(!session.is_handshake_complete()); + + // Attempt to encrypt data - should fail with PskNotInjected + let plaintext = b"test data"; + let encrypt_result = session.encrypt_data(plaintext); + + assert!( + encrypt_result.is_err(), + "encrypt_data should fail without PSK injection" + ); + match encrypt_result.unwrap_err() { + NoiseError::PskNotInjected => { + // Expected - this is the safety mechanism working + } + e => panic!("Expected PskNotInjected error, got: {:?}", e), + } + + // Create a dummy encrypted message to test decrypt + let dummy_ciphertext = LpMessage::EncryptedData(EncryptedDataPayload(vec![0u8; 48])); + + // Attempt to decrypt data - should also fail with PskNotInjected + let decrypt_result = session.decrypt_data(&dummy_ciphertext); + + assert!( + decrypt_result.is_err(), + "decrypt_data should fail without PSK injection" + ); + match decrypt_result.unwrap_err() { + NoiseError::PskNotInjected => { + // Expected - this is the safety mechanism working + } + e => panic!("Expected PskNotInjected error, got: {:?}", e), + } + } +} diff --git a/common/nym-lp/src/session_integration/mod.rs b/common/nym-lp/src/session_integration/mod.rs new file mode 100644 index 00000000000..4e52a6cbd37 --- /dev/null +++ b/common/nym-lp/src/session_integration/mod.rs @@ -0,0 +1,1375 @@ +#[cfg(test)] +mod tests { + use crate::codec::{parse_lp_packet, serialize_lp_packet}; + use crate::keypair::PublicKey; + use crate::make_lp_id; + use crate::{ + LpError, + message::LpMessage, + packet::{LpHeader, LpPacket, TRAILER_LEN}, + session_manager::SessionManager, + }; + use bytes::BytesMut; + use nym_crypto::asymmetric::ed25519; + + // Function to create a test packet - similar to how it's done in codec.rs tests + fn create_test_packet( + protocol_version: u8, + session_id: u32, + counter: u64, + message: LpMessage, + ) -> LpPacket { + // Create the header + let header = LpHeader { + protocol_version, + reserved: 0u16, // reserved + session_id, + counter, + }; + + // Create the trailer (zeros for now, in a real implementation this might be a MAC) + let trailer = [0u8; TRAILER_LEN]; + + // Create and return the packet directly + LpPacket { + header, + message, + trailer, + } + } + + /// Tests the complete session flow including: + /// - Creation of sessions through session manager + /// - Packet encoding/decoding with the session + /// - Replay protection across the session + /// - Multiple sessions with unique indices + /// - Session removal and cleanup + #[test] + fn test_full_session_flow() { + // 1. Initialize session manager + let session_manager_1 = SessionManager::new(); + let session_manager_2 = SessionManager::new(); + + // 2. Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_a = ed25519::KeyPair::from_secret([1u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([2u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_a = ed25519_keypair_a + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_b = ed25519_keypair_b + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + + // Test salt + let salt = [42u8; 32]; + + // 4. Create sessions using the pre-built Noise states + let peer_a_sm = session_manager_1 + .create_session_state_machine( + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key(), + ), + ed25519_keypair_b.public_key(), + true, + &salt, + ) + .expect("Failed to create session A"); + + let peer_b_sm = session_manager_2 + .create_session_state_machine( + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key(), + ), + ed25519_keypair_a.public_key(), + false, + &salt, + ) + .expect("Failed to create session B"); + + // Verify session count + assert_eq!(session_manager_1.session_count(), 1); + assert_eq!(session_manager_2.session_count(), 1); + + // Initialize KKT state for both sessions (test bypass) + session_manager_1 + .init_kkt_for_test(peer_a_sm, &lp_pub_b) + .expect("Failed to init KKT for peer A"); + session_manager_2 + .init_kkt_for_test(peer_b_sm, &lp_pub_a) + .expect("Failed to init KKT for peer B"); + + // 5. Simulate Noise Handshake (Sans-IO) + println!("Starting handshake simulation..."); + let mut i_msg_payload; + let mut r_msg_payload = None; + let mut rounds = 0; + const MAX_ROUNDS: usize = 10; + + // Prime initiator's first message + i_msg_payload = session_manager_1 + .prepare_handshake_message(peer_a_sm) + .transpose() + .unwrap(); + + assert!( + i_msg_payload.is_some(), + "Initiator did not produce initial message" + ); + + while rounds < MAX_ROUNDS { + rounds += 1; + let mut did_exchange = false; + + // === Initiator -> Responder === + if let Some(payload) = i_msg_payload.take() { + did_exchange = true; + println!( + " Round {}: Initiator -> Responder ({} bytes)", + rounds, + payload.len() + ); + + // A prepares packet + let counter = session_manager_1.next_counter(lp_id).unwrap(); + let message_a_to_b = create_test_packet(1, lp_id, counter, payload); + let mut encoded_msg = BytesMut::new(); + serialize_lp_packet(&message_a_to_b, &mut encoded_msg).expect("A serialize failed"); + + // B parses packet and checks replay + let decoded_packet = parse_lp_packet(&encoded_msg).expect("B parse failed"); + assert_eq!(decoded_packet.header.counter, counter); + + // Check replay before processing handshake + session_manager_2 + .receiving_counter_quick_check(peer_b_sm, decoded_packet.header.counter) + .expect("B replay check failed (A->B)"); + + match session_manager_2 + .process_handshake_message(peer_b_sm, &decoded_packet.message) + { + Ok(_) => { + // Mark counter only after successful processing + session_manager_2 + .receiving_counter_mark(peer_b_sm, decoded_packet.header.counter) + .expect("B mark counter failed"); + } + Err(e) => panic!("Responder processing failed: {:?}", e), + } + // Check if responder needs to send a reply + r_msg_payload = session_manager_2 + .prepare_handshake_message(peer_b_sm) + .transpose() + .unwrap(); + println!("{:?}", r_msg_payload); + } + + // Check completion + if session_manager_1.is_handshake_complete(peer_a_sm).unwrap() + && session_manager_2.is_handshake_complete(peer_b_sm).unwrap() + { + println!("Handshake completed after Initiator->Responder message."); + break; + } + + // === Responder -> Initiator === + if let Some(payload) = r_msg_payload.take() { + did_exchange = true; + println!( + " Round {}: Responder -> Initiator ({} bytes)", + rounds, + payload.len() + ); + + // B prepares packet + let counter = session_manager_2.next_counter(peer_b_sm).unwrap(); + let message_b_to_a = create_test_packet(1, lp_id, counter, payload); + let mut encoded_msg = BytesMut::new(); + serialize_lp_packet(&message_b_to_a, &mut encoded_msg).expect("B serialize failed"); + + // A parses packet and checks replay + let decoded_packet = parse_lp_packet(&encoded_msg).expect("A parse failed"); + assert_eq!(decoded_packet.header.counter, counter); + + // Check replay before processing handshake + session_manager_1 + .receiving_counter_quick_check(peer_a_sm, decoded_packet.header.counter) + .expect("A replay check failed (B->A)"); + + match session_manager_1 + .process_handshake_message(peer_a_sm, &decoded_packet.message) + { + Ok(_) => { + // Mark counter only after successful processing + session_manager_1 + .receiving_counter_mark(peer_a_sm, decoded_packet.header.counter) + .expect("A mark counter failed"); + } + Err(e) => panic!("Initiator processing failed: {:?}", e), + } + + // Check if initiator needs to send a reply + i_msg_payload = session_manager_1 + .prepare_handshake_message(peer_a_sm) + .transpose() + .unwrap(); + } + + // println!("Initiator state: {}", session_manager_1.get_state(peer_a_sm).unwrap()); + // println!("Responder state: {}", session_manager_2.get_state(peer_b_sm).unwrap()); + + println!( + "Initiator state: {}", + session_manager_1.is_handshake_complete(peer_a_sm).unwrap() + ); + println!( + "Responder state: {}", + session_manager_2.is_handshake_complete(peer_b_sm).unwrap() + ); + + // Check completion again + if session_manager_1.is_handshake_complete(peer_a_sm).unwrap() + && session_manager_2.is_handshake_complete(peer_b_sm).unwrap() + { + println!("Handshake completed after Responder->Initiator message."); + + // Safety break if no messages were exchanged in a round + if !did_exchange { + println!("No messages exchanged in round {}, breaking.", rounds); + break; + } + } + + assert!(rounds < MAX_ROUNDS, "Handshake loop exceeded max rounds"); + } + assert!( + session_manager_1.is_handshake_complete(peer_a_sm).unwrap(), + "Initiator handshake did not complete" + ); + assert!( + session_manager_2.is_handshake_complete(peer_b_sm).unwrap(), + "Responder handshake did not complete" + ); + println!( + "Handshake simulation completed successfully in {} rounds.", + rounds + ); + + // --- Handshake Complete --- + + // 7. Simulate Data Transfer (Post-Handshake) + println!("Starting data transfer simulation..."); + let plaintext_a_to_b = b"Hello from A!"; + + // A encrypts data + let ciphertext_a_to_b = session_manager_1 + .encrypt_data(peer_a_sm, plaintext_a_to_b) + .expect("A encrypt failed"); + + // A prepares packet + let counter_a = session_manager_1.next_counter(peer_a_sm).unwrap(); + let message_a_to_b = create_test_packet(1, lp_id, counter_a, ciphertext_a_to_b); + let mut encoded_data_a_to_b = BytesMut::new(); + serialize_lp_packet(&message_a_to_b, &mut encoded_data_a_to_b) + .expect("A serialize data failed"); + + // B parses packet and checks replay + let decoded_packet_b = parse_lp_packet(&encoded_data_a_to_b).expect("B parse data failed"); + assert_eq!(decoded_packet_b.header.counter, counter_a); + + // Check replay before decrypting + session_manager_2 + .receiving_counter_quick_check(peer_b_sm, decoded_packet_b.header.counter) + .expect("B data replay check failed (A->B)"); + + // B decrypts data + let decrypted_payload = session_manager_2 + .decrypt_data(peer_b_sm, &decoded_packet_b.message) + .expect("B decrypt failed"); + assert_eq!(decrypted_payload, plaintext_a_to_b); + // Mark counter only after successful decryption + session_manager_2 + .receiving_counter_mark(peer_b_sm, decoded_packet_b.header.counter) + .expect("B mark data counter failed"); + println!( + " A->B: Decrypted successfully: {:?}", + String::from_utf8_lossy(&decrypted_payload) + ); + + // B sends data to A + let plaintext_b_to_a = b"Hello from B!"; + let ciphertext_b_to_a = session_manager_2 + .encrypt_data(peer_b_sm, plaintext_b_to_a) + .expect("B encrypt failed"); + let counter_b = session_manager_2.next_counter(peer_b_sm).unwrap(); + let message_b_to_a = create_test_packet(1, lp_id, counter_b, ciphertext_b_to_a); + let mut encoded_data_b_to_a = BytesMut::new(); + serialize_lp_packet(&message_b_to_a, &mut encoded_data_b_to_a) + .expect("B serialize data failed"); + + // A parses packet and checks replay + let decoded_packet_a = parse_lp_packet(&encoded_data_b_to_a).expect("A parse data failed"); + assert_eq!(decoded_packet_a.header.counter, counter_b); + + // Check replay before decrypting + session_manager_1 + .receiving_counter_quick_check(peer_a_sm, decoded_packet_a.header.counter) + .expect("A data replay check failed (B->A)"); + + // A decrypts data + let decrypted_payload = session_manager_1 + .decrypt_data(peer_a_sm, &decoded_packet_a.message) + .expect("A decrypt failed"); + assert_eq!(decrypted_payload, plaintext_b_to_a); + // Mark counter only after successful decryption + session_manager_1 + .receiving_counter_mark(peer_a_sm, decoded_packet_a.header.counter) + .expect("A mark data counter failed"); + println!( + " B->A: Decrypted successfully: {:?}", + String::from_utf8_lossy(&decrypted_payload) + ); + + println!("Data transfer simulation completed."); + + // 8. Replay Protection Test (Data Packet) + println!("Testing data packet replay protection..."); + // Try to replay the last message from B to A + // Need to re-encode because decode consumes the buffer + let message_b_to_a_replay = create_test_packet( + 1, + lp_id, + counter_b, + LpMessage::EncryptedData(crate::message::EncryptedDataPayload( + plaintext_b_to_a.to_vec(), + )), // Using plaintext here, but content doesn't matter for replay check + ); + let mut encoded_data_b_to_a_replay = BytesMut::new(); + serialize_lp_packet(&message_b_to_a_replay, &mut encoded_data_b_to_a_replay) + .expect("B serialize replay failed"); + + let parsed_replay_packet = + parse_lp_packet(&encoded_data_b_to_a_replay).expect("A parse replay failed"); + let replay_result = session_manager_1 + .receiving_counter_quick_check(peer_a_sm, parsed_replay_packet.header.counter); + assert!(replay_result.is_err(), "Data replay should be prevented"); + assert!( + matches!(replay_result.unwrap_err(), LpError::Replay(_)), + "Should be a replay protection error for data packet" + ); + println!("Data packet replay protection test passed."); + + // 9. Test out-of-order packet reception (send counter N+1 before counter N) + println!("Testing out-of-order data packet reception..."); + let counter_a_next = session_manager_1.next_counter(peer_a_sm).unwrap(); // Should be counter_a + 1 + let counter_a_skip = session_manager_1.next_counter(peer_a_sm).unwrap(); // Should be counter_a + 2 + + // Prepare data for counter_a_skip (N+1) + let plaintext_skip = b"Out of order message"; + let ciphertext_skip = session_manager_1 + .encrypt_data(peer_a_sm, plaintext_skip) + .expect("A encrypt skip failed"); + + let message_a_to_b_skip = create_test_packet( + 1, // protocol version + lp_id, + counter_a_skip, // Send N+1 first + ciphertext_skip, + ); + + // Encode the skip message + let mut encoded_skip = BytesMut::new(); + serialize_lp_packet(&message_a_to_b_skip, &mut encoded_skip) + .expect("Failed to serialize skip message"); + + // B parses skip message and checks replay + let decoded_packet_skip = parse_lp_packet(&encoded_skip).expect("B parse skip failed"); + session_manager_2 + .receiving_counter_quick_check(peer_b_sm, decoded_packet_skip.header.counter) + .expect("B replay check skip failed"); + assert_eq!(decoded_packet_skip.header.counter, counter_a_skip); + + // B decrypts skip message + let decrypted_payload = session_manager_2 + .decrypt_data(peer_b_sm, &decoded_packet_skip.message) + .expect("B decrypt skip failed"); + assert_eq!(decrypted_payload, plaintext_skip); + // Mark counter N+1 + session_manager_2 + .receiving_counter_mark(peer_b_sm, decoded_packet_skip.header.counter) + .expect("B mark skip counter failed"); + println!( + " A->B (Counter {}): Decrypted successfully: {:?}", + counter_a_skip, + String::from_utf8_lossy(&decrypted_payload) + ); + + // 10. Now send the skipped counter N message (should still work) + println!("Testing delayed data packet reception..."); + // Prepare data for counter_a_next (N) + let plaintext_delayed = b"Delayed message"; + let ciphertext_delayed = session_manager_1 + .encrypt_data(peer_a_sm, plaintext_delayed) + .expect("A encrypt delayed failed"); + + let message_a_to_b_delayed = create_test_packet( + 1, // protocol version + lp_id, + counter_a_next, // counter N (delayed packet) + ciphertext_delayed, + ); + + // Encode the delayed message + let mut encoded_delayed = BytesMut::new(); + serialize_lp_packet(&message_a_to_b_delayed, &mut encoded_delayed) + .expect("Failed to serialize delayed message"); + + // Make a copy for replay test later + let encoded_delayed_copy = encoded_delayed.clone(); + + // B parses delayed message and checks replay + let decoded_packet_delayed = + parse_lp_packet(&encoded_delayed).expect("B parse delayed failed"); + session_manager_2 + .receiving_counter_quick_check(peer_b_sm, decoded_packet_delayed.header.counter) + .expect("B replay check delayed failed"); + assert_eq!(decoded_packet_delayed.header.counter, counter_a_next); + + // B decrypts delayed message + let decrypted_payload = session_manager_2 + .decrypt_data(peer_b_sm, &decoded_packet_delayed.message) + .expect("B decrypt delayed failed"); + assert_eq!(decrypted_payload, plaintext_delayed); + // Mark counter N + session_manager_2 + .receiving_counter_mark(peer_b_sm, decoded_packet_delayed.header.counter) + .expect("B mark delayed counter failed"); + println!( + " A->B (Counter {}): Decrypted successfully: {:?}", + counter_a_next, + String::from_utf8_lossy(&decrypted_payload) + ); + + println!("Delayed data packet reception test passed."); + + // 11. Try to replay message with counter N (should fail) + println!("Testing replay of delayed packet..."); + let parsed_delayed_replay = + parse_lp_packet(&encoded_delayed_copy).expect("Parse delayed replay failed"); + let result = session_manager_2 + .receiving_counter_quick_check(peer_b_sm, parsed_delayed_replay.header.counter); + assert!(result.is_err(), "Replay attack should be prevented"); + assert!( + matches!(result, Err(LpError::Replay(_))), + "Should be a replay protection error" + ); + + // 12. Session removal + assert!(session_manager_1.remove_state_machine(lp_id)); + assert_eq!(session_manager_1.session_count(), 0); + + // Verify the session is gone + let session = session_manager_1.state_machine_exists(lp_id); + assert!(!session, "Session should be removed"); + + // But the other session still exists + let session = session_manager_2.state_machine_exists(lp_id); + assert!(session, "Session still exists in the other manager"); + } + + /// Tests simultaneous bidirectional communication between sessions + #[test] + fn test_bidirectional_communication() { + // 1. Initialize session manager + let session_manager_1 = SessionManager::new(); + let session_manager_2 = SessionManager::new(); + + // 2. Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_a = ed25519::KeyPair::from_secret([3u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([4u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_a = ed25519_keypair_a + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_b = ed25519_keypair_b + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + + // Test salt + let salt = [43u8; 32]; + + let peer_a_sm = session_manager_1 + .create_session_state_machine( + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key(), + ), + ed25519_keypair_b.public_key(), + true, + &salt, + ) + .unwrap(); + let peer_b_sm = session_manager_2 + .create_session_state_machine( + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key(), + ), + ed25519_keypair_a.public_key(), + false, + &salt, + ) + .unwrap(); + + // Initialize KKT state for both sessions (test bypass) + session_manager_1 + .init_kkt_for_test(peer_a_sm, &lp_pub_b) + .expect("Failed to init KKT for peer A"); + session_manager_2 + .init_kkt_for_test(peer_b_sm, &lp_pub_a) + .expect("Failed to init KKT for peer B"); + + // Drive handshake to completion (simplified) + let mut i_msg = session_manager_1 + .prepare_handshake_message(peer_a_sm) + .transpose() + .unwrap() + .unwrap(); + + session_manager_2 + .process_handshake_message(peer_b_sm, &i_msg) + .unwrap(); + session_manager_2 + .receiving_counter_mark(peer_b_sm, 0) + .unwrap(); // Assume counter 0 for first msg + let r_msg = session_manager_2 + .prepare_handshake_message(peer_b_sm) + .transpose() + .unwrap() + .unwrap(); + session_manager_1 + .process_handshake_message(peer_a_sm, &r_msg) + .unwrap(); + session_manager_1 + .receiving_counter_mark(peer_a_sm, 0) + .unwrap(); // Assume counter 0 for first msg + i_msg = session_manager_1 + .prepare_handshake_message(peer_a_sm) + .transpose() + .unwrap() + .unwrap(); + + session_manager_2 + .process_handshake_message(peer_b_sm, &i_msg) + .unwrap(); + session_manager_2 + .receiving_counter_mark(peer_b_sm, 1) + .unwrap(); // Assume counter 1 for second msg from A + + assert!(session_manager_1.is_handshake_complete(peer_a_sm).unwrap()); + assert!(session_manager_2.is_handshake_complete(peer_b_sm).unwrap()); + println!("Bidirectional test: Handshake complete."); + + // Counters after handshake (A sent 2, B sent 1) + let mut counter_a = 2; // Next counter for A to send + let mut counter_b = 1; // Next counter for B to send + + // 4. Send multiple encrypted messages both ways + const NUM_MESSAGES: u64 = 5; + for i in 0..NUM_MESSAGES { + println!("Bidirectional test: Round {}", i); + // --- A sends to B --- + let plaintext_a = format!("A->B Message {}", i).into_bytes(); + let ciphertext_a = session_manager_1 + .encrypt_data(peer_a_sm, &plaintext_a) + .expect("A encrypt failed"); + let current_counter_a = counter_a; + counter_a += 1; + + let message_a = create_test_packet(1, lp_id, current_counter_a, ciphertext_a); + let mut encoded_a = BytesMut::new(); + serialize_lp_packet(&message_a, &mut encoded_a).expect("A serialize failed"); + + // B parses and checks replay + let decoded_packet_b = parse_lp_packet(&encoded_a).expect("B parse failed"); + session_manager_2 + .receiving_counter_quick_check(peer_b_sm, decoded_packet_b.header.counter) + .expect("B replay check failed (A->B)"); + assert_eq!(decoded_packet_b.header.counter, current_counter_a); + let decrypted_payload = session_manager_2 + .decrypt_data(peer_b_sm, &decoded_packet_b.message) + .expect("B decrypt failed"); + assert_eq!(decrypted_payload, plaintext_a); + session_manager_2 + .receiving_counter_mark(peer_b_sm, current_counter_a) + .expect("B mark counter failed"); + + // --- B sends to A --- + let plaintext_b = format!("B->A Message {}", i).into_bytes(); + let ciphertext_b = session_manager_2 + .encrypt_data(peer_b_sm, &plaintext_b) + .expect("B encrypt failed"); + let current_counter_b = counter_b; + counter_b += 1; + + let message_b = create_test_packet(1, lp_id, current_counter_b, ciphertext_b); + let mut encoded_b = BytesMut::new(); + serialize_lp_packet(&message_b, &mut encoded_b).expect("B serialize failed"); + + // A parses and checks replay + let decoded_packet_a = parse_lp_packet(&encoded_b).expect("A parse failed"); + session_manager_1 + .receiving_counter_quick_check(peer_a_sm, decoded_packet_a.header.counter) + .expect("A replay check failed (B->A)"); + assert_eq!(decoded_packet_a.header.counter, current_counter_b); + let decrypted_payload = session_manager_1 + .decrypt_data(peer_a_sm, &decoded_packet_a.message) + .expect("A decrypt failed"); + assert_eq!(decrypted_payload, plaintext_b); + session_manager_1 + .receiving_counter_mark(peer_a_sm, current_counter_b) + .expect("A mark counter failed"); + } + + // 5. Verify counter stats + // Note: current_packet_cnt() returns (next_expected_receive_counter, total_received) + let (next_recv_a, total_recv_a) = session_manager_1.current_packet_cnt(peer_a_sm).unwrap(); + let (next_recv_b, total_recv_b) = session_manager_2.current_packet_cnt(peer_b_sm).unwrap(); + + // Peer A sent handshake(0), handshake(1) + 5 data packets = 7 total. Next send counter = 7. + // Peer A received handshake(0) + 5 data packets = 6 total. Next expected recv counter = 6. + assert_eq!( + counter_a, + 2 + NUM_MESSAGES, + "Peer A final send counter mismatch" + ); + assert_eq!( + total_recv_a, + 1 + NUM_MESSAGES, + "Peer A total received count mismatch" + ); // Received 1 handshake + 5 data + assert_eq!( + next_recv_a, + 1 + NUM_MESSAGES, + "Peer A next expected receive counter mismatch" + ); // Expected counter for msg from B + + // Peer B sent handshake(0) + 5 data packets = 6 total. Next send counter = 6. + // Peer B received handshake(0), handshake(1) + 5 data packets = 7 total. Next expected recv counter = 7. + assert_eq!( + counter_b, + 1 + NUM_MESSAGES, + "Peer B final send counter mismatch" + ); + assert_eq!( + total_recv_b, + 2 + NUM_MESSAGES, + "Peer B total received count mismatch" + ); // Received 2 handshake + 5 data + assert_eq!( + next_recv_b, + 2 + NUM_MESSAGES, + "Peer B next expected receive counter mismatch" + ); // Expected counter for msg from A + + println!("Bidirectional test completed."); + } + + /// Tests error handling in session flow + #[test] + fn test_session_error_handling() { + // 1. Initialize session manager + let session_manager = SessionManager::new(); + + // Generate Ed25519 keypair for PSQ authentication + let ed25519_keypair = ed25519::KeyPair::from_secret([5u8; 32], 0); + + // Derive X25519 key from Ed25519 (same as state machine does internally) + let x25519_pub = ed25519_keypair + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair type + let lp_pub = PublicKey::from_bytes(x25519_pub.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (self-connection: both sides use same key) + let lp_id = make_lp_id(&lp_pub, &lp_pub); + + // Test salt + let salt = [44u8; 32]; + + // 2. Create a session (using real noise state) + let _session = session_manager + .create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ) + .expect("Failed to create session"); + + // 3. Try to get a non-existent session + let result = session_manager.state_machine_exists(999); + assert!(!result, "Non-existent session should return None"); + + // 4. Try to remove a non-existent session + let result = session_manager.remove_state_machine(999); + assert!( + !result, + "Remove session should not remove a non-existent session" + ); + + // 5. Create and immediately remove a session + let _temp_session = session_manager + .create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ) + .expect("Failed to create temp session"); + + assert!( + session_manager.remove_state_machine(lp_id), + "Should remove the session" + ); + + // 6. Create a codec and test error cases + // let mut codec = LPCodec::new(session); + + // 7. Create an invalid message type packet + let mut buf = BytesMut::new(); + + // Add header + buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved + buf.extend_from_slice(&lp_id.to_le_bytes()); // Sender index + buf.extend_from_slice(&0u64.to_le_bytes()); // Counter + + // Add invalid message type + buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); + + // Add some dummy data + buf.extend_from_slice(&[0u8; 80]); + + // Add trailer + buf.extend_from_slice(&[0u8; TRAILER_LEN]); + + // Try to parse the invalid message type + let result = parse_lp_packet(&buf); + assert!(result.is_err(), "Decoding invalid message type should fail"); + + // Add assertion for the specific error type + assert!(matches!( + result.unwrap_err(), + LpError::InvalidMessageType(0xFFFF) + )); + + // 8. Test partial packet decoding + let partial_packet = &buf[0..10]; // Too short to be a valid packet + let partial_bytes = BytesMut::from(partial_packet); + + let result = parse_lp_packet(&partial_bytes); + assert!(result.is_err(), "Parsing partial packet should fail"); + assert!(matches!( + result.unwrap_err(), + LpError::InsufficientBufferSize + )); + } + // Remove unused imports if SessionManager methods are no longer direct dependencies + // use crate::noise_protocol::{create_noise_state, create_noise_state_responder}; + use crate::{ + // Bring in state machine types + state_machine::{LpAction, LpInput, LpStateBare}, + // message::LpMessage, // LpMessage likely still needed for LpInput/LpAction + // packet::{LpHeader, LpPacket, TRAILER_LEN}, // LpPacket needed for LpAction/LpInput + }; + use bytes::Bytes; // Use Bytes for SendData input + + // Keep helper function for creating test packets if needed, + // but LpAction::SendPacket should provide the packets now. + // fn create_test_packet(...) -> LpPacket { ... } + + /// Tests the complete session flow using ONLY the process_input interface: + /// - Creation of sessions through session manager + /// - Handshake driven by StartHandshake, ReceivePacket inputs + /// - Data transfer driven by SendData, ReceivePacket inputs + /// - Actions like SendPacket, DeliverData handled from output + /// - Implicit replay protection via state machine logic + /// - Closing driven by Close input + #[test] + fn test_full_session_flow_with_process_input() { + // 1. Initialize session managers + let session_manager_1 = SessionManager::new(); + let session_manager_2 = SessionManager::new(); + + // 2. Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_a = ed25519::KeyPair::from_secret([6u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([7u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_a = ed25519_keypair_a + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_b = ed25519_keypair_b + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + + // Test salt + let salt = [45u8; 32]; + + // 3. Create sessions state machines + assert!( + session_manager_1 + .create_session_state_machine( + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key() + ), + ed25519_keypair_b.public_key(), + true, + &salt, + ) // Initiator + .is_ok() + ); + assert!( + session_manager_2 + .create_session_state_machine( + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key() + ), + ed25519_keypair_a.public_key(), + false, + &salt, + ) // Responder + .is_ok() + ); + + assert_eq!(session_manager_1.session_count(), 1); + assert_eq!(session_manager_2.session_count(), 1); + assert!(session_manager_1.state_machine_exists(lp_id)); + assert!(session_manager_2.state_machine_exists(lp_id)); + + // Verify initial states are ReadyToHandshake + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::ReadyToHandshake + ); + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::ReadyToHandshake + ); + + // --- 4. Simulate Noise Handshake via process_input --- + println!("Starting handshake simulation via process_input..."); + + let mut packet_a_to_b: Option; + let mut packet_b_to_a: Option; + let mut rounds = 0; + const MAX_ROUNDS: usize = 10; // KKT (2 messages) + XK handshake (3 messages) + PSQ = 6 rounds total + + // --- Round 1: Initiator Starts --- + println!(" Round {}: Initiator starts handshake", rounds); + let action_a1 = session_manager_1 + .process_input(lp_id, LpInput::StartHandshake) + .expect("Initiator StartHandshake should produce an action") + .expect("Initiator StartHandshake failed"); + + if let LpAction::SendPacket(packet) = action_a1 { + println!(" Initiator produced SendPacket (KKT request)"); + packet_a_to_b = Some(packet); + } else { + panic!("Initiator StartHandshake did not produce SendPacket"); + } + // After StartHandshake, initiator should be in KKTExchange state (not Handshaking yet) + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::KKTExchange, + "Initiator state wrong after StartHandshake (should be KKTExchange)" + ); + + // *** ADD THIS BLOCK for Responder StartHandshake *** + println!( + " Round {}: Responder explicitly enters KKTExchange state", + rounds + ); + let action_b_start = session_manager_2.process_input(lp_id, LpInput::StartHandshake); + // Responder's StartHandshake should not produce an action to send + assert!( + action_b_start.as_ref().unwrap().is_none(), + "Responder StartHandshake should produce None action, got {:?}", + action_b_start + ); + // Verify responder transitions to KKTExchange state (not Handshaking yet) + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::KKTExchange, // Responder also enters KKTExchange state + "Responder state should be KKTExchange after its StartHandshake" + ); + // *** END OF ADDED BLOCK *** + + // --- Round 2: Responder Receives KKT Request, Sends KKT Response --- + rounds += 1; + println!( + " Round {}: Responder receives KKT request, sends KKT response", + rounds + ); + let packet_to_process = packet_a_to_b + .take() + .expect("KKT request from A was missing"); + + // Simulate network: serialize -> parse (optional but good practice) + let mut buf_a = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_a).unwrap(); + let parsed_packet_a = parse_lp_packet(&buf_a).unwrap(); + + // Responder processes KKT request + let action_b1 = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a)) + .expect("Responder ReceivePacket should produce an action") + .expect("Responder ReceivePacket failed"); + + if let LpAction::SendPacket(packet) = action_b1 { + println!(" Responder received KKT request, produced KKT response"); + packet_b_to_a = Some(packet); + } else { + panic!("Responder ReceivePacket did not produce SendPacket for KKT response"); + } + // Responder transitions to Handshaking after KKT completes + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::Handshaking, + "Responder state should be Handshaking after KKT exchange" + ); + + // --- Round 3: Initiator Receives KKT Response, Sends First Noise Message (with PSQ) --- + rounds += 1; + println!( + " Round {}: Initiator receives KKT response, sends first Noise message (with PSQ)", + rounds + ); + let packet_to_process = packet_b_to_a + .take() + .expect("KKT response from B was missing"); + + // Simulate network + let mut buf_b = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_b).unwrap(); + let parsed_packet_b = parse_lp_packet(&buf_b).unwrap(); + + // Initiator processes KKT response + let action_a2 = session_manager_1 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_b)) + .expect("Initiator ReceivePacket should produce an action") + .expect("Initiator ReceivePacket failed"); + + match action_a2 { + LpAction::SendPacket(packet) => { + println!( + " Initiator received KKT response, produced first Noise message (-> e)" + ); + packet_a_to_b = Some(packet); + // Initiator transitions to Handshaking after KKT completes + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::Handshaking, + "Initiator state should be Handshaking after receiving KKT response" + ); + } + LpAction::KKTComplete => { + println!( + " Initiator received KKT response, produced KKTComplete (will send Noise in next step)" + ); + // KKT completed, now need to explicitly trigger handshake message + // This might be the case if KKT completion doesn't automatically send the first Noise message + // Let's try to prepare the handshake message + if let Some(msg_result) = session_manager_1.prepare_handshake_message(lp_id) { + let msg = msg_result.expect("Failed to prepare handshake message after KKT"); + // Create a packet from the message + let packet = create_test_packet(1, lp_id, 0, msg); + packet_a_to_b = Some(packet); + println!(" Prepared first Noise message after KKTComplete"); + } else { + panic!("No handshake message available after KKT complete"); + } + } + other => { + panic!( + "Initiator ReceivePacket produced unexpected action after KKT response: {:?}", + other + ); + } + } + + // --- Round 4: Responder Receives First Noise Message, Sends Second --- + rounds += 1; + println!( + " Round {}: Responder receives first Noise message, sends second", + rounds + ); + let packet_to_process = packet_a_to_b + .take() + .expect("First Noise packet from A was missing"); + + // Simulate network + let mut buf_a2 = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_a2).unwrap(); + let parsed_packet_a2 = parse_lp_packet(&buf_a2).unwrap(); + + // Responder processes first Noise message and sends second Noise message + let action_b2 = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a2)) + .expect("Responder ReceivePacket should produce an action") + .expect("Responder ReceivePacket failed"); + + if let LpAction::SendPacket(packet) = action_b2 { + println!( + " Responder received first Noise message, produced second Noise message (<- e, ee, s, es)" + ); + packet_b_to_a = Some(packet); + } else { + panic!("Responder did not produce SendPacket for second Noise message"); + } + // Responder still in Handshaking, waiting for final message + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::Handshaking, + "Responder state should still be Handshaking after sending second message" + ); + + // --- Round 5: Initiator Receives Second Noise Message, Sends Third, Completes --- + rounds += 1; + println!( + " Round {}: Initiator receives second Noise message, sends third, completes", + rounds + ); + let packet_to_process = packet_b_to_a + .take() + .expect("Second Noise packet from B was missing"); + + let mut buf_b2 = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_b2).unwrap(); + let parsed_packet_b2 = parse_lp_packet(&buf_b2).unwrap(); + + let action_a3 = session_manager_1 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_b2)) + .expect("Initiator ReceivePacket should produce an action") + .expect("Initiator ReceivePacket failed"); + + if let LpAction::SendPacket(packet) = action_a3 { + println!( + " Initiator received second Noise message, produced third Noise message (-> s, se)" + ); + packet_a_to_b = Some(packet); + } else { + panic!("Initiator did not produce SendPacket for third Noise message"); + } + // Initiator transitions to Transport after sending third message + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::Transport, + "Initiator state should be Transport after sending third message" + ); + + // --- Round 6: Responder Receives Third Noise Message, Completes --- + rounds += 1; + println!( + " Round {}: Responder receives third Noise message, completes", + rounds + ); + let packet_to_process = packet_a_to_b + .take() + .expect("Third Noise packet from A was missing"); + + let mut buf_a3 = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_a3).unwrap(); + let parsed_packet_a3 = parse_lp_packet(&buf_a3).unwrap(); + + let action_b3 = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a3)) + .expect("Responder final ReceivePacket should produce an action") + .expect("Responder final ReceivePacket failed"); + + // Responder completes handshake + if let LpAction::HandshakeComplete = action_b3 { + println!(" Responder received third Noise message, produced HandshakeComplete"); + } else { + println!( + " Responder received third Noise message (Action: {:?})", + action_b3 + ); + } + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::Transport, + "Responder state should be Transport after processing third message" + ); + + // --- Verification --- + assert!(rounds < MAX_ROUNDS, "Handshake took too many rounds"); + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::Transport + ); + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::Transport + ); + println!("Handshake simulation completed successfully via process_input."); + + // --- 5. Simulate Data Transfer via process_input --- + println!("Starting data transfer simulation via process_input..."); + let plaintext_a_to_b = b"Hello from A via process_input!"; + let plaintext_b_to_a = b"Hello from B via process_input!"; + + // --- A sends to B --- + println!(" A sends to B"); + let action_a_send = session_manager_1 + .process_input(lp_id, LpInput::SendData(plaintext_a_to_b.to_vec())) + .expect("A SendData should produce action") + .expect("A SendData failed"); + + let data_packet_a = if let LpAction::SendPacket(packet) = action_a_send { + packet + } else { + panic!("A SendData did not produce SendPacket"); + }; + + // Simulate network + let mut buf_data_a = BytesMut::new(); + serialize_lp_packet(&data_packet_a, &mut buf_data_a).unwrap(); + let parsed_data_a = parse_lp_packet(&buf_data_a).unwrap(); + + // B receives + println!(" B receives from A"); + let action_b_recv = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(parsed_data_a)) + .expect("B ReceivePacket (data) should produce action") + .expect("B ReceivePacket (data) failed"); + + if let LpAction::DeliverData(data) = action_b_recv { + assert_eq!( + data, + Bytes::copy_from_slice(plaintext_a_to_b), + "Decrypted data mismatch A->B" + ); + println!( + " B successfully decrypted: {:?}", + String::from_utf8_lossy(&data) + ); + } else { + panic!("B ReceivePacket did not produce DeliverData"); + } + + // --- B sends to A --- + println!(" B sends to A"); + let action_b_send = session_manager_2 + .process_input(lp_id, LpInput::SendData(plaintext_b_to_a.to_vec())) + .expect("B SendData should produce action") + .expect("B SendData failed"); + + let data_packet_b = if let LpAction::SendPacket(packet) = action_b_send { + packet + } else { + panic!("B SendData did not produce SendPacket"); + }; + // Keep a copy for replay test + let data_packet_b_replay = data_packet_b.clone(); + + // Simulate network + let mut buf_data_b = BytesMut::new(); + serialize_lp_packet(&data_packet_b, &mut buf_data_b).unwrap(); + let parsed_data_b = parse_lp_packet(&buf_data_b).unwrap(); + + // A receives + println!(" A receives from B"); + let action_a_recv = session_manager_1 + .process_input(lp_id, LpInput::ReceivePacket(parsed_data_b)) + .expect("A ReceivePacket (data) should produce action") + .expect("A ReceivePacket (data) failed"); + + if let LpAction::DeliverData(data) = action_a_recv { + assert_eq!( + data, + Bytes::copy_from_slice(plaintext_b_to_a), + "Decrypted data mismatch B->A" + ); + println!( + " A successfully decrypted: {:?}", + String::from_utf8_lossy(&data) + ); + } else { + panic!("A ReceivePacket did not produce DeliverData"); + } + println!("Data transfer simulation completed."); + + // --- 6. Replay Protection Test --- + println!("Testing data packet replay protection via process_input..."); + let replay_result = + session_manager_1.process_input(lp_id, LpInput::ReceivePacket(data_packet_b_replay)); // Use cloned packet + + assert!(replay_result.is_err(), "Replay should produce Err(...)"); + let error = replay_result.err().unwrap(); + assert!( + matches!(error, LpError::Replay(_)), + "Expected Replay error, got {:?}", + error + ); + println!("Data packet replay protection test passed."); + + // --- 7. Out-of-Order Test --- + println!("Testing out-of-order reception via process_input..."); + + // A prepares N+1 then N + let data_n_plus_1 = Bytes::from_static(b"Message N+1"); + let data_n = Bytes::from_static(b"Message N"); + + let action_send_n1 = session_manager_1 + .process_input(lp_id, LpInput::SendData(data_n_plus_1.to_vec())) + .unwrap() + .unwrap(); + let packet_n1 = match action_send_n1 { + LpAction::SendPacket(p) => p, + _ => panic!("Expected SendPacket"), + }; + + let action_send_n = session_manager_1 + .process_input(lp_id, LpInput::SendData(data_n.to_vec())) + .unwrap() + .unwrap(); + let packet_n = match action_send_n { + LpAction::SendPacket(p) => p, + _ => panic!("Expected SendPacket"), + }; + let packet_n_replay = packet_n.clone(); // For replay test + + // B receives N+1 first + println!(" B receives N+1"); + let action_recv_n1 = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(packet_n1)) + .unwrap() + .unwrap(); + match action_recv_n1 { + LpAction::DeliverData(d) => assert_eq!(d, data_n_plus_1, "Data N+1 mismatch"), + _ => panic!("Expected DeliverData for N+1"), + } + + // B receives N second (should work) + println!(" B receives N"); + let action_recv_n = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(packet_n)) + .unwrap() + .unwrap(); + match action_recv_n { + LpAction::DeliverData(d) => assert_eq!(d, data_n, "Data N mismatch"), + _ => panic!("Expected DeliverData for N"), + } + + // B tries to replay N (should fail) + println!(" B tries to replay N"); + let replay_n_result = + session_manager_2.process_input(lp_id, LpInput::ReceivePacket(packet_n_replay)); + assert!(replay_n_result.is_err(), "Replay N should produce Err"); + assert!( + matches!(replay_n_result.err().unwrap(), LpError::Replay(_)), + "Expected Replay error for N" + ); + println!("Out-of-order test passed."); + + // --- 8. Close Test --- + println!("Testing close via process_input..."); + + // A closes + let action_a_close = session_manager_1 + .process_input(lp_id, LpInput::Close) + .expect("A Close should produce action") + .expect("A Close failed"); + assert!(matches!(action_a_close, LpAction::ConnectionClosed)); + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::Closed + ); + + // Further actions on A fail + let send_after_close_a = + session_manager_1.process_input(lp_id, LpInput::SendData(b"fail".to_vec())); + assert!(send_after_close_a.is_err()); + assert!(matches!( + send_after_close_a.err().unwrap(), + LpError::LpSessionClosed + )); + + // B closes + let action_b_close = session_manager_2 + .process_input(lp_id, LpInput::Close) + .expect("B Close should produce action") + .expect("B Close failed"); + assert!(matches!(action_b_close, LpAction::ConnectionClosed)); + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::Closed + ); + + // Further actions on B fail + let send_after_close_b = + session_manager_2.process_input(lp_id, LpInput::SendData(b"fail".to_vec())); + assert!(send_after_close_b.is_err()); + assert!(matches!( + send_after_close_b.err().unwrap(), + LpError::LpSessionClosed + )); + println!("Close test passed."); + + // --- 9. Session Removal --- + assert!(session_manager_1.remove_state_machine(lp_id)); + assert_eq!(session_manager_1.session_count(), 0); + assert!(!session_manager_1.state_machine_exists(lp_id)); + + // B's session manager still has it until removed + assert!(session_manager_2.state_machine_exists(lp_id)); + assert!(session_manager_2.remove_state_machine(lp_id)); + assert_eq!(session_manager_2.session_count(), 0); + assert!(!session_manager_2.state_machine_exists(lp_id)); + println!("Session removal test passed."); + } + // ... other tests ... +} diff --git a/common/nym-lp/src/session_manager.rs b/common/nym-lp/src/session_manager.rs new file mode 100644 index 00000000000..4baa9f5a3f6 --- /dev/null +++ b/common/nym-lp/src/session_manager.rs @@ -0,0 +1,335 @@ +// Copyright 2025 - Nym Technologies SA +// 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 nym_crypto::asymmetric::ed25519; + +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, +} + +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, 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 { + Ok(self.get_state(lp_id)? == LpStateBare::Handshaking) + } + + pub fn should_initiate_handshake(&self, lp_id: u32) -> Result { + Ok(self.ready_to_handshake(lp_id)? || self.closed(lp_id)?) + } + + pub fn ready_to_handshake(&self, lp_id: u32) -> Result { + Ok(self.get_state(lp_id)? == LpStateBare::ReadyToHandshake) + } + + pub fn closed(&self, lp_id: u32) -> Result { + Ok(self.get_state(lp_id)? == LpStateBare::Closed) + } + + pub fn transport(&self, lp_id: u32) -> Result { + Ok(self.get_state(lp_id)? == LpStateBare::Transport) + } + + #[cfg(test)] + fn get_state_machine_id(&self, lp_id: u32) -> Result { + self.with_state_machine(lp_id, |sm| sm.id())? + } + + pub fn get_state(&self, lp_id: u32) -> Result { + 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> { + self.prepare_handshake_message(lp_id) + } + + pub fn prepare_handshake_message(&self, lp_id: u32) -> Option> { + self.with_state_machine(lp_id, |sm| sm.session().ok()?.prepare_handshake_message()) + .ok()? + } + + pub fn is_handshake_complete(&self, lp_id: u32) -> Result { + self.with_state_machine(lp_id, |sm| Ok(sm.session()?.is_handshake_complete()))? + } + + pub fn next_counter(&self, lp_id: u32) -> Result { + self.with_state_machine(lp_id, |sm| Ok(sm.session()?.next_counter()))? + } + + pub fn decrypt_data(&self, lp_id: u32, message: &LpMessage) -> Result, 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 { + 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 { + self.with_state_machine(lp_id, |sm| 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(&self, lp_id: u32, f: F) -> Result + 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(&self, lp_id: u32, f: F) -> Result + 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_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), + remote_ed25519_key: &ed25519::PublicKey, + is_initiator: bool, + salt: &[u8; 32], + ) -> Result { + let sm = LpStateMachine::new( + is_initiator, + local_ed25519_keypair, + remote_ed25519_key, + salt, + )?; + 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() + } + + /// Test-only method to initialize KKT state to Completed for a session. + /// This allows integration tests to bypass KKT exchange and directly test PSQ/handshake. + #[cfg(test)] + pub fn init_kkt_for_test( + &self, + lp_id: u32, + remote_x25519_pub: &crate::keypair::PublicKey, + ) -> Result<(), LpError> { + self.with_state_machine(lp_id, |sm| { + sm.session()?.set_kkt_completed_for_test(remote_x25519_pub); + Ok(()) + })? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_crypto::asymmetric::ed25519; + + #[test] + fn test_session_manager_get() { + let manager = SessionManager::new(); + let ed25519_keypair = ed25519::KeyPair::from_secret([10u8; 32], 0); + let salt = [47u8; 32]; + + let sm_1_id = manager + .create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ) + .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 ed25519_keypair = ed25519::KeyPair::from_secret([11u8; 32], 0); + let salt = [48u8; 32]; + + let sm_1_id = manager + .create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ) + .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 ed25519_keypair_1 = ed25519::KeyPair::from_secret([12u8; 32], 0); + let ed25519_keypair_2 = ed25519::KeyPair::from_secret([13u8; 32], 1); + let ed25519_keypair_3 = ed25519::KeyPair::from_secret([14u8; 32], 2); + let salt = [49u8; 32]; + + let sm_1 = manager + .create_session_state_machine( + ( + ed25519_keypair_1.private_key(), + ed25519_keypair_1.public_key(), + ), + ed25519_keypair_1.public_key(), + true, + &salt, + ) + .unwrap(); + + let sm_2 = manager + .create_session_state_machine( + ( + ed25519_keypair_2.private_key(), + ed25519_keypair_2.public_key(), + ), + ed25519_keypair_2.public_key(), + true, + &salt, + ) + .unwrap(); + + let sm_3 = manager + .create_session_state_machine( + ( + ed25519_keypair_3.private_key(), + ed25519_keypair_3.public_key(), + ), + ed25519_keypair_3.public_key(), + true, + &salt, + ) + .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 ed25519_keypair = ed25519::KeyPair::from_secret([15u8; 32], 0); + let salt = [50u8; 32]; + + let sm = manager.create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ); + + 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); + } +} diff --git a/common/nym-lp/src/state_machine.rs b/common/nym-lp/src/state_machine.rs new file mode 100644 index 00000000000..27cc5896cac --- /dev/null +++ b/common/nym-lp/src/state_machine.rs @@ -0,0 +1,1045 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Lewes Protocol State Machine for managing connection lifecycle. + +use crate::{ + LpError, + keypair::{Keypair, PrivateKey as LpPrivateKey, PublicKey as LpPublicKey}, + make_lp_id, + noise_protocol::NoiseError, + packet::LpPacket, + session::LpSession, +}; +use bytes::BytesMut; +use nym_crypto::asymmetric::ed25519; +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 }, + + /// Performing KKT (KEM Key Transfer) exchange before Noise handshake. + /// Initiator requests responder's KEM public key, responder provides signed key. + KKTExchange { 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, + KKTExchange, + Handshaking, + Transport, + Closed, + Processing, +} + +impl From<&LpState> for LpStateBare { + fn from(state: &LpState) -> Self { + match state { + LpState::ReadyToHandshake { .. } => LpStateBare::ReadyToHandshake, + LpState::KKTExchange { .. } => LpStateBare::KKTExchange, + 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), // 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 KKT exchange completed successfully. + KKTComplete, + /// 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::KKTExchange { 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 { + match self.state { + LpState::ReadyToHandshake { session } + | LpState::KKTExchange { session } + | LpState::Handshaking { session } + | LpState::Transport { session } => Ok(session), + LpState::Closed { .. } => Err(LpError::LpSessionClosed), + LpState::Processing => Err(LpError::LpSessionProcessing), + } + } + + pub fn id(&self) -> Result { + Ok(self.session()?.id()) + } + + /// Creates a new state machine from Ed25519 keys, internally deriving X25519 keys. + /// + /// This is the primary constructor that accepts only Ed25519 keys (identity/signing keys) + /// and internally derives the X25519 keys needed for Noise protocol and DHKEM. + /// This simplifies the API by hiding the X25519 derivation as an implementation detail. + /// + /// # Arguments + /// + /// * `is_initiator` - Whether this side initiates the handshake + /// * `local_ed25519_keypair` - Ed25519 keypair for PSQ authentication and X25519 derivation + /// (from client identity key or gateway signing key) + /// * `remote_ed25519_key` - Peer's Ed25519 public key for PSQ authentication and X25519 derivation + /// * `salt` - Fresh salt for PSK derivation (must be unique per session) + /// + /// # Errors + /// + /// Returns `LpError::Ed25519RecoveryError` if Ed25519→X25519 conversion fails for the remote key. + /// Local private key conversion cannot fail. + pub fn new( + is_initiator: bool, + local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), + remote_ed25519_key: &ed25519::PublicKey, + salt: &[u8; 32], + ) -> Result { + // We use standard RFC 7748 conversion to derive X25519 keys from Ed25519 identity keys. + // This allows callers to provide only Ed25519 keys (which they already have for signing/identity) + // without needing to manage separate X25519 keypairs. + // + // Security: Ed25519→X25519 conversion is cryptographically sound (RFC 7748). + // The derived X25519 keys are used for: + // - Noise protocol ephemeral DH + // - PSQ ECDH baseline security (pre-quantum) + // - lp_id calculation (session identifier) + + // Convert Ed25519 keys to X25519 for Noise protocol + let local_x25519_private = local_ed25519_keypair.0.to_x25519(); + let local_x25519_public = local_ed25519_keypair + .1 + .to_x25519() + .map_err(LpError::Ed25519RecoveryError)?; + + let remote_x25519_public = remote_ed25519_key + .to_x25519() + .map_err(LpError::Ed25519RecoveryError)?; + + // Convert nym_crypto X25519 types to nym_lp keypair types + let lp_private = LpPrivateKey::from_bytes(local_x25519_private.as_bytes()); + let lp_public = LpPublicKey::from_bytes(local_x25519_public.as_bytes())?; + let lp_remote_public = LpPublicKey::from_bytes(remote_x25519_public.as_bytes())?; + + // Create X25519 keypair for Noise and lp_id calculation + let local_x25519_keypair = Keypair::from_keys(lp_private, lp_public); + + // Calculate the shared lp_id using derived X25519 keys + let lp_id = make_lp_id(local_x25519_keypair.public_key(), &lp_remote_public); + + // Create the session with both Ed25519 (for PSQ auth) and derived X25519 keys (for Noise) + let session = LpSession::new( + lp_id, + is_initiator, + local_ed25519_keypair, + local_x25519_keypair.private_key(), + remote_ed25519_key, + &lp_remote_public, + salt, + )?; + + Ok(LpStateMachine { + state: LpState::ReadyToHandshake { session }, + }) + } + /// Processes an input event and returns a list of actions to perform. + pub fn process_input(&mut self, input: LpInput) -> Option> { + // 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> = 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 starts by requesting KEM key via KKT + match session.prepare_kkt_request() { + Some(Ok(kkt_message)) => { + match session.next_packet(kkt_message) { + Ok(kkt_packet) => { + result_action = Some(Ok(LpAction::SendPacket(kkt_packet))); + LpState::KKTExchange { session } // Transition to KKTExchange + } + 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 => { + // Should not happen for initiator + let err = LpError::Internal( + "prepare_kkt_request returned None for initiator".to_string(), + ); + let reason = err.to_string(); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + } else { + // Responder waits for KKT request + LpState::KKTExchange { session } + // No action needed yet, result_action remains None. + } + } + + // --- KKTExchange State --- + (LpState::KKTExchange { 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()))); + LpState::KKTExchange { session } + } else { + use crate::message::LpMessage; + + // Packet message is already parsed, match on it directly + match &packet.message { + LpMessage::KKTRequest(kkt_request) if !session.is_initiator() => { + // Responder processes KKT request + // Convert X25519 public key to KEM format for KKT response + use nym_kkt::ciphersuite::EncapsulationKey; + + // Get local X25519 public key by deriving from private key + let local_x25519_public = session.local_x25519_public(); + + // Convert to libcrux KEM public key + match libcrux_kem::PublicKey::decode( + libcrux_kem::Algorithm::X25519, + local_x25519_public.as_bytes(), + ) { + Ok(libcrux_public_key) => { + let responder_kem_pk = EncapsulationKey::X25519(libcrux_public_key); + + match session.process_kkt_request(&kkt_request.0, &responder_kem_pk) { + Ok(kkt_response_message) => { + match session.next_packet(kkt_response_message) { + Ok(response_packet) => { + result_action = Some(Ok(LpAction::SendPacket(response_packet))); + // After KKT exchange, move to Handshaking + LpState::Handshaking { session } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = format!("Failed to convert X25519 to KEM: {:?}", e); + let err = LpError::Internal(reason.clone()); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + } + LpMessage::KKTResponse(kkt_response) if session.is_initiator() => { + // Initiator processes KKT response (signature-only mode with None) + match session.process_kkt_response(&kkt_response.0, None) { + Ok(()) => { + result_action = Some(Ok(LpAction::KKTComplete)); + // After successful KKT, move to Handshaking + LpState::Handshaking { session } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + _ => { + // Wrong message type for KKT state + let err = LpError::InvalidStateTransition { + state: "KKTExchange".to_string(), + input: format!("Unexpected message type: {:?}", packet.message), + }; + let reason = err.to_string(); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + } + } + + // Reject SendData during KKT exchange + (LpState::KKTExchange { session }, LpInput::SendData(_)) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "KKTExchange".to_string(), + input: "SendData".to_string(), + })); + LpState::KKTExchange { session } + } + + // Reject StartHandshake if already in KKT exchange + (LpState::KKTExchange { session }, LpInput::StartHandshake) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "KKTExchange".to_string(), + input: "StartHandshake".to_string(), + })); + LpState::KKTExchange { session } + } + + // --- 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. First check if we need to send a handshake message (before checking completion) + 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 if handshake became complete after preparing message + 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 => { + // 5. No message to send - check if handshake is complete + if session.is_handshake_complete() { + result_action = Some(Ok(LpAction::HandshakeComplete)); + LpState::Transport { session } // Transition to Transport + } else { + // 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)); + 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, KKTExchange, Handshaking, Transport) --- + ( + LpState::ReadyToHandshake { .. } // We consume the session here + | LpState::KKTExchange { .. } + | 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 prepare an outgoing data packet + // Kept as it doesn't mutate self.state + fn prepare_data_packet( + &self, + session: &LpSession, + data: &[u8], + ) -> Result { + 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 bytes::Bytes; + use nym_crypto::asymmetric::ed25519; + + #[test] + fn test_state_machine_init() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([16u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([17u8; 32], 1); + + // Test salt + let salt = [51u8; 32]; + + let initiator_sm = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ); + 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, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ); + 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 (derived internally from Ed25519 keys) + // Both state machines should have the same lp_id + assert_eq!(init_session.id(), resp_session.id()); + } + + #[test] + fn test_state_machine_simplified_flow() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([18u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([19u8; 32], 1); + + // Test salt + let salt = [52u8; 32]; + + // Create state machines (already in ReadyToHandshake) + let mut initiator = LpStateMachine::new( + true, // is_initiator + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + let mut responder = LpStateMachine::new( + false, // is_initiator + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ) + .unwrap(); + + let lp_id = initiator.id().unwrap(); + assert_eq!(lp_id, responder.id().unwrap()); + + // --- KKT Exchange --- + println!("--- Step 1: Initiator starts handshake (sends KKT request) ---"); + let init_actions_1 = initiator.process_input(LpInput::StartHandshake); + let kkt_request_packet = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_1 { + packet.clone() + } else { + panic!("Initiator should send KKT request"); + }; + + assert!( + matches!(initiator.state, LpState::KKTExchange { .. }), + "Initiator should be in KKTExchange" + ); + assert_eq!( + kkt_request_packet.header.session_id(), + lp_id, + "KKT request packet has wrong lp_id" + ); + + println!("--- Step 2: Responder starts handshake (waits for KKT) ---"); + 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::KKTExchange { .. }), + "Responder should be in KKTExchange" + ); + + println!("--- Step 3: Responder receives KKT request, sends KKT response ---"); + let resp_actions_2 = responder.process_input(LpInput::ReceivePacket(kkt_request_packet)); + let kkt_response_packet = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_2 { + packet.clone() + } else { + panic!("Responder should send KKT response"); + }; + assert!( + matches!(responder.state, LpState::Handshaking { .. }), + "Responder should be Handshaking after KKT" + ); + + println!("--- Step 4: Initiator receives KKT response (KKT complete) ---"); + let init_actions_2 = initiator.process_input(LpInput::ReceivePacket(kkt_response_packet)); + assert!( + matches!(init_actions_2, Some(Ok(LpAction::KKTComplete))), + "Initiator should signal KKT complete" + ); + assert!( + matches!(initiator.state, LpState::Handshaking { .. }), + "Initiator should be Handshaking after KKT" + ); + + // --- Noise Handshake Message Exchange --- + println!("--- Step 5: Responder receives Noise msg 1, sends Noise msg 2 ---"); + // Now both sides are in Handshaking, continue with Noise handshake + // Initiator needs to send first Noise message + // (In real flow, this might happen automatically or via another process_input call) + // For this test, we'll simulate the responder receiving the first Noise message + // Actually, let me check if initiator automatically sends the first Noise message... + // Looking at the old test, it seems packet 1 was the first Noise message. + // With KKT, we need the initiator to send the first Noise message now. + + // Initiator prepares and sends first Noise handshake message + let init_noise_msg = initiator.session().unwrap().prepare_handshake_message(); + let init_packet_1 = if let Some(Ok(msg)) = init_noise_msg { + initiator.session().unwrap().next_packet(msg).unwrap() + } else { + panic!("Initiator should have first Noise message"); + }; + + let resp_actions_3 = responder.process_input(LpInput::ReceivePacket(init_packet_1)); + let resp_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_3 { + 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 6: Initiator receives Noise msg 2, sends Noise msg 3 ---"); + let init_actions_3 = initiator.process_input(LpInput::ReceivePacket(resp_packet_2)); + let init_packet_3 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_3 { + packet.clone() + } else { + panic!("Initiator should send Noise packet 3"); + }; + assert!( + matches!(initiator.state, LpState::Transport { .. }), + "Initiator should be Transport" + ); + assert_eq!( + init_packet_3.header.session_id(), + lp_id, + "Noise packet 3 has wrong lp_id" + ); + + println!("--- Step 7: Responder receives Noise msg 3, completes handshake ---"); + let resp_actions_4 = responder.process_input(LpInput::ReceivePacket(init_packet_3)); + assert!( + matches!(resp_actions_4, Some(Ok(LpAction::HandshakeComplete))), + "Responder should complete handshake" + ); + assert!( + matches!(responder.state, LpState::Transport { .. }), + "Responder should be Transport" + ); + + // --- Transport Phase --- + println!("--- Step 8: Initiator sends data ---"); + let data_to_send_1 = b"hello responder"; + let init_actions_4 = initiator.process_input(LpInput::SendData(data_to_send_1.to_vec())); + let data_packet_1 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_4 { + packet.clone() + } else { + panic!("Initiator should send data packet"); + }; + assert_eq!(data_packet_1.header.session_id(), lp_id); + + println!("--- Step 9: Responder receives data ---"); + let resp_actions_5 = responder.process_input(LpInput::ReceivePacket(data_packet_1)); + let resp_data_1 = if let Some(Ok(LpAction::DeliverData(data))) = resp_actions_5 { + data + } else { + panic!("Responder should deliver data"); + }; + assert_eq!(resp_data_1, Bytes::copy_from_slice(data_to_send_1)); + + println!("--- Step 10: Responder sends data ---"); + let data_to_send_2 = b"hello initiator"; + let resp_actions_6 = responder.process_input(LpInput::SendData(data_to_send_2.to_vec())); + let data_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_6 { + packet.clone() + } else { + panic!("Responder should send data packet"); + }; + assert_eq!(data_packet_2.header.session_id(), lp_id); + + println!("--- Step 11: Initiator receives data ---"); + let init_actions_5 = initiator.process_input(LpInput::ReceivePacket(data_packet_2)); + if let Some(Ok(LpAction::DeliverData(data))) = init_actions_5 { + assert_eq!(data, Bytes::copy_from_slice(data_to_send_2)); + } else { + panic!("Initiator should deliver data"); + } + + // --- Close --- + println!("--- Step 12: Initiator closes ---"); + let init_actions_6 = initiator.process_input(LpInput::Close); + assert!(matches!( + init_actions_6, + Some(Ok(LpAction::ConnectionClosed)) + )); + assert!(matches!(initiator.state, LpState::Closed { .. })); + + println!("--- Step 13: Responder closes ---"); + let resp_actions_7 = responder.process_input(LpInput::Close); + assert!(matches!( + resp_actions_7, + Some(Ok(LpAction::ConnectionClosed)) + )); + assert!(matches!(responder.state, LpState::Closed { .. })); + } + + #[test] + fn test_kkt_exchange_initiator_flow() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([20u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([21u8; 32], 1); + + let salt = [53u8; 32]; + + // Create initiator state machine + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + // Verify initial state + assert!(matches!(initiator.state, LpState::ReadyToHandshake { .. })); + + // Step 1: Initiator starts handshake (should send KKT request) + let init_action = initiator.process_input(LpInput::StartHandshake); + assert!(matches!(init_action, Some(Ok(LpAction::SendPacket(_))))); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + } + + #[test] + fn test_kkt_exchange_responder_flow() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([22u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([23u8; 32], 1); + + let salt = [54u8; 32]; + + // Create responder state machine + let mut responder = LpStateMachine::new( + false, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ) + .unwrap(); + + // Verify initial state + assert!(matches!(responder.state, LpState::ReadyToHandshake { .. })); + + // Step 1: Responder starts handshake (should transition to KKTExchange without sending) + let resp_action = responder.process_input(LpInput::StartHandshake); + assert!(resp_action.is_none()); + assert!(matches!(responder.state, LpState::KKTExchange { .. })); + } + + #[test] + fn test_kkt_exchange_full_roundtrip() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([24u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([25u8; 32], 1); + + let salt = [55u8; 32]; + + // Create both state machines + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + let mut responder = LpStateMachine::new( + false, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ) + .unwrap(); + + // Step 1: Initiator starts handshake, sends KKT request + let init_action = initiator.process_input(LpInput::StartHandshake); + let kkt_request_packet = if let Some(Ok(LpAction::SendPacket(packet))) = init_action { + packet.clone() + } else { + panic!("Initiator should send KKT request"); + }; + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + + // Step 2: Responder transitions to KKTExchange + let resp_action = responder.process_input(LpInput::StartHandshake); + assert!(resp_action.is_none()); + assert!(matches!(responder.state, LpState::KKTExchange { .. })); + + // Step 3: Responder receives KKT request, sends KKT response + let resp_action = responder.process_input(LpInput::ReceivePacket(kkt_request_packet)); + let kkt_response_packet = if let Some(Ok(LpAction::SendPacket(packet))) = resp_action { + packet.clone() + } else { + panic!("Responder should send KKT response"); + }; + // After sending KKT response, responder moves to Handshaking + assert!(matches!(responder.state, LpState::Handshaking { .. })); + + // Step 4: Initiator receives KKT response, completes KKT + let init_action = initiator.process_input(LpInput::ReceivePacket(kkt_response_packet)); + assert!(matches!(init_action, Some(Ok(LpAction::KKTComplete)))); + // After KKT complete, initiator moves to Handshaking + assert!(matches!(initiator.state, LpState::Handshaking { .. })); + } + + #[test] + fn test_kkt_exchange_close() { + // Ed25519 keypairs for KKT authentication + let ed25519_keypair_init = ed25519::KeyPair::from_secret([26u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([27u8; 32], 1); + + let salt = [56u8; 32]; + + // Create initiator state machine + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + // Start handshake to enter KKTExchange state + initiator.process_input(LpInput::StartHandshake); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + + // Close during KKT exchange + let close_action = initiator.process_input(LpInput::Close); + assert!(matches!(close_action, Some(Ok(LpAction::ConnectionClosed)))); + assert!(matches!(initiator.state, LpState::Closed { .. })); + } + + #[test] + fn test_kkt_exchange_rejects_invalid_inputs() { + // Ed25519 keypairs for KKT authentication + let ed25519_keypair_init = ed25519::KeyPair::from_secret([28u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([29u8; 32], 1); + + let salt = [57u8; 32]; + + // Create initiator state machine + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + // Start handshake to enter KKTExchange state + initiator.process_input(LpInput::StartHandshake); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + + // Try SendData during KKT exchange (should be rejected) + let send_action = initiator.process_input(LpInput::SendData(vec![1, 2, 3])); + assert!(matches!( + send_action, + Some(Err(LpError::InvalidStateTransition { .. })) + )); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); // Still in KKTExchange + + // Try StartHandshake again during KKT exchange (should be rejected) + let start_action = initiator.process_input(LpInput::StartHandshake); + assert!(matches!( + start_action, + Some(Err(LpError::InvalidStateTransition { .. })) + )); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); // Still in KKTExchange + } +} diff --git a/common/registration/Cargo.toml b/common/registration/Cargo.toml index 22749ccdc9b..6d4c56e0224 100644 --- a/common/registration/Cargo.toml +++ b/common/registration/Cargo.toml @@ -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 diff --git a/common/registration/src/lib.rs b/common/registration/src/lib.rs index f07ea673ebb..c071e949a51 100644 --- a/common/registration/src/lib.rs +++ b/common/registration/src/lib.rs @@ -1,12 +1,17 @@ // Copyright 2025 - Nym Technologies SA // 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, pub authenticator_address: Option, + pub lp_address: Option, pub version: AuthenticatorVersion, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct GatewayData { pub public_key: PublicKey, pub endpoint: SocketAddr, diff --git a/common/registration/src/lp_messages.rs b/common/registration/src/lp_messages.rs new file mode 100644 index 00000000000..8103e38314f --- /dev/null +++ b/common/registration/src/lp_messages.rs @@ -0,0 +1,270 @@ +// Copyright 2025 - Nym Technologies SA +// 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, + + /// Gateway configuration data (same as returned by authenticator) + /// This matches what WireguardRegistrationResult expects + pub gateway_data: Option, + + /// 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 = 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"), + } + } +} diff --git a/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts b/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts index 0d50d01f558..dcd95efae63 100644 --- a/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts +++ b/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts @@ -36,4 +36,9 @@ custom_http_port: number | null, /** * Base58-encoded ed25519 EdDSA public key. */ -identity_key: string, }; +identity_key: string, +/** + * 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" + */ +lp_address: string | null, }; diff --git a/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs b/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs index 435359efac8..68fd5cdeede 100644 --- a/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs +++ b/common/wireguard-private-metadata/tests/src/v2/peer_controller.rs @@ -26,6 +26,7 @@ impl From<&PeerControlRequest> for PeerControlRequestTypeV2 { fn from(req: &PeerControlRequest) -> Self { match req { PeerControlRequest::AddPeer { .. } => PeerControlRequestTypeV2::AddPeer, + PeerControlRequest::RegisterPeer { .. } => PeerControlRequestTypeV2::AddPeer, PeerControlRequest::RemovePeer { .. } => PeerControlRequestTypeV2::RemovePeer, PeerControlRequest::QueryPeer { .. } => PeerControlRequestTypeV2::QueryPeer, PeerControlRequest::GetClientBandwidthByKey { .. } => { @@ -112,6 +113,15 @@ impl MockPeerControllerV2 { ) .unwrap(); } + PeerControlRequest::RegisterPeer { response_tx, .. } => { + response_tx + .send( + *response + .downcast() + .expect("registered response has mismatched type"), + ) + .unwrap(); + } PeerControlRequest::RemovePeer { response_tx, .. } => { response_tx .send( diff --git a/common/wireguard-types/src/lib.rs b/common/wireguard-types/src/lib.rs index 8f73b404195..455eea38dd5 100644 --- a/common/wireguard-types/src/lib.rs +++ b/common/wireguard-types/src/lib.rs @@ -12,3 +12,5 @@ pub use error::Error; pub use public_key::PeerPublicKey; pub const DEFAULT_PEER_TIMEOUT_CHECK: Duration = Duration::from_secs(5); // 5 seconds +pub const DEFAULT_IP_CLEANUP_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes +pub const DEFAULT_IP_STALE_AGE: Duration = Duration::from_secs(3600); // 1 hour diff --git a/common/wireguard/Cargo.toml b/common/wireguard/Cargo.toml index f2a773d4ec3..7d887b73942 100644 --- a/common/wireguard/Cargo.toml +++ b/common/wireguard/Cargo.toml @@ -15,6 +15,9 @@ base64 = { workspace = true } defguard_wireguard_rs = { workspace = true } futures = { workspace = true } ip_network = { workspace = true } +ipnetwork = { workspace = true } +log.workspace = true +rand = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util"] } tokio-stream = { workspace = true } @@ -25,6 +28,8 @@ 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-ip-packet-requests = { path = "../ip-packet-requests" } +nym-metrics = { path = "../nym-metrics" } nym-network-defaults = { path = "../network-defaults" } nym-task = { path = "../task" } nym-wireguard-types = { path = "../wireguard-types" } diff --git a/common/wireguard/src/error.rs b/common/wireguard/src/error.rs index d240889d4a4..7f5437d6308 100644 --- a/common/wireguard/src/error.rs +++ b/common/wireguard/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { #[error("{0}")] SystemTime(#[from] std::time::SystemTimeError), + + #[error("IP pool error: {0}")] + IpPool(String), } pub type Result = std::result::Result; diff --git a/common/wireguard/src/ip_pool.rs b/common/wireguard/src/ip_pool.rs new file mode 100644 index 00000000000..e1c2b0453f9 --- /dev/null +++ b/common/wireguard/src/ip_pool.rs @@ -0,0 +1,202 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use ipnetwork::IpNetwork; +use nym_ip_packet_requests::IpPair; +use rand::seq::IteratorRandom; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::RwLock; + +/// Represents the state of an IP allocation +#[derive(Debug, Clone, Copy)] +pub enum AllocationState { + /// IP is available for allocation + Free, + /// IP is allocated and in use, with timestamp of allocation + Allocated(SystemTime), +} + +/// Thread-safe IP address pool manager +/// +/// Manages allocation of IPv4/IPv6 address pairs from configured CIDR ranges. +/// Ensures collision-free allocation and supports stale cleanup. +#[derive(Clone)] +pub struct IpPool { + allocations: Arc>>, +} + +impl IpPool { + /// Create a new IP pool from IPv4 and IPv6 CIDR ranges + /// + /// # Arguments + /// * `ipv4_network` - Base IPv4 address for the pool + /// * `ipv4_prefix` - CIDR prefix length for IPv4 (e.g., 16 for /16) + /// * `ipv6_network` - Base IPv6 address for the pool + /// * `ipv6_prefix` - CIDR prefix length for IPv6 (e.g., 112 for /112) + /// + /// # Errors + /// Returns error if CIDR ranges are invalid + pub fn new( + ipv4_network: Ipv4Addr, + ipv4_prefix: u8, + ipv6_network: Ipv6Addr, + ipv6_prefix: u8, + ) -> Result { + let ipv4_net = IpNetwork::new(ipv4_network.into(), ipv4_prefix)?; + let ipv6_net = IpNetwork::new(ipv6_network.into(), ipv6_prefix)?; + + // Build initial pool with all IPs marked as free + let mut allocations = HashMap::new(); + + // Collect IPv4 and IPv6 addresses into vectors for pairing + let ipv4_addrs: Vec = ipv4_net + .iter() + .filter_map(|ip| { + if let IpAddr::V4(v4) = ip { + Some(v4) + } else { + None + } + }) + .collect(); + + let ipv6_addrs: Vec = ipv6_net + .iter() + .filter_map(|ip| { + if let IpAddr::V6(v6) = ip { + Some(v6) + } else { + None + } + }) + .collect(); + + // Create IpPairs by matching IPv4 and IPv6 addresses + // Use the minimum length to avoid index out of bounds + let pair_count = ipv4_addrs.len().min(ipv6_addrs.len()); + for i in 0..pair_count { + let pair = IpPair::new(ipv4_addrs[i], ipv6_addrs[i]); + allocations.insert(pair, AllocationState::Free); + } + + tracing::info!( + "Initialized IP pool with {} address pairs from {}/{} and {}/{}", + allocations.len(), + ipv4_network, + ipv4_prefix, + ipv6_network, + ipv6_prefix + ); + + Ok(IpPool { + allocations: Arc::new(RwLock::new(allocations)), + }) + } + + /// Allocate a free IP pair from the pool + /// + /// Randomly selects an available IP pair and marks it as allocated. + /// + /// # Errors + /// Returns `IpPoolError::NoFreeIp` if no IPs are available + pub async fn allocate(&self) -> Result { + let mut pool = self.allocations.write().await; + + // Find a free IP and allocate it + let free_ip = pool + .iter_mut() + .filter(|(_, state)| matches!(state, AllocationState::Free)) + .choose(&mut rand::thread_rng()) + .ok_or(IpPoolError::NoFreeIp)?; + + let ip_pair = *free_ip.0; + *free_ip.1 = AllocationState::Allocated(SystemTime::now()); + + tracing::debug!("Allocated IP pair: {}", ip_pair); + Ok(ip_pair) + } + + /// Release an IP pair back to the pool + /// + /// Marks the IP as free for future allocations. + pub async fn release(&self, ip_pair: IpPair) { + let mut pool = self.allocations.write().await; + if let Some(state) = pool.get_mut(&ip_pair) { + *state = AllocationState::Free; + tracing::debug!("Released IP pair: {}", ip_pair); + } + } + + /// Mark an IP pair as allocated (used during initialization from database) + /// + /// This is used when restoring state from the database on gateway startup. + pub async fn mark_used(&self, ip_pair: IpPair) { + let mut pool = self.allocations.write().await; + if let Some(state) = pool.get_mut(&ip_pair) { + *state = AllocationState::Allocated(SystemTime::now()); + tracing::debug!("Marked IP pair as used: {}", ip_pair); + } else { + tracing::warn!("Attempted to mark unknown IP pair as used: {}", ip_pair); + } + } + + /// Get the number of free IPs in the pool + pub async fn free_count(&self) -> usize { + let pool = self.allocations.read().await; + pool.iter() + .filter(|(_, state)| matches!(state, AllocationState::Free)) + .count() + } + + /// Get the number of allocated IPs in the pool + pub async fn allocated_count(&self) -> usize { + let pool = self.allocations.read().await; + pool.iter() + .filter(|(_, state)| matches!(state, AllocationState::Allocated(_))) + .count() + } + + /// Get the total pool size + pub async fn total_count(&self) -> usize { + let pool = self.allocations.read().await; + pool.len() + } + + /// Clean up stale allocations older than the specified duration + /// + /// Returns the number of IPs that were freed + pub async fn cleanup_stale(&self, max_age: std::time::Duration) -> usize { + let mut pool = self.allocations.write().await; + let now = SystemTime::now(); + let mut freed = 0; + + for (_ip, state) in pool.iter_mut() { + if let AllocationState::Allocated(allocated_at) = state + && let Ok(age) = now.duration_since(*allocated_at) + && age > max_age + { + *state = AllocationState::Free; + freed += 1; + } + } + + if freed > 0 { + tracing::info!("Cleaned up {} stale IP allocations", freed); + } + + freed + } +} + +/// Errors that can occur during IP pool operations +#[derive(Debug, thiserror::Error)] +pub enum IpPoolError { + #[error("No free IP addresses available in pool")] + NoFreeIp, + + #[error("Invalid IP network configuration: {0}")] + InvalidNetwork(#[from] ipnetwork::IpNetworkError), +} diff --git a/common/wireguard/src/lib.rs b/common/wireguard/src/lib.rs index cf7ff7f32ff..7fda2e11c93 100644 --- a/common/wireguard/src/lib.rs +++ b/common/wireguard/src/lib.rs @@ -9,7 +9,6 @@ use defguard_wireguard_rs::{WGApi, WireguardInterfaceApi, host::Peer, key::Key, net::IpAddrMask}; 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}; use tracing::error; @@ -17,15 +16,23 @@ use tracing::error; #[cfg(target_os = "linux")] use nym_credential_verification::ecash::EcashManager; +#[cfg(target_os = "linux")] +use nym_ip_packet_requests::IpPair; +#[cfg(target_os = "linux")] +use std::net::IpAddr; + #[cfg(target_os = "linux")] use nym_network_defaults::constants::WG_TUN_BASE_NAME; pub mod error; +pub mod ip_pool; pub mod peer_controller; pub mod peer_handle; pub mod peer_storage_manager; pub use error::Error; +pub use ip_pool::{IpPool, IpPoolError}; +pub use peer_controller::{PeerControlRequest, PeerRegistrationData}; pub const CONTROL_CHANNEL_SIZE: usize = 256; @@ -159,29 +166,34 @@ impl WireguardGatewayData { pub struct WireguardData { pub inner: WireguardGatewayData, pub peer_rx: Receiver, + pub use_userspace: bool, } /// Start wireguard device #[cfg(target_os = "linux")] pub async fn start_wireguard( - ecash_manager: Arc, + ecash_manager: Arc, metrics: nym_node_metrics::NymNodeMetrics, peers: Vec, upgrade_mode_status: nym_credential_verification::upgrade_mode::UpgradeModeStatus, shutdown_token: nym_task::ShutdownToken, wireguard_data: WireguardData, + use_userspace: bool, ) -> Result, Box> { use base64::{Engine, prelude::BASE64_STANDARD}; use defguard_wireguard_rs::{InterfaceConfiguration, WireguardInterfaceApi}; use ip_network::IpNetwork; - use nym_credential_verification::ecash::traits::EcashManager; use peer_controller::PeerController; use std::collections::HashMap; use tokio::sync::RwLock; use tracing::info; let ifname = String::from(WG_TUN_BASE_NAME); - let wg_api = defguard_wireguard_rs::WGApi::new(ifname.clone(), false)?; + info!( + "Initializing WireGuard interface '{}' with use_userspace={}", + ifname, use_userspace + ); + let wg_api = defguard_wireguard_rs::WGApi::new(ifname.clone(), use_userspace)?; let mut peer_bandwidth_managers = HashMap::with_capacity(peers.len()); for peer in peers.iter() { @@ -204,7 +216,7 @@ pub async fn start_wireguard( prvkey: BASE64_STANDARD.encode(wireguard_data.inner.keypair().private_key().to_bytes()), address: wireguard_data.inner.config().private_ipv4.to_string(), port: wireguard_data.inner.config().announced_tunnel_port as u32, - peers, + peers: peers.clone(), // Clone since we need to use peers later to mark IPs as used mtu: None, }; info!( @@ -212,7 +224,13 @@ pub async fn start_wireguard( interface_config.address, interface_config.port ); - wg_api.configure_interface(&interface_config)?; + info!("Configuring WireGuard interface..."); + wg_api.configure_interface(&interface_config).map_err(|e| { + log::error!("Failed to configure WireGuard interface: {:?}", e); + e + })?; + + info!("Adding IPv6 address to interface..."); std::process::Command::new("ip") .args([ "-6", @@ -226,7 +244,11 @@ pub async fn start_wireguard( "dev", (&ifname), ]) - .output()?; + .output() + .map_err(|e| { + log::error!("Failed to add IPv6 address: {:?}", e); + e + })?; // Use a dummy peer to create routing rule for the entire network space let mut catch_all_peer = Peer::new(Key::new([0; 32])); @@ -247,9 +269,38 @@ pub async fn start_wireguard( let host = wg_api.read_interface_data()?; let wg_api = std::sync::Arc::new(WgApiWrapper::new(wg_api)); + // Initialize IP pool from configuration + info!("Initializing IP pool for WireGuard peer allocation"); + let ip_pool = IpPool::new( + wireguard_data.inner.config().private_ipv4, + wireguard_data.inner.config().private_network_prefix_v4, + wireguard_data.inner.config().private_ipv6, + wireguard_data.inner.config().private_network_prefix_v6, + )?; + + // Mark existing peer IPs as used in the pool + for peer in &peers { + for allowed_ip in &peer.allowed_ips { + // Extract IPv4 and IPv6 from peer's allowed_ips + if let IpAddr::V4(ipv4) = allowed_ip.ip { + // Find corresponding IPv6 + if let Some(ipv6_mask) = peer + .allowed_ips + .iter() + .find(|ip| matches!(ip.ip, IpAddr::V6(_))) + { + if let IpAddr::V6(ipv6) = ipv6_mask.ip { + ip_pool.mark_used(IpPair::new(ipv4, ipv6)).await; + } + } + } + } + } + let mut controller = PeerController::new( ecash_manager, metrics, + ip_pool, wg_api.clone(), host, peer_bandwidth_managers, diff --git a/common/wireguard/src/peer_controller.rs b/common/wireguard/src/peer_controller.rs index 54b208c5afc..04011603e15 100644 --- a/common/wireguard/src/peer_controller.rs +++ b/common/wireguard/src/peer_controller.rs @@ -20,22 +20,68 @@ use nym_credential_verification::{ use nym_credentials_interface::CredentialSpendingData; use nym_gateway_requests::models::CredentialSpendingRequest; use nym_gateway_storage::traits::BandwidthGatewayStorage; +use nym_ip_packet_requests::IpPair; use nym_node_metrics::NymNodeMetrics; -use nym_wireguard_types::DEFAULT_PEER_TIMEOUT_CHECK; +use nym_wireguard_types::{ + DEFAULT_IP_CLEANUP_INTERVAL, DEFAULT_IP_STALE_AGE, DEFAULT_PEER_TIMEOUT_CHECK, +}; use std::{collections::HashMap, sync::Arc}; use std::{ - net::IpAddr, + net::{IpAddr, SocketAddr}, time::{Duration, SystemTime}, }; use tokio::sync::{RwLock, mpsc}; use tokio_stream::{StreamExt, wrappers::IntervalStream}; use tracing::{debug, error, info, trace}; +use crate::ip_pool::IpPool; + +/// Registration data for a new peer (without pre-allocated IPs) +#[derive(Debug, Clone)] +pub struct PeerRegistrationData { + pub public_key: Key, + pub preshared_key: Option, + pub endpoint: Option, + pub persistent_keepalive_interval: Option, +} + +impl PeerRegistrationData { + pub fn new(public_key: Key) -> Self { + Self { + public_key, + preshared_key: None, + endpoint: None, + persistent_keepalive_interval: None, + } + } + + pub fn with_preshared_key(mut self, key: Key) -> Self { + self.preshared_key = Some(key); + self + } + + pub fn with_endpoint(mut self, endpoint: SocketAddr) -> Self { + self.endpoint = Some(endpoint); + self + } + + pub fn with_keepalive(mut self, interval: u16) -> Self { + self.persistent_keepalive_interval = Some(interval); + self + } +} + pub enum PeerControlRequest { + /// Add a peer with pre-allocated IPs (for backwards compatibility) AddPeer { peer: Peer, response_tx: oneshot::Sender, }, + /// Register a new peer and allocate IPs from the pool + RegisterPeer { + registration_data: PeerRegistrationData, + response_tx: oneshot::Sender, + }, RemovePeer { key: Key, response_tx: oneshot::Sender, @@ -65,6 +111,7 @@ pub enum PeerControlRequest { } pub type AddPeerControlResponse = Result<()>; +pub type RegisterPeerControlResponse = Result; pub type RemovePeerControlResponse = Result<()>; pub type QueryPeerControlResponse = Result>; pub type GetClientBandwidthControlResponse = Result; @@ -77,6 +124,9 @@ pub struct PeerController { // so the overhead is minimal metrics: NymNodeMetrics, + // IP address pool for peer allocation + ip_pool: IpPool, + // used to receive commands from individual handles too request_tx: mpsc::Sender, request_rx: mpsc::Receiver, @@ -84,6 +134,7 @@ pub struct PeerController { host_information: Arc>, bw_storage_managers: HashMap, timeout_check_interval: IntervalStream, + ip_cleanup_interval: IntervalStream, /// Flag indicating whether the system is undergoing an upgrade and thus peers shouldn't be getting /// their bandwidth metered. @@ -96,6 +147,7 @@ impl PeerController { pub(crate) fn new( ecash_verifier: Arc, metrics: NymNodeMetrics, + ip_pool: IpPool, wg_api: Arc, initial_host_information: Host, bw_storage_managers: HashMap, @@ -106,6 +158,8 @@ impl PeerController { ) -> Self { let timeout_check_interval = IntervalStream::new(tokio::time::interval(DEFAULT_PEER_TIMEOUT_CHECK)); + let ip_cleanup_interval = + IntervalStream::new(tokio::time::interval(DEFAULT_IP_CLEANUP_INTERVAL)); let host_information = Arc::new(RwLock::new(initial_host_information)); for (public_key, (bandwidth_storage_manager, peer)) in bw_storage_managers.iter() { let cached_peer_manager = CachedPeerManager::new(peer); @@ -131,20 +185,24 @@ impl PeerController { PeerController { ecash_verifier, + metrics, + ip_pool, wg_api, host_information, bw_storage_managers, request_tx, request_rx, timeout_check_interval, + ip_cleanup_interval, upgrade_mode, shutdown_token, - metrics, } } // 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()) @@ -152,9 +210,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"); 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?) } @@ -184,7 +245,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) @@ -213,9 +282,34 @@ impl PeerController { handle.run().await; debug!("Peer handle shut down for {public_key}"); }); + + nym_metrics::inc!("wg_peer_addition_success"); Ok(()) } + /// Allocate IP pair from pool for a new peer registration + /// + /// This only allocates IPs - the caller must handle database storage and + /// then call AddPeer with a complete Peer struct. + async fn handle_register_request( + &mut self, + _registration_data: PeerRegistrationData, + ) -> Result { + nym_metrics::inc!("wg_ip_allocation_attempts"); + + // Allocate IP pair from pool + let ip_pair = self + .ip_pool + .allocate() + .await + .map_err(|e| Error::IpPool(e.to_string()))?; + + nym_metrics::inc!("wg_ip_allocation_success"); + tracing::debug!("Allocated IP pair: {}", ip_pair); + + Ok(ip_pair) + } + async fn ip_to_key(&self, ip: IpAddr) -> Result> { Ok(self .bw_storage_managers @@ -393,6 +487,14 @@ impl PeerController { *self.host_information.write().await = host; } + _ = self.ip_cleanup_interval.next() => { + // Periodically cleanup stale IP allocations + let freed = self.ip_pool.cleanup_stale(DEFAULT_IP_STALE_AGE).await; + if freed > 0 { + nym_metrics::inc_by!("wg_stale_ips_cleaned", freed as u64); + log::info!("Cleaned up {} stale IP allocations", freed); + } + } _ = self.shutdown_token.cancelled() => { trace!("PeerController handler: Received shutdown"); break; @@ -402,6 +504,9 @@ impl PeerController { Some(PeerControlRequest::AddPeer { peer, response_tx }) => { response_tx.send(self.handle_add_request(&peer).await).ok(); } + Some(PeerControlRequest::RegisterPeer { registration_data, response_tx }) => { + response_tx.send(self.handle_register_request(registration_data).await).ok(); + } Some(PeerControlRequest::RemovePeer { key, response_tx }) => { response_tx.send(self.remove_peer(&key).await).ok(); } @@ -528,6 +633,7 @@ pub fn start_controller( Arc>, nym_task::ShutdownManager, ) { + use std::net::{Ipv4Addr, Ipv6Addr}; use std::sync::Arc; let storage = Arc::new(RwLock::new( @@ -537,10 +643,22 @@ pub fn start_controller( Box::new(storage.clone()), )); let wg_api = Arc::new(MockWgApi::default()); + + // Create IP pool for testing + #[allow(clippy::expect_used)] + let ip_pool = IpPool::new( + Ipv4Addr::new(10, 0, 0, 0), + 24, + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0), + 112, + ) + .expect("Failed to create IP pool for testing"); + let shutdown_manager = nym_task::ShutdownManager::empty_mock(); let mut peer_controller = PeerController::new( ecash_manager, Default::default(), + ip_pool, wg_api, Default::default(), Default::default(), @@ -562,8 +680,7 @@ pub async fn stop_controller(mut shutdown_manager: nym_task::ShutdownManager) { shutdown_manager.run_until_shutdown().await; } -#[cfg(test)] -#[cfg(feature = "mock")] +#[cfg(all(test, feature = "mock"))] mod tests { use super::*; diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 42e5e1352a2..85f258b9c12 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1158,10 +1158,12 @@ version = "0.4.0" dependencies = [ "base64 0.22.1", "bs58", + "curve25519-dalek", "ed25519-dalek", "nym-pemstore", "nym-sphinx-types", "rand", + "sha2", "subtle-encoding", "thiserror 2.0.12", "x25519-dalek", @@ -1795,9 +1797,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index 1508ac15484..ed5af774a08 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -967,6 +967,7 @@ pub mod test_helpers { host: "1.2.3.4".to_string(), custom_http_port: None, identity_key, + lp_address: None, }; let msg = nymnode_bonding_sign_payload(self.deps(), sender, node.clone(), stake); let owner_signature = ed25519_sign_message(msg, keypair.private_key()); diff --git a/contracts/performance/src/testing/mod.rs b/contracts/performance/src/testing/mod.rs index 33e90beaf3d..4f943eca75e 100644 --- a/contracts/performance/src/testing/mod.rs +++ b/contracts/performance/src/testing/mod.rs @@ -446,6 +446,7 @@ pub(crate) trait PerformanceContractTesterExt: host: "1.2.3.4".to_string(), custom_http_port: None, identity_key, + lp_address: None, }; let cost_params = NodeCostParams { profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) diff --git a/docker/localnet/Dockerfile.localnet b/docker/localnet/Dockerfile.localnet new file mode 100644 index 00000000000..9c1f6a30afb --- /dev/null +++ b/docker/localnet/Dockerfile.localnet @@ -0,0 +1,53 @@ +# 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 including Go for wireguard-go +RUN apt update && apt install -y \ + python3 \ + python3-pip \ + netcat-openbsd \ + jq \ + iproute2 \ + net-tools \ + wireguard-tools \ + golang-go \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install wireguard-go (userspace WireGuard implementation) +RUN git clone https://git.zx2c4.com/wireguard-go && \ + cd wireguard-go && \ + make && \ + cp wireguard-go /usr/local/bin/ && \ + cd .. && \ + rm -rf wireguard-go + +# 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"] diff --git a/docker/localnet/README.md b/docker/localnet/README.md new file mode 100644 index 00000000000..19a2f37a5e8 --- /dev/null +++ b/docker/localnet/README.md @@ -0,0 +1,645 @@ +# 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 +- 41264 → LP control port (registration) +- 51264 → LP data port + +### 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 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 +``` + +### LP (Lewes Protocol) Testing + +The gateway is configured with LP listener enabled and **mock ecash verification** for testing: + +```bash +# LP listener ports (exposed on host): +# - 41264: LP control port (TCP registration) +# - 51264: LP data port + +# Check LP ports are listening +nc -zv localhost 41264 +nc -zv localhost 51264 + +# Test LP registration with nym-gateway-probe +cargo run -p nym-gateway-probe run-local \ + --mnemonic "test mnemonic here" \ + --gateway-ip 'localhost:41264' \ + --only-lp-registration +``` + +**Mock Ecash Mode**: +- Gateway uses `--lp.use-mock-ecash true` flag +- Accepts ANY bandwidth credential without blockchain verification +- Perfect for testing LP protocol implementation +- **WARNING**: Never use mock ecash in production! + +**Testing without blockchain**: +The mock ecash manager allows testing the complete LP registration flow without requiring: +- Running nyxd blockchain +- Deploying smart contracts +- Acquiring real bandwidth credentials +- Setting up coconut signers + +This makes localnet perfect for rapid LP protocol development and testing. + +## 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. diff --git a/docker/localnet/build_topology.py b/docker/localnet/build_topology.py new file mode 100644 index 00000000000..88c2bfe8139 --- /dev/null +++ b/docker/localnet/build_topology.py @@ -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 [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:]) diff --git a/docker/localnet/localnet-logs.sh b/docker/localnet/localnet-logs.sh new file mode 100755 index 00000000000..3347943e096 --- /dev/null +++ b/docker/localnet/localnet-logs.sh @@ -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 diff --git a/docker/localnet/localnet.sh b/docker/localnet/localnet.sh new file mode 100755 index 00000000000..03478fa55eb --- /dev/null +++ b/docker/localnet/localnet.sh @@ -0,0 +1,619 @@ +#!/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 \ + -p 41264:41264 \ + -p 51264:51264 \ + -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 \ + --enable-lp true \ + --lp-use-mock-ecash true \ + --output=json \ + --wireguard-enabled true \ + --wireguard-userspace true \ + --bonding-information-output="/localnet/gateway.json"; + + echo "Waiting for network.json..."; + while [ ! -f /localnet/network.json ]; do + sleep 2; + done; + echo "Starting gateway with LP listener (mock ecash)..."; + exec nym-node run --id gateway-localnet --unsafe-disable-replay-protection --local --wireguard-enabled true --wireguard-userspace true --lp-use-mock-ecash true + ' + + 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:" + echo " Mixnet:" + for port in 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 + echo " Gateway:" + for port in 9000 30004; 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 + echo " LP (Lewes Protocol):" + for port in 41264 51264; 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 + echo " SOCKS5:" + if nc -z 127.0.0.1 1080 2>/dev/null; then + echo -e " ${GREEN}●${NC} Port 1080 - listening" + else + echo -e " ${RED}○${NC} Port 1080 - not listening" + fi +} + +# 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 < [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 "$@" diff --git a/docs/LP_DEPLOYMENT.md b/docs/LP_DEPLOYMENT.md new file mode 100644 index 00000000000..a3b88c79fdb --- /dev/null +++ b/docs/LP_DEPLOYMENT.md @@ -0,0 +1,845 @@ +# LP (Lewes Protocol) Deployment Guide + +## Prerequisites + +### System Requirements + +**Minimum:** +- CPU: 2 cores (x86_64 or ARM64) +- RAM: 4 GB +- Network: 100 Mbps +- Disk: 20 GB SSD + +**Recommended:** +- CPU: 4+ cores with AVX2/NEON support (for SIMD optimizations) +- RAM: 8+ GB +- Network: 1 Gbps +- Disk: 50+ GB NVMe SSD + +### Software Dependencies + +```bash +# Ubuntu/Debian +sudo apt-get update +sudo apt-get install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + postgresql \ + wireguard + +# macOS +brew install \ + postgresql \ + wireguard-tools +``` + +## Gateway Setup + +### 1. Enable LP in Configuration + +Edit your gateway configuration file (typically `~/.nym/gateways//config/config.toml`): + +```toml +[lp] +# Enable the LP listener +enabled = true + +# Bind address (0.0.0.0 for all interfaces, 127.0.0.1 for localhost only) +bind_address = "0.0.0.0" + +# Control port for LP handshake and registration +control_port = 41264 + +# Data port (reserved for future use, not currently used) +data_port = 51264 + +# Maximum concurrent LP connections +# Adjust based on expected load and available memory (~5 KB per connection) +max_connections = 10000 + +# Timestamp tolerance in seconds +# ClientHello messages with timestamps outside this window are rejected +# Balance security (smaller window) vs clock skew tolerance (larger window) +timestamp_tolerance_secs = 30 + +# IMPORTANT: ONLY for testing! Never enable in production +use_mock_ecash = false +``` + +### 2. Network Configuration + +#### Firewall Rules + +```bash +# Allow LP control port +sudo ufw allow 41264/tcp comment 'Nym LP control port' + +# Optional: Rate limiting using iptables +sudo iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --set --name LP_CONN_LIMIT + +sudo iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --update --seconds 60 --hitcount 100 --name LP_CONN_LIMIT \ + -j DROP +``` + +#### NAT/Port Forwarding + +If your gateway is behind NAT, forward port 41264: + +```bash +# Example for router at 192.168.1.1 +# Forward external:41264 -> internal:41264 (TCP) + +# Verify with: +nc -zv 41264 +``` + +### 3. LP Keypair Generation + +LP uses separate keypairs from the gateway's main identity. Generate on first run: + +```bash +# Start gateway (will auto-generate LP keypair if missing) +./nym-node run --mode gateway --id + +# LP keypair stored at: +# ~/.nym/gateways//keys/lp_x25519.pem +``` + +**Key Storage Security:** + +```bash +# Restrict key file permissions +chmod 600 ~/.nym/gateways//keys/lp_x25519.pem + +# Backup keys securely (encrypted) +gpg -c ~/.nym/gateways//keys/lp_x25519.pem +# Store lp_x25519.pem.gpg in secure location +``` + +### 4. Database Configuration + +LP requires PostgreSQL for credential tracking: + +```bash +# Create database +sudo -u postgres createdb nym_gateway + +# Create user +sudo -u postgres psql -c "CREATE USER nym_gateway WITH PASSWORD 'strong_password';" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE nym_gateway TO nym_gateway;" + +# Configure in gateway config +[storage] +database_url = "postgresql://nym_gateway:strong_password@localhost/nym_gateway" +``` + +**Database Maintenance:** + +```sql +-- Index for nullifier lookups (critical for performance) +CREATE INDEX idx_nullifiers ON spent_credentials(nullifier); + +-- Periodic cleanup of old nullifiers (run daily via cron) +DELETE FROM spent_credentials WHERE expiry < NOW() - INTERVAL '30 days'; + +-- Vacuum to reclaim space +VACUUM ANALYZE spent_credentials; +``` + +### 5. WireGuard Configuration (for dVPN mode) + +```bash +# Enable WireGuard kernel module +sudo modprobe wireguard + +# Verify loaded +lsmod | grep wireguard + +# Generate gateway WireGuard keys +wg genkey | tee wg_private.key | wg pubkey > wg_public.key +chmod 600 wg_private.key + +# Configure in gateway config +[wireguard] +enabled = true +private_key_path = "/path/to/wg_private.key" +listen_port = 51820 +interface_name = "wg-nym" +subnet = "10.0.0.0/8" +``` + +**WireGuard Interface Setup:** + +```bash +# Create interface +sudo ip link add dev wg-nym type wireguard + +# Configure interface +sudo ip addr add 10.0.0.1/8 dev wg-nym +sudo ip link set wg-nym up + +# Enable IP forwarding +sudo sysctl -w net.ipv4.ip_forward=1 +echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf + +# NAT for WireGuard clients +sudo iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -o eth0 -j MASQUERADE +``` + +### 6. Monitoring Setup + +#### Prometheus Metrics + +LP exposes metrics on the gateway's metrics endpoint (default: `:8080/metrics`): + +```yaml +# prometheus.yml +scrape_configs: + - job_name: 'nym-gateway-lp' + static_configs: + - targets: ['gateway-host:8080'] + metric_relabel_configs: + # Focus on LP metrics + - source_labels: [__name__] + regex: 'lp_.*' + action: keep +``` + +**Key Metrics:** + +```promql +# Connection metrics +nym_gateway_active_lp_connections # Current active connections +rate(nym_gateway_lp_connections_total[5m]) # Connection rate +rate(nym_gateway_lp_connections_completed_with_error[5m]) # Error rate + +# Handshake metrics +rate(nym_gateway_lp_handshakes_success[5m]) +rate(nym_gateway_lp_handshakes_failed[5m]) +histogram_quantile(0.95, nym_gateway_lp_handshake_duration_seconds) + +# Registration metrics +rate(nym_gateway_lp_registration_success_total[5m]) +rate(nym_gateway_lp_registration_failed_total[5m]) +histogram_quantile(0.95, nym_gateway_lp_registration_duration_seconds) + +# Credential metrics +rate(nym_gateway_lp_credential_verification_failed[5m]) +nym_gateway_lp_bandwidth_allocated_bytes_total + +# Error metrics +rate(nym_gateway_lp_errors_handshake[5m]) +rate(nym_gateway_lp_errors_timestamp_too_old[5m]) +rate(nym_gateway_lp_errors_wg_peer_registration[5m]) +``` + +#### Grafana Dashboard + +Import dashboard JSON (create and export after setup): + +```json +{ + "dashboard": { + "title": "Nym Gateway - LP Protocol", + "panels": [ + { + "title": "Active Connections", + "targets": [ + { + "expr": "nym_gateway_active_lp_connections" + } + ] + }, + { + "title": "Registration Success Rate", + "targets": [ + { + "expr": "rate(nym_gateway_lp_registration_success_total[5m]) / (rate(nym_gateway_lp_registration_success_total[5m]) + rate(nym_gateway_lp_registration_failed_total[5m]))" + } + ] + } + ] + } +} +``` + +#### Alert Rules + +```yaml +# alerting_rules.yml +groups: + - name: lp_alerts + interval: 30s + rules: + # High connection rejection rate + - alert: LPHighRejectionRate + expr: rate(nym_gateway_lp_connections_completed_with_error[5m]) > 10 + for: 5m + labels: + severity: warning + annotations: + summary: "High LP connection rejection rate" + description: "Gateway {{ $labels.instance }} rejecting {{ $value }} connections/sec" + + # Handshake failure rate > 5% + - alert: LPHandshakeFailures + expr: | + rate(nym_gateway_lp_handshakes_failed[5m]) / + (rate(nym_gateway_lp_handshakes_success[5m]) + rate(nym_gateway_lp_handshakes_failed[5m])) + > 0.05 + for: 10m + labels: + severity: warning + annotations: + summary: "High LP handshake failure rate" + + # Credential verification issues + - alert: LPCredentialVerificationFailures + expr: rate(nym_gateway_lp_credential_verification_failed[5m]) > 50 + for: 5m + labels: + severity: critical + annotations: + summary: "High credential verification failure rate" + + # High latency + - alert: LPHighLatency + expr: histogram_quantile(0.95, nym_gateway_lp_registration_duration_seconds) > 5 + for: 10m + labels: + severity: warning + annotations: + summary: "LP registration latency is high" +``` + +## Client Configuration + +### 1. Obtain Gateway LP Public Key + +```bash +# Query gateway descriptor +curl https://validator.nymtech.net/api/v1/gateways/ + +# Extract LP public key from response +{ + "gateway": { + "identity_key": "...", + "lp_public_key": "base64-encoded-x25519-public-key", + "host": "1.2.3.4", + "lp_port": 41264 + } +} +``` + +### 2. Initialize Registration Client + +```rust +use nym_registration_client::{RegistrationClient, RegistrationMode}; + +// Create client +let mut client = RegistrationClient::builder() + .gateway_identity("gateway-identity-key") + .gateway_lp_public_key(gateway_lp_pubkey) + .gateway_lp_address("1.2.3.4:41264") + .mode(RegistrationMode::Lp) + .build()?; + +// Perform registration +let result = client.register_lp( + credential, // E-cash credential + RegistrationMode::Dvpn { + wg_public_key: client_wg_pubkey, + } +).await?; + +match result { + LpRegistrationResult::Success { gateway_data, .. } => { + // Use gateway_data to configure WireGuard tunnel + } + LpRegistrationResult::Error { code, message } => { + eprintln!("Registration failed: {}", message); + } +} +``` + +## Testing + +### Local Testing Environment + +#### 1. Start Mock Gateway + +```bash +# Use mock e-cash verifier (accepts any credential) +export LP_USE_MOCK_ECASH=true + +# Start gateway in dev mode +./nym-node run --mode gateway --id test-gateway +``` + +#### 2. Test LP Connection + +```bash +# Test TCP connectivity +nc -zv localhost 41264 + +# Test with openssl (basic TLS check - won't work as LP uses Noise) +timeout 5 openssl s_client -connect localhost:41264 < /dev/null +# Expected: Connection closes (Noise != TLS) +``` + +#### 3. Run Integration Tests + +```bash +# Run full LP registration test suite +cargo test --test lp_integration -- --nocapture + +# Run specific test +cargo test --test lp_integration test_dvpn_registration_success +``` + +### Production Testing + +#### Health Check Script + +```bash +#!/bin/bash +# lp_health_check.sh + +GATEWAY_HOST="${1:-localhost}" +GATEWAY_PORT="${2:-41264}" + +# Check TCP connectivity +if ! timeout 5 nc -zv "$GATEWAY_HOST" "$GATEWAY_PORT" 2>&1 | grep -q succeeded; then + echo "CRITICAL: Cannot connect to LP port $GATEWAY_PORT" + exit 2 +fi + +# Check metrics endpoint +ACTIVE_CONNS=$(curl -s "http://$GATEWAY_HOST:8080/metrics" | \ + grep "^nym_gateway_active_lp_connections" | awk '{print $2}') + +if [ -z "$ACTIVE_CONNS" ]; then + echo "WARNING: Cannot read metrics" + exit 1 +fi + +echo "OK: LP listener responding, $ACTIVE_CONNS active connections" +exit 0 +``` + +#### Load Testing + +```bash +# Install tool +cargo install --git https://github.com/nymtech/nym tools/nym-lp-load-test + +# Run load test (1000 concurrent registrations) +nym-lp-load-test \ + --gateway "1.2.3.4:41264" \ + --gateway-pubkey "base64-key" \ + --concurrent 1000 \ + --duration 60s +``` + +## Troubleshooting + +### Connection Refused + +**Symptom:** `Connection refused` when connecting to port 41264 + +**Diagnosis:** +```bash +# Check if LP listener is running +sudo netstat -tlnp | grep 41264 + +# Check gateway logs +journalctl -u nym-gateway -f | grep LP + +# Check firewall +sudo ufw status | grep 41264 +``` + +**Solutions:** +1. Ensure `lp.enabled = true` in config +2. Check bind address (`0.0.0.0` vs `127.0.0.1`) +3. Open firewall port: `sudo ufw allow 41264/tcp` +4. Restart gateway after config changes + +### Handshake Failures + +**Symptom:** `lp_handshakes_failed` metric increasing + +**Diagnosis:** +```bash +# Check error logs +journalctl -u nym-gateway | grep "LP.*handshake.*failed" + +# Common errors: +# - "Noise decryption error" → Wrong keys or MITM +# - "Timestamp too old" → Clock skew > 30s +# - "Replay detected" → Duplicate connection attempt +``` + +**Solutions:** +1. **Noise errors**: Verify client has correct gateway LP public key +2. **Timestamp errors**: Sync clocks with NTP + ```bash + sudo timedatectl set-ntp true + sudo timedatectl status + ``` +3. **Replay errors**: Check for connection retry logic creating duplicates + +### Credential Verification Failures + +**Symptom:** `lp_credential_verification_failed` metric high + +**Diagnosis:** +```bash +# Check database connectivity +psql -U nym_gateway -d nym_gateway -c "SELECT COUNT(*) FROM spent_credentials;" + +# Check ecash manager logs +journalctl -u nym-gateway | grep -i credential +``` + +**Solutions:** +1. **Database errors**: Check PostgreSQL is running and accessible +2. **Signature errors**: Verify ecash contract address is correct +3. **Expired credentials**: Client needs to obtain fresh credentials +4. **Nullifier collision**: Credential already used (check `spent_credentials` table) + +### High Latency + +**Symptom:** `lp_registration_duration_seconds` p95 > 5 seconds + +**Diagnosis:** +```bash +# Check database query performance +psql -U nym_gateway -d nym_gateway -c "EXPLAIN ANALYZE SELECT * FROM spent_credentials WHERE nullifier = 'test';" + +# Check system load +top -bn1 | head -20 +iostat -x 1 5 +``` + +**Solutions:** +1. **Database slow**: Add index on nullifier column + ```sql + CREATE INDEX CONCURRENTLY idx_nullifiers ON spent_credentials(nullifier); + ``` +2. **CPU bound**: Check if SIMD is enabled + ```bash + # Check for AVX2 support + grep avx2 /proc/cpuinfo + # Rebuild with target-cpu=native + RUSTFLAGS="-C target-cpu=native" cargo build --release + ``` +3. **Network latency**: Check RTT to gateway + ```bash + ping -c 10 gateway-host + mtr gateway-host + ``` + +### Connection Limit Reached + +**Symptom:** `lp_connections_completed_with_error` high, logs show "connection limit exceeded" + +**Diagnosis:** +```bash +# Check active connections +curl -s http://localhost:8080/metrics | grep active_lp_connections + +# Check system limits +ulimit -n # File descriptors per process +sysctl net.ipv4.ip_local_port_range +``` + +**Solutions:** +1. **Increase max_connections** in config: + ```toml + [lp] + max_connections = 20000 # Increased from 10000 + ``` +2. **Increase system limits**: + ```bash + # /etc/security/limits.conf + nym-gateway soft nofile 65536 + nym-gateway hard nofile 65536 + + # /etc/sysctl.conf + net.ipv4.ip_local_port_range = 1024 65535 + net.core.somaxconn = 4096 + + # Apply + sudo sysctl -p + ``` +3. **Check for connection leaks**: + ```bash + # Connections in CLOSE_WAIT (indicates app not closing properly) + netstat -an | grep 41264 | grep CLOSE_WAIT | wc -l + ``` + +## Performance Tuning + +### TCP Tuning + +```bash +# /etc/sysctl.conf - Optimize for many concurrent connections + +# Increase max backlog +net.core.somaxconn = 4096 +net.ipv4.tcp_max_syn_backlog = 8192 + +# Faster TCP timeouts +net.ipv4.tcp_fin_timeout = 15 +net.ipv4.tcp_keepalive_time = 300 +net.ipv4.tcp_keepalive_probes = 5 +net.ipv4.tcp_keepalive_intvl = 15 + +# Optimize buffer sizes +net.core.rmem_max = 134217728 +net.core.wmem_max = 134217728 +net.ipv4.tcp_rmem = 4096 87380 67108864 +net.ipv4.tcp_wmem = 4096 65536 67108864 + +# Enable TCP Fast Open +net.ipv4.tcp_fastopen = 3 + +# Apply +sudo sysctl -p +``` + +### SIMD Optimization + +Ensure gateway is built with CPU-specific optimizations: + +```bash +# Check current CPU features +rustc --print target-features + +# Build with native CPU features (enables AVX2, SSE4, etc.) +RUSTFLAGS="-C target-cpu=native" cargo build --release -p nym-node + +# Verify SIMD is used (check binary for AVX2 instructions) +objdump -d target/release/nym-node | grep vpmovzxbw | wc -l +# Non-zero result means AVX2 is being used +``` + +### Database Optimization + +```sql +-- Analyze query performance +EXPLAIN ANALYZE SELECT * FROM spent_credentials WHERE nullifier = 'xyz'; + +-- Essential indexes +CREATE INDEX CONCURRENTLY idx_spent_credentials_nullifier ON spent_credentials(nullifier); +CREATE INDEX CONCURRENTLY idx_spent_credentials_expiry ON spent_credentials(expiry); + +-- Optimize PostgreSQL config (postgresql.conf) +-- Adjust based on available RAM +shared_buffers = 2GB # 25% of RAM +effective_cache_size = 6GB # 75% of RAM +maintenance_work_mem = 512MB +work_mem = 64MB +max_connections = 200 + +-- Enable query planning optimizations +random_page_cost = 1.1 # SSD-optimized +effective_io_concurrency = 200 # SSD-optimized + +-- Restart PostgreSQL after config changes +sudo systemctl restart postgresql +``` + +## Security Hardening + +### 1. Principle of Least Privilege + +```bash +# Run gateway as dedicated user (not root) +sudo useradd -r -s /bin/false nym-gateway + +# Set file ownership +sudo chown -R nym-gateway:nym-gateway /home/nym-gateway/.nym + +# Systemd service with restrictions +[Service] +User=nym-gateway +Group=nym-gateway +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/home/nym-gateway/.nym +``` + +### 2. TLS for Metrics Endpoint + +```bash +# Use reverse proxy (nginx) for metrics +server { + listen 443 ssl http2; + server_name metrics.your-gateway.com; + + ssl_certificate /etc/letsencrypt/live/metrics.your-gateway.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/metrics.your-gateway.com/privkey.pem; + + location /metrics { + proxy_pass http://127.0.0.1:8080/metrics; + # Authentication + auth_basic "Metrics"; + auth_basic_user_file /etc/nginx/.htpasswd; + } +} +``` + +### 3. Key Rotation + +```bash +# Generate new LP keypair +./nym-node generate-lp-keypair --output new_lp_key.pem + +# Atomic key swap (minimizes downtime) +# 1. Stop gateway gracefully +systemctl stop nym-gateway + +# 2. Backup old key +cp ~/.nym/gateways//keys/lp_x25519.pem ~/.nym/gateways//keys/lp_x25519.pem.backup + +# 3. Install new key +mv new_lp_key.pem ~/.nym/gateways//keys/lp_x25519.pem +chmod 600 ~/.nym/gateways//keys/lp_x25519.pem + +# 4. Restart gateway +systemctl start nym-gateway + +# 5. Update gateway descriptor (publishes new public key) +# This happens automatically on restart +``` + +## Maintenance + +### Regular Tasks + +**Daily:** +- Monitor metrics for anomalies +- Check error logs for new patterns +- Verify disk space for database growth + +**Weekly:** +- Vacuum database to reclaim space + ```sql + VACUUM ANALYZE spent_credentials; + ``` +- Review and archive old logs + ```bash + journalctl --vacuum-time=7d + ``` + +**Monthly:** +- Update dependencies (security patches) + ```bash + cargo update + cargo audit + cargo build --release + ``` +- Backup configuration and keys +- Review and update alert thresholds based on traffic patterns + +**Quarterly:** +- Key rotation (if security policy requires) +- Performance review and capacity planning +- Security audit of configuration + +### Backup Procedure + +```bash +#!/bin/bash +# backup_lp.sh + +BACKUP_DIR="/backup/nym-gateway/$(date +%Y%m%d)" +mkdir -p "$BACKUP_DIR" + +# Backup keys +cp -r ~/.nym/gateways//keys "$BACKUP_DIR/" + +# Backup config +cp ~/.nym/gateways//config/config.toml "$BACKUP_DIR/" + +# Backup database +pg_dump -U nym_gateway nym_gateway | gzip > "$BACKUP_DIR/database.sql.gz" + +# Encrypt and upload +tar -czf - "$BACKUP_DIR" | gpg -c | aws s3 cp - s3://backups/nym-gateway-$(date +%Y%m%d).tar.gz.gpg +``` + +### Upgrade Procedure + +```bash +# 1. Backup current installation +./backup_lp.sh + +# 2. Download new version +wget https://github.com/nymtech/nym/releases/download/vX.Y.Z/nym-node + +# 3. Stop gateway +systemctl stop nym-gateway + +# 4. Replace binary +sudo mv nym-node /usr/local/bin/nym-node +sudo chmod +x /usr/local/bin/nym-node + +# 5. Run migrations (if any) +nym-node migrate --config ~/.nym/gateways//config/config.toml + +# 6. Start gateway +systemctl start nym-gateway + +# 7. Verify +curl http://localhost:8080/metrics | grep lp_connections_total +journalctl -u nym-gateway -f +``` + +## Reference + +### Default Ports + +| Port | Protocol | Purpose | +|------|----------|---------| +| 41264 | TCP | LP control plane (handshake + registration) | +| 51264 | Reserved | LP data plane (future use) | +| 51820 | UDP | WireGuard (for dVPN mode) | +| 8080 | HTTP | Metrics endpoint | + +### File Locations + +| File | Location | Purpose | +|------|----------|---------| +| Config | `~/.nym/gateways//config/config.toml` | Main configuration | +| LP Private Key | `~/.nym/gateways//keys/lp_x25519.pem` | LP static private key | +| WG Private Key | `~/.nym/gateways//keys/wg_private.key` | WireGuard private key | +| Database | PostgreSQL database | Nullifier tracking | +| Logs | `journalctl -u nym-gateway` | System logs | + +### Useful Commands + +```bash +# Check LP listener status +sudo netstat -tlnp | grep 41264 + +# View real-time logs +journalctl -u nym-gateway -f | grep LP + +# Query metrics +curl -s http://localhost:8080/metrics | grep "^lp_" + +# Check active connections +ss -tn sport = :41264 | wc -l + +# Test credential verification +psql -U nym_gateway -d nym_gateway -c \ + "SELECT COUNT(*) FROM spent_credentials WHERE created_at > NOW() - INTERVAL '1 hour';" +``` diff --git a/docs/LP_PROTOCOL.md b/docs/LP_PROTOCOL.md new file mode 100644 index 00000000000..de3e5f50bef --- /dev/null +++ b/docs/LP_PROTOCOL.md @@ -0,0 +1,990 @@ +# Lewes Protocol (LP) - Technical Specification + +## Overview + +The Lewes Protocol (LP) is a direct TCP-based registration protocol for Nym gateways. It provides an alternative to mixnet-based registration with different trade-offs: lower latency at the cost of revealing client IP to the gateway. + +**Design Goals:** +- **Low latency**: Direct TCP connection vs multi-hop mixnet routing +- **High reliability**: KCP protocol provides ordered, reliable delivery with ARQ +- **Strong security**: Noise XKpsk3 provides mutual authentication and forward secrecy +- **Replay protection**: Bitmap-based counter validation prevents replay attacks +- **Observability**: Prometheus metrics for production monitoring + +**Non-Goals:** +- Network-level anonymity (use mixnet registration for that) +- Persistent connections (LP is registration-only, single-use) +- Backward compatibility with legacy protocols + +## Architecture + +### Protocol Stack + +``` +┌─────────────────────────────────────────┐ +│ Application Layer │ +│ - Registration Requests │ +│ - E-cash Credential Verification │ +│ - WireGuard Peer Management │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ LP Layer (Lewes Protocol) │ +│ - Noise XKpsk3 Handshake │ +│ - Replay Protection (1024-pkt window) │ +│ - Counter-based Sequencing │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ KCP Layer (Reliability) │ +│ - Ordered Delivery │ +│ - ARQ with Selective ACK │ +│ - Congestion Control │ +│ - RTT Estimation │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ TCP Layer │ +│ - Connection Establishment │ +│ - Byte Stream Delivery │ +└─────────────────────────────────────────┘ +``` + +### Why This Layering? + +**TCP**: Provides connection-oriented byte stream and handles network-level retransmission. + +**KCP**: Adds application-level reliability optimized for low latency: +- **Fast retransmit**: Triggered after 2 duplicate ACKs (vs TCP's 3) +- **Selective ACK**: Acknowledges specific packets, not just cumulative +- **Configurable RTO**: Minimum RTO of 100ms (configurable) +- **No Nagle**: Immediate sending for low-latency applications + +**LP**: Provides cryptographic security and session management: +- **Noise XKpsk3**: Mutual authentication with pre-shared key +- **Replay protection**: Prevents duplicate packet acceptance +- **Session isolation**: Each session has unique cryptographic state + +**Application**: Business logic for registration and credential verification. + +## Protocol Flow + +### 1. Connection Establishment + +``` +Client Gateway + | | + |--- TCP SYN ---------------------------> | + |<-- TCP SYN-ACK ------------------------ | + |--- TCP ACK ----------------------------> | + | | +``` + +- **Control Port**: 41264 (default, configurable) +- **Data Port**: 51264 (reserved for future use, not currently used) + +### 2. Session Initialization + +Client generates session parameters: + +```rust +// Client-side session setup +let client_lp_keypair = Keypair::generate(); // X25519 keypair +let gateway_lp_public = gateway.lp_public_key; // From gateway descriptor +let salt = [timestamp (8 bytes) || nonce (24 bytes)]; // 32-byte salt + +// Derive PSK using ECDH + Blake3 KDF +let shared_secret = ECDH(client_private, gateway_public); +let psk = Blake3_derive_key( + context = "nym-lp-psk-v1", + input = shared_secret, + salt = salt +); + +// Calculate session IDs (deterministic from keys) +let lp_id = hash(client_lp_public || 0xCC || gateway_lp_public) & 0xFFFFFFFF; +let kcp_conv_id = hash(client_lp_public || 0xFF || gateway_lp_public) & 0xFFFFFFFF; +``` + +**Session ID Properties:** +- **Deterministic**: Same key pair always produces same ID +- **Order-independent**: `ID(A, B) == ID(B, A)` due to sorted hashing +- **Collision-resistant**: Uses full hash, truncated to u32 +- **Unique per protocol**: Different delimiters (0xCC for LP, 0xFF for KCP) + +### 3. Noise Handshake (XKpsk3 Pattern) + +``` +Client (Initiator) Gateway (Responder) + | | + |--- e ----------------------------------> | [1] Client ephemeral + | | + |<-- e, ee, s, es --------------------- | [2] Gateway ephemeral + static + | | + |--- s, se, psk -------------------------> | [3] Client static + PSK mix + | | + [Transport mode established] +``` + +**Message Contents:** + +**[1] Initiator → Responder: `e`** +- Payload: Client ephemeral public key (32 bytes) +- Encrypted: No (initial message) + +**[2] Responder → Initiator: `e, ee, s, es`** +- `e`: Responder ephemeral public key +- `ee`: Mix ephemeral-ephemeral DH +- `s`: Responder static public key (encrypted) +- `es`: Mix ephemeral-static DH +- Encrypted: Yes (with keys from `ee`) + +**[3] Initiator → Responder: `s, se, psk`** +- `s`: Initiator static public key (encrypted) +- `se`: Mix static-ephemeral DH +- `psk`: Mix pre-shared key (at position 3) +- Encrypted: Yes (with keys from `ee`, `es`) + +**Security Properties:** +- ✅ **Mutual authentication**: Both sides prove identity via static keys +- ✅ **Forward secrecy**: Ephemeral keys provide PFS +- ✅ **PSK authentication**: Binds session to out-of-band PSK +- ✅ **Identity hiding**: Static keys encrypted after first message + +**Handshake Characteristics:** +- **Messages**: 3 (1.5 round trips) +- **Minimum network RTTs**: 1.5 +- **Cryptographic operations**: ECDH, ChaCha20-Poly1305, SHA-256 + +### 4. PSK Derivation Details + +**Formula:** +``` +shared_secret = X25519(client_private_lp, gateway_public_lp) +psk = Blake3_derive_key( + context = "nym-lp-psk-v1", + key_material = shared_secret (32 bytes), + salt = timestamp || nonce (32 bytes) +) +``` + +**Implementation** (from `common/nym-lp/src/psk.rs:48`): +```rust +pub fn derive_psk( + local_private: &PrivateKey, + remote_public: &PublicKey, + salt: &[u8; 32], +) -> [u8; 32] { + let shared_secret = local_private.diffie_hellman(remote_public); + nym_crypto::kdf::derive_key_blake3(PSK_CONTEXT, shared_secret.as_bytes(), salt) +} +``` + +**Why This Design:** + +1. **Identity-bound**: PSK tied to LP keypairs, not ephemeral + - Prevents MITM without LP private key + - Links session to long-term identities + +2. **Session-specific via salt**: Different registrations use different PSKs + - `timestamp`: 8-byte Unix timestamp (milliseconds) + - `nonce`: 24-byte random value + - Prevents PSK reuse across sessions + +3. **Symmetric derivation**: Both sides derive same PSK + - Client: `ECDH(client_priv, gateway_pub)` + - Gateway: `ECDH(gateway_priv, client_pub)` + - Mathematical property: `ECDH(a, B) == ECDH(b, A)` + +4. **Blake3 KDF with domain separation**: + - Context string prevents cross-protocol attacks + - Generates uniform 32-byte output suitable for Noise + +**Salt Transmission:** +- Included in `ClientHello` message (cleartext) +- Gateway extracts salt before deriving PSK +- Timestamp validation rejects stale salts + +### 5. Replay Protection + +**Mechanism: Sliding Window with Bitmap** (from `common/nym-lp/src/replay/validator.rs:32`): + +```rust +const WORD_SIZE: usize = 64; +const N_WORDS: usize = 16; // 1024 bits total +const N_BITS: usize = WORD_SIZE * N_WORDS; // 1024 + +pub struct ReceivingKeyCounterValidator { + next: u64, // Next expected counter + receive_cnt: u64, // Total packets received + bitmap: [u64; 16], // 1024-bit bitmap +} +``` + +**Algorithm:** +``` +For each incoming packet with counter C: + 1. Quick check (branchless): + - If C >= next: Accept (growing) + - If C + 1024 < next: Reject (too old, outside window) + - If bitmap[C % 1024] is set: Reject (duplicate) + - Else: Accept (out-of-order within window) + + 2. After successful processing, mark: + - Set bitmap[C % 1024] = 1 + - If C >= next: Update next = C + 1 + - Increment receive_cnt +``` + +**Performance Optimizations:** + +1. **SIMD-accelerated bitmap operations** (from `common/nym-lp/src/replay/simd/`): + - AVX2 support (x86_64) + - SSE2 support (x86_64) + - NEON support (ARM) + - Scalar fallback (portable) + +2. **Branchless execution** (constant-time): + ```rust + // No early returns - prevents timing attacks + 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(())) + }; + result.unwrap() + ``` + +3. **Overflow-safe arithmetic**: + ```rust + let too_far_back = if counter > u64::MAX - 1024 { + false // Can't overflow, so not too far back + } else { + counter + 1024 < self.next + }; + ``` + +**Memory Usage** (verified from `common/nym-lp/src/replay/validator.rs:738`): +```rust +// test_memory_usage() +size = size_of::() * 2 + // next + receive_cnt = 16 bytes + size_of::() * N_WORDS; // bitmap = 128 bytes +// Total: 144 bytes +``` + +### 6. Registration Request + +After handshake completes, client sends encrypted registration request: + +```rust +pub struct RegistrationRequest { + pub mode: RegistrationMode, + pub credential: EcashCredential, + pub gateway_identity: String, +} + +pub enum RegistrationMode { + Dvpn { + wg_public_key: [u8; 32], + }, + Mixnet { + client_id: String, + mix_address: Option, + }, +} +``` + +**Encryption:** +- Encrypted using Noise transport mode +- Includes 16-byte Poly1305 authentication tag +- Replay protection via LP counter + +### 7. Credential Verification + +Gateway verifies the e-cash credential: + +```rust +// Gateway-side verification +pub async fn verify_credential( + &self, + credential: &EcashCredential, +) -> Result { + // 1. Check credential signature (BLS12-381) + verify_blinded_signature(&credential.signature)?; + + // 2. Check credential not already spent (nullifier check) + if self.storage.is_nullifier_spent(&credential.nullifier).await? { + return Err(CredentialError::AlreadySpent); + } + + // 3. Extract bandwidth allocation + let bandwidth_bytes = credential.bandwidth_value; + + // 4. Mark nullifier as spent + self.storage.mark_nullifier_spent(&credential.nullifier).await?; + + Ok(VerifiedCredential { + bandwidth_bytes, + expiry: credential.expiry, + }) +} +``` + +**For dVPN Mode:** +```rust +let peer_config = WireguardPeerConfig { + public_key: request.wg_public_key, + allowed_ips: vec!["10.0.0.0/8"], + bandwidth_limit: verified.bandwidth_bytes, +}; +self.wg_controller.add_peer(peer_config).await?; +``` + +### 8. Registration Response + +```rust +pub enum RegistrationResponse { + Success { + bandwidth_allocated: u64, + expiry: u64, + gateway_data: GatewayData, + }, + Error { + code: ErrorCode, + message: String, + }, +} + +pub enum ErrorCode { + InvalidCredential = 1, + CredentialExpired = 2, + CredentialAlreadyUsed = 3, + InsufficientBandwidth = 4, + WireguardPeerRegistrationFailed = 5, + InternalError = 99, +} +``` + +## State Machine and Security Protocol + +### Protocol Components + +The Lewes Protocol combines three cryptographic protocols for secure, post-quantum resistant communication: + +1. **KKT (KEM Key Transfer)** - Dynamically fetches responder's KEM public key with Ed25519 authentication +2. **PSQ (Post-Quantum Secure PSK)** - Derives PSK using KEM-based protocol for HNDL resistance +3. **Noise XKpsk3** - Provides encrypted transport with mutual authentication and forward secrecy + +### State Machine + +The LP state machine orchestrates the complete protocol flow from connection to encrypted transport: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ LEWES PROTOCOL STATE MACHINE │ +└─────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────┐ + │ ReadyToHandshake │ + │ │ + │ • Keys loaded │ + │ • Session ID set │ + └────────┬─────────┘ + │ + StartHandshake input + │ + ▼ + ┌───────────────────────────────────────┐ + │ KKTExchange │ + │ │ + │ Initiator: │ + │ 1. Send KKT request (signed) │ + │ 2. Receive KKT response │ + │ 3. Validate Ed25519 signature │ + │ 4. Extract KEM public key │ + │ │ + │ Responder: │ + │ 1. Wait for KKT request │ + │ 2. Validate signature │ + │ 3. Send signed KEM key │ + └───────────────┬───────────────────────┘ + │ + KKT Complete + │ + ▼ + ┌───────────────────────────────────────┐ + │ Handshaking │ + │ │ + │ PSQ Protocol: │ + │ 1. Initiator encapsulates PSK │ + │ (embedded in Noise msg 1) │ + │ 2. Responder decapsulates PSK │ + │ (sends ctxt_B in Noise msg 2) │ + │ 3. Both derive final PSK: │ + │ KDF(ECDH || KEM_shared) │ + │ │ + │ Noise XKpsk3 Handshake: │ + │ → msg 1: e, es, ss + PSQ payload │ + │ ← msg 2: e, ee, se + ctxt_B │ + │ → msg 3: s, se (handshake complete) │ + └───────────────┬───────────────────────┘ + │ + Handshake Complete + │ + ▼ + ┌───────────────────────────────────────┐ + │ Transport │ + │ │ + │ • Encrypted data transfer │ + │ • AEAD with ChaCha20-Poly1305 │ + │ • Replay protection (counters) │ + │ • Bidirectional communication │ + └───────────────┬───────────────────────┘ + │ + Close input + │ + ▼ + ┌──────────┐ + │ Closed │ + │ │ + │ • Reason │ + └──────────┘ +``` + +### Message Sequence + +Complete protocol flow from connection to encrypted transport: + +``` +Initiator Responder + │ │ + │ ════════════════ KKT EXCHANGE ════════════════ │ + │ │ + │ KKTRequest (signed with Ed25519) │ + ├──────────────────────────────────────────────────────────>│ + │ │ Validate + │ │ signature + │ KKTResponse (signed KEM key + hash) │ + │<──────────────────────────────────────────────────────────┤ + │ │ + │ Validate signature │ + │ Extract kem_pk │ + │ │ + │ ══════════════ PSQ + NOISE HANDSHAKE ══════════════ │ + │ │ + │ Noise msg 1: e, es, ss │ + │ + PSQ InitiatorMsg (KEM encapsulation) │ + ├──────────────────────────────────────────────────────────>│ + │ │ + │ │ PSQ: Decapsulate + │ │ Derive PSK + │ │ Inject into Noise + │ Noise msg 2: e, ee, se │ + │ + ctxt_B (encrypted PSK) │ + │<──────────────────────────────────────────────────────────┤ + │ │ + │ Extract ctxt_B │ + │ Store for re-registration │ + │ Inject PSK into Noise │ + │ │ + │ Noise msg 3: s, se │ + ├──────────────────────────────────────────────────────────>│ + │ │ + │ Handshake Complete ✓ │ Handshake Complete ✓ + │ Transport mode active │ Transport mode active + │ │ + │ ═══════════════ TRANSPORT MODE ═══════════════ │ + │ │ + │ EncryptedData (AEAD, counter N) │ + ├──────────────────────────────────────────────────────────>│ + │ │ + │ EncryptedData (counter M) │ + │<──────────────────────────────────────────────────────────┤ + │ │ + │ (bidirectional encrypted communication) │ + │◄──────────────────────────────────────────────────────────► + │ │ +``` + +### KKT (KEM Key Transfer) Protocol + +**Purpose**: Securely obtain responder's KEM public key before PSQ can begin. + +**Key Features**: +- Ed25519 signatures for authentication (both request and response signed) +- Optional hash validation for key pinning (future directory service integration) +- Currently signature-only mode (deployable without infrastructure) +- Easy upgrade path to hash-based key pinning + +**Initiator Flow**: +```rust +1. Generate KKT request with Ed25519 signature +2. Send KKTRequest to responder +3. Receive KKTResponse with signed KEM key +4. Validate Ed25519 signature +5. (Optional) Validate key hash against directory +6. Store KEM key for PSQ encapsulation +``` + +**Responder Flow**: +```rust +1. Receive KKTRequest from initiator +2. Validate initiator's Ed25519 signature +3. Generate KKTResponse with: + - Responder's KEM public key + - Ed25519 signature over (key || timestamp) + - Blake3 hash of KEM key +4. Send KKTResponse to initiator +``` + +### PSQ (Post-Quantum Secure PSK) Protocol + +**Purpose**: Derive a post-quantum secure PSK for Noise protocol. + +**Security Properties**: +- **HNDL resistance**: PSK derived from KEM-based protocol +- **Forward secrecy**: Ephemeral KEM keypair per session +- **Authentication**: Ed25519 signatures prevent MitM +- **Algorithm agility**: Easy upgrade from X25519 to ML-KEM + +**PSK Derivation**: +``` +Classical ECDH: + ecdh_secret = X25519_DH(local_private, remote_public) + +KEM Encapsulation (Initiator): + (kem_shared_secret, ciphertext) = KEM.Encap(responder_kem_pk) + +KEM Decapsulation (Responder): + kem_shared_secret = KEM.Decap(kem_private, ciphertext) + +Final PSK: + combined = ecdh_secret || kem_shared_secret || salt + psk = Blake3_KDF("nym-lp-psk-psq-v1", combined) +``` + +**Integration with Noise**: +- PSQ payload embedded in first Noise message (no extra round-trip) +- Responder sends encrypted PSK handle (ctxt_B) in second Noise message +- Both sides inject derived PSK before completing Noise handshake +- Noise validates PSK correctness during handshake + +**PSK Handle (ctxt_B)**: +The responder's encrypted PSK handle allows future re-registration without repeating PSQ: +- Encrypted with responder's long-term key +- Can be presented in future sessions +- Enables fast re-registration for returning clients + +### Security Guarantees + +**Achieved Properties**: +- ✅ **Mutual authentication**: Ed25519 signatures in KKT and PSQ +- ✅ **Forward secrecy**: Ephemeral keys in Noise handshake +- ✅ **Post-quantum PSK**: KEM-based PSK derivation +- ✅ **HNDL resistance**: PSK safe even if private keys compromised later +- ✅ **Replay protection**: Monotonic counters with sliding window +- ✅ **Key confirmation**: Noise handshake validates PSK correctness + +**Implementation Status**: +- 🔄 **Key pinning**: Hash validation via directory service (signature-only for now) +- 🔄 **ML-KEM support**: Easy config upgrade from X25519 to ML-KEM-768 +- 🔄 **PSK re-use**: ctxt_B handle stored for future re-registration + +### Algorithm Choices + +**Current (Testing/Development)**: +- KEM: X25519 (DHKEM) - Classical ECDH, widely tested +- Hash: Blake3 - Fast, secure, parallel +- Signature: Ed25519 - Fast verification, compact +- AEAD: ChaCha20-Poly1305 - Fast, constant-time + +**Future (Production)**: +- KEM: ML-KEM-768 - NIST-approved post-quantum KEM +- Hash: Blake3 - No change needed +- Signature: Ed25519 - No change needed (or upgrade to ML-DSA) +- AEAD: ChaCha20-Poly1305 - No change needed + +**Migration Path**: +```toml +# Current deployment +[lp.crypto] +kem_algorithm = "x25519" + +# Future upgrade (config change only) +[lp.crypto] +kem_algorithm = "ml-kem-768" +``` + +### Message Types + +**KKT Messages**: +```rust +// Message Type 0x0004 +struct KKTRequest { + timestamp: u64, // Unix timestamp (replay protection) + initiator_ed25519_pk: [u8; 32], // Initiator's public key + signature: [u8; 64], // Ed25519 signature +} + +// Message Type 0x0005 +struct KKTResponse { + kem_pk: Vec, // Responder's KEM public key + key_hash: [u8; 32], // Blake3 hash of KEM key + timestamp: u64, // Unix timestamp + signature: [u8; 64], // Ed25519 signature +} +``` + +**PSQ Embedding**: +- PSQ InitiatorMsg embedded in Noise message 1 payload (after 'e, es, ss') +- PSQ ResponderMsg (ctxt_B) embedded in Noise message 2 payload (after 'e, ee, se') +- No additional round-trips beyond standard 3-message Noise handshake + +## KCP Protocol Details + +### KCP Configuration + +From `common/nym-kcp/src/session.rs`: + +```rust +pub struct KcpSession { + conv: u32, // Conversation ID + mtu: usize, // Default: 1400 bytes + snd_wnd: u16, // Send window: 128 segments + rcv_wnd: u16, // Receive window: 128 segments + rx_minrto: u32, // Minimum RTO: 100ms (configurable) +} +``` + +### KCP Packet Format + +``` +┌────────────────────────────────────────────────┐ +│ Conv ID (4 bytes) - Conversation identifier │ +├────────────────────────────────────────────────┤ +│ Cmd (1 byte) - PSH/ACK/WND/ERR │ +├────────────────────────────────────────────────┤ +│ Frg (1 byte) - Fragment number (reverse order) │ +├────────────────────────────────────────────────┤ +│ Wnd (2 bytes) - Receive window size │ +├────────────────────────────────────────────────┤ +│ Timestamp (4 bytes) - Send timestamp │ +├────────────────────────────────────────────────┤ +│ Sequence Number (4 bytes) - Packet sequence │ +├────────────────────────────────────────────────┤ +│ UNA (4 bytes) - Unacknowledged sequence │ +├────────────────────────────────────────────────┤ +│ Length (4 bytes) - Data length │ +├────────────────────────────────────────────────┤ +│ Data (variable) - Payload │ +└────────────────────────────────────────────────┘ +``` + +**Total header**: 24 bytes + +### KCP Features + +**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 (configurable threshold) +- **RTO Calculation**: Smoothed RTT with variance + +## LP Packet Format + +### LP Header + +``` +┌────────────────────────────────────────────────┐ +│ Protocol Version (1 byte) - Currently: 1 │ +├────────────────────────────────────────────────┤ +│ Session ID (4 bytes) - LP session identifier │ +├────────────────────────────────────────────────┤ +│ Counter (8 bytes) - Replay protection counter │ +└────────────────────────────────────────────────┘ +``` + +**Total header**: 13 bytes + +### LP Message Types + +```rust +pub enum LpMessage { + Handshake(Vec), + EncryptedData(Vec), + ClientHello { + client_lp_public: [u8; 32], + salt: [u8; 32], + timestamp: u64, + }, + Busy, +} +``` + +### Complete Packet Structure + +``` +┌─────────────────────────────────────┐ +│ LP Header (13 bytes) │ +│ - Version, Session ID, Counter │ +├─────────────────────────────────────┤ +│ LP Message (variable) │ +│ - Type tag (1 byte) │ +│ - Message data │ +├─────────────────────────────────────┤ +│ Trailer (16 bytes) │ +│ - Reserved for future MAC/tag │ +└─────────────────────────────────────┘ +``` + +## Security Properties + +### Threat Model + +**Protected Against:** +- ✅ **Passive eavesdropping**: Noise encryption (ChaCha20-Poly1305) +- ✅ **Active MITM**: Mutual authentication via static keys + PSK +- ✅ **Replay attacks**: Counter-based validation with 1024-packet window +- ✅ **Packet injection**: Poly1305 authentication tags +- ✅ **Timestamp replay**: 30-second window for ClientHello timestamps (configurable) +- ✅ **DoS (connection flood)**: Connection limit (default: 10,000, configurable) +- ✅ **Credential reuse**: Nullifier tracking in database + +**Not Protected Against:** +- ❌ **Network-level traffic analysis**: LP is not anonymous (use mixnet for that) +- ❌ **Gateway compromise**: Gateway sees client registration data +- ⚠️ **Per-IP DoS**: No per-IP rate limiting (global limit only) + +### Cryptographic Primitives + +| Component | Algorithm | Key Size | Source | +|-----------|-----------|----------|--------| +| Key Exchange | X25519 | 256 bits | RustCrypto | +| Encryption | ChaCha20 | 256 bits | RustCrypto | +| Authentication | Poly1305 | 256 bits | RustCrypto | +| KDF | Blake3 | 256 bits | nym_crypto | +| Hash (Noise) | SHA-256 | 256 bits | snow crate | +| Signature (E-cash) | BLS12-381 | 381 bits | E-cash contract | + +### Forward Secrecy + +Noise XKpsk3 provides forward secrecy through ephemeral keys: + +1. **Initial handshake**: Uses ephemeral + static keys +2. **Key compromise scenario**: + - Compromise of **static key**: Past sessions remain secure (ephemeral keys destroyed) + - Compromise of **PSK**: Attacker needs static key too (two-factor security) + - Compromise of **both**: Only future sessions affected, not past + +3. **Session key lifetime**: Destroyed after single registration completes + +### Timing Attack Resistance + +**Constant-time operations:** +- ✅ Replay protection check (branchless) +- ✅ Bitmap bit operations (branchless) +- ✅ Noise crypto operations (via snow/RustCrypto) + +**Variable-time operations:** +- ⚠️ Credential verification (database lookup time varies) +- ⚠️ WireGuard peer registration (filesystem operations) + +## Configuration + +### Gateway Configuration + +From `gateway/src/node/lp_listener/mod.rs:78`: + +```toml +[lp] +# Enable/disable LP listener +enabled = true + +# Bind address +bind_address = "0.0.0.0" + +# Control port (for LP handshake and registration) +control_port = 41264 + +# Data port (reserved for future use) +data_port = 51264 + +# Maximum concurrent connections +max_connections = 10000 + +# Timestamp validation window (seconds) +# ClientHello messages older than this are rejected +timestamp_tolerance_secs = 30 + +# Use mock e-cash verifier (TESTING ONLY!) +use_mock_ecash = false +``` + +### Firewall Rules + +**Required inbound rules:** +```bash +# Allow TCP connections to LP control port +iptables -A INPUT -p tcp --dport 41264 -j ACCEPT + +# Optional: Rate limiting +iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --set --name LP_LIMIT +iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --update --seconds 60 --hitcount 100 --name LP_LIMIT \ + -j DROP +``` + +## Metrics + +From `gateway/src/node/lp_listener/mod.rs:4`: + +**Connection Metrics:** +- `active_lp_connections`: Gauge tracking current active LP connections +- `lp_connections_total`: Counter for total LP connections handled +- `lp_connection_duration_seconds`: Histogram of connection durations +- `lp_connections_completed_gracefully`: Counter for successful completions +- `lp_connections_completed_with_error`: Counter for error terminations + +**Handshake Metrics:** +- `lp_handshakes_success`: Counter for successful handshakes +- `lp_handshakes_failed`: Counter for failed handshakes +- `lp_handshake_duration_seconds`: Histogram of handshake durations +- `lp_client_hello_failed`: Counter for ClientHello failures + +**Registration Metrics:** +- `lp_registration_attempts_total`: Counter for all registration attempts +- `lp_registration_success_total`: Counter for successful registrations +- `lp_registration_failed_total`: Counter for failed registrations +- `lp_registration_duration_seconds`: Histogram of registration durations + +**Mode-Specific:** +- `lp_registration_dvpn_attempts/success/failed`: dVPN mode counters +- `lp_registration_mixnet_attempts/success/failed`: Mixnet mode counters + +**Credential Metrics:** +- `lp_credential_verification_attempts/success/failed`: Verification counters +- `lp_bandwidth_allocated_bytes_total`: Total bandwidth allocated + +**Error Metrics:** +- `lp_errors_handshake`: Handshake errors +- `lp_errors_timestamp_too_old/too_far_future`: Timestamp validation errors +- `lp_errors_wg_peer_registration`: WireGuard peer registration failures + +## Error Codes + +### Handshake Errors + +| Error | Description | +|-------|-------------| +| `NOISE_DECRYPT_ERROR` | Invalid ciphertext or wrong keys | +| `NOISE_PROTOCOL_ERROR` | Unexpected message or state | +| `REPLAY_DUPLICATE` | Counter already seen | +| `REPLAY_OUT_OF_WINDOW` | Counter outside 1024-packet window | +| `TIMESTAMP_TOO_OLD` | ClientHello > configured tolerance | +| `TIMESTAMP_FUTURE` | ClientHello from future | + +### Registration Errors + +| Code | Name | Description | +|------|------|-------------| +| `CREDENTIAL_INVALID` | Invalid credential | Signature verification failed | +| `CREDENTIAL_EXPIRED` | Credential expired | Past expiry timestamp | +| `CREDENTIAL_SPENT` | Already used | Nullifier already in database | +| `INSUFFICIENT_BANDWIDTH` | Not enough bandwidth | Requested > credential value | +| `WIREGUARD_FAILED` | Peer registration failed | Kernel error adding WireGuard peer | + +## Limitations + +### Current Limitations + +1. **No persistent sessions**: Each registration is independent +2. **Single registration per session**: Connection closes after registration +3. **No streaming**: Protocol is request-response only +4. **No gateway discovery**: Client must know gateway's LP public key beforehand +5. **No version negotiation**: Protocol version fixed at 1 +6. **No per-IP rate limiting**: Only global connection limit + +### Testing Gaps + +1. **No end-to-end integration tests**: Unit tests exist, integration tests pending +2. **No performance benchmarks**: Latency/throughput not measured +3. **No load testing**: Concurrent connection limits not stress-tested +4. **No security audit**: Cryptographic implementation not externally reviewed + +## References + +### Specifications + +- **Noise Protocol Framework**: https://noiseprotocol.org/noise.html +- **XKpsk3 Pattern**: https://noiseexplorer.com/patterns/XKpsk3/ +- **KCP Protocol**: https://github.com/skywind3000/kcp +- **Blake3**: https://github.com/BLAKE3-team/BLAKE3-specs + +### Implementations + +- **snow**: Rust Noise protocol implementation +- **RustCrypto**: Cryptographic primitives (ChaCha20-Poly1305, X25519) +- **tokio**: Async runtime for network I/O + +### Security Audits + +- [ ] Noise implementation audit (pending) +- [ ] Replay protection audit (pending) +- [ ] E-cash integration audit (pending) +- [ ] Penetration testing (pending) + +## Changelog + +### Version 1.1 (Post-Quantum PSK with KKT) + +**Implemented:** +- KKTExchange state in state machine for pre-handshake KEM key transfer +- PSQ (Post-Quantum Secure PSK) protocol integration +- KKT (KEM Key Transfer) protocol with Ed25519 authentication +- Optional hash validation for KEM key pinning (signature-only mode active) +- PSK handle (ctxt_B) storage for future re-registration +- X25519 DHKEM support (ready for ML-KEM upgrade) +- Comprehensive state machine tests (7 test cases) +- generate_fresh_salt() utility for session creation + +**Security Improvements:** +- Post-quantum PSK derivation (KEM-based) +- HNDL (Harvest Now, Decrypt Later) resistance +- Mutual authentication via Ed25519 signatures +- Easy migration path to ML-KEM-768 + +**Architecture:** +- State flow: ReadyToHandshake → KKTExchange → Handshaking → Transport +- PSQ embedded in Noise handshake (no extra round-trip) +- Automatic KKT on StartHandshake (no manual key distribution) + +**Related Issues:** +- nym-4za: Add KKTExchange state to LpStateMachine + +### Version 1.0 (Initial Implementation) + +**Implemented:** +- Noise XKpsk3 handshake +- KCP reliability layer +- Replay protection (1024-packet window with SIMD) +- PSK derivation (ECDH + Blake3) +- dVPN and Mixnet registration modes +- E-cash credential verification +- WireGuard peer management +- Prometheus metrics +- DoS protection (connection limits, timestamp validation) + +**Pending:** +- End-to-end integration tests +- Performance benchmarks +- Security audit +- Client implementation +- Gateway probe support +- Per-IP rate limiting diff --git a/docs/LP_README.md b/docs/LP_README.md new file mode 100644 index 00000000000..f1e2ac049d8 --- /dev/null +++ b/docs/LP_README.md @@ -0,0 +1,470 @@ +# Lewes Protocol (LP) - Fast Gateway Registration + +## What is LP? + +The Lewes Protocol (LP) is a direct TCP-based registration protocol for Nym gateways. It provides an alternative to mixnet-based registration with different trade-offs. + +**Trade-offs:** +- **Faster**: Direct TCP connection vs multi-hop mixnet routing (fewer hops = lower latency) +- **Less Anonymous**: Client IP visible to gateway (mixnet hides IP) +- **More Reliable**: KCP provides ordered delivery with fast retransmission +- **Secure**: Noise XKpsk3 provides mutual authentication and forward secrecy + +**Use LP when:** +- Fast registration is important +- Network anonymity is not required for the registration step +- You want reliable, ordered delivery + +**Use mixnet registration when:** +- Network-level anonymity is essential +- IP address hiding is required +- Traffic analysis resistance is critical + +## Quick Start + +### For Gateway Operators + +```bash +# 1. Enable LP in gateway config +cat >> ~/.nym/gateways//config/config.toml << EOF +[lp] +enabled = true +bind_address = "0.0.0.0" +control_port = 41264 +max_connections = 10000 +timestamp_tolerance_secs = 30 +EOF + +# 2. Open firewall +sudo ufw allow 41264/tcp + +# 3. Restart gateway +systemctl restart nym-gateway + +# 4. Verify LP listener +sudo netstat -tlnp | grep 41264 +curl http://localhost:8080/metrics | grep lp_connections_total +``` + +### For Client Developers + +```rust +use nym_registration_client::{RegistrationClient, RegistrationMode}; + +// Initialize client +let client = RegistrationClient::builder() + .gateway_identity("gateway-identity-key") + .gateway_lp_public_key(gateway_lp_pubkey) // From gateway descriptor + .gateway_lp_address("gateway-ip:41264") + .mode(RegistrationMode::Lp) + .build()?; + +// Register with dVPN mode +let result = client.register_lp( + credential, + RegistrationMode::Dvpn { + wg_public_key: client_wg_pubkey, + } +).await?; + +match result { + LpRegistrationResult::Success { gateway_data, bandwidth_allocated, .. } => { + // Use gateway_data to configure WireGuard tunnel + } + LpRegistrationResult::Error { code, message } => { + eprintln!("Registration failed: {} (code: {})", message, code); + } +} +``` + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Application │ +│ - Registration Request │ +│ - E-cash Verification │ +│ - WireGuard Setup │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ LP Layer │ +│ - Noise XKpsk3 Handshake │ +│ - Replay Protection (1024 packets) │ +│ - Counter-based Sequencing │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ KCP Layer │ +│ - Ordered Delivery │ +│ - Fast Retransmission │ +│ - Congestion Control │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ TCP │ +│ - Connection-oriented │ +│ - Byte Stream │ +└─────────────────────────────────────────┘ +``` + +### Why This Stack? + +**TCP**: Reliable connection establishment, handles network-level packet loss. + +**KCP**: Application-level reliability optimized for low latency: +- Fast retransmit after 2 duplicate ACKs (vs TCP's 3) +- Selective acknowledgment (better than TCP's cumulative ACK) +- Minimum RTO of 100ms (configurable, vs TCP's typical 200ms+) + +**LP**: Cryptographic security: +- **Noise XKpsk3**: Mutual authentication + forward secrecy +- **Replay Protection**: 1024-packet sliding window +- **Session Isolation**: Each registration has unique crypto state + +**Application**: Credential verification and peer registration logic. + +## Key Features + +### Security + +**Cryptographic Primitives:** +- **Noise XKpsk3**: Mutual authentication with PSK +- **ChaCha20-Poly1305**: Authenticated encryption +- **X25519**: Key exchange +- **Blake3**: KDF for PSK derivation + +**Security Properties:** +- Mutual authentication (both client and gateway prove identity) +- Forward secrecy (past sessions remain secure if keys compromised) +- Replay protection (1024-packet sliding window with SIMD optimization) +- Timestamp validation (30-second window, configurable) + +### Observability + +**Prometheus metrics** (from `gateway/src/node/lp_listener/mod.rs:4`): +- Connection counts and durations +- Handshake success/failure rates +- Registration outcomes (dVPN vs Mixnet) +- Credential verification results +- Error categorization +- Latency histograms + +### DoS Protection + +From `gateway/src/node/lp_listener/mod.rs`: +- **Connection limits**: Configurable max concurrent connections (default: 10,000) +- **Timestamp validation**: Rejects messages outside configured window (default: 30s) +- **Replay protection**: Prevents packet replay attacks + +## Components + +### Core Modules + +| Module | Path | Purpose | +|--------|------|---------| +| **nym-lp** | `common/nym-lp/` | Core LP protocol implementation | +| **nym-kcp** | `common/nym-kcp/` | KCP reliability protocol | +| **lp_listener** | `gateway/src/node/lp_listener/` | Gateway-side LP listener | + +### Key Files + +**Protocol:** +- `common/nym-lp/src/noise_protocol.rs` - Noise state machine +- `common/nym-lp/src/replay/validator.rs` - Replay protection +- `common/nym-lp/src/psk.rs` - PSK derivation +- `common/nym-lp/src/session.rs` - LP session management + +**KCP:** +- `common/nym-kcp/src/session.rs` - KCP state machine +- `common/nym-kcp/src/packet.rs` - KCP packet format + +**Gateway:** +- `gateway/src/node/lp_listener/mod.rs` - TCP listener +- `gateway/src/node/lp_listener/handler.rs` - Connection handler +- `gateway/src/node/lp_listener/handshake.rs` - Noise handshake +- `gateway/src/node/lp_listener/registration.rs` - Registration logic + +## Protocol Flow + +### 1. Connection Establishment + +``` +Client Gateway + |--- TCP SYN ------------> | + |<-- TCP SYN-ACK --------- | + |--- TCP ACK ------------> | +``` + +Port: 41264 (default, configurable) + +### 2. Session Setup + +```rust +// Client generates session parameters +let salt = [timestamp (8 bytes) || nonce (24 bytes)]; +let shared_secret = ECDH(client_lp_private, gateway_lp_public); +let psk = Blake3_derive_key("nym-lp-psk-v1", shared_secret, salt); + +// Deterministic session IDs (order-independent) +let lp_id = hash(client_pub || 0xCC || gateway_pub) & 0xFFFFFFFF; +let kcp_conv = hash(client_pub || 0xFF || gateway_pub) & 0xFFFFFFFF; +``` + +### 3. Noise Handshake (XKpsk3) + +``` +Client Gateway + |--- e ------------------------>| [1] Client ephemeral + |<-- e, ee, s, es -------------| [2] Gateway ephemeral + static + |--- s, se, psk -------------->| [3] Client static + PSK + [Transport mode established] +``` + +**Handshake characteristics:** +- 3 messages (1.5 round trips minimum) +- Cryptographic operations: ECDH, ChaCha20-Poly1305, SHA-256 + +### 4. Registration + +``` +Client Gateway + |--- RegistrationRequest ------>| (encrypted) + | | [Verify credential] + | | [Register WireGuard peer if dVPN] + |<-- RegistrationResponse ------| (encrypted) +``` + +### 5. Connection Close + +After successful registration, connection is closed. LP is registration-only. + +## Configuration + +### Gateway + +```toml +# ~/.nym/gateways//config/config.toml + +[lp] +enabled = true +bind_address = "0.0.0.0" +control_port = 41264 +data_port = 51264 # Reserved, not currently used +max_connections = 10000 +timestamp_tolerance_secs = 30 +use_mock_ecash = false # TESTING ONLY! +``` + +### Environment Variables + +```bash +RUST_LOG=nym_gateway::node::lp_listener=debug +LP_ENABLED=true +LP_CONTROL_PORT=41264 +LP_MAX_CONNECTIONS=20000 +``` + +## Monitoring + +### Key Metrics + +**Connections:** +```promql +nym_gateway_active_lp_connections +rate(nym_gateway_lp_connections_total[5m]) +rate(nym_gateway_lp_connections_completed_with_error[5m]) +``` + +**Handshakes:** +```promql +rate(nym_gateway_lp_handshakes_success[5m]) +rate(nym_gateway_lp_handshakes_failed[5m]) +histogram_quantile(0.95, nym_gateway_lp_handshake_duration_seconds) +``` + +**Registrations:** +```promql +rate(nym_gateway_lp_registration_success_total[5m]) +rate(nym_gateway_lp_registration_dvpn_success[5m]) +rate(nym_gateway_lp_registration_mixnet_success[5m]) +histogram_quantile(0.95, nym_gateway_lp_registration_duration_seconds) +``` + +### Recommended Alerts + +```yaml +- alert: LPHighRejectionRate + expr: rate(nym_gateway_lp_connections_completed_with_error[5m]) > 10 + for: 5m + +- alert: LPHandshakeFailures + expr: rate(nym_gateway_lp_handshakes_failed[5m]) / rate(nym_gateway_lp_handshakes_success[5m]) > 0.05 + for: 10m +``` + +## Testing + +### Unit Tests + +```bash +# Run all LP tests +cargo test -p nym-lp +cargo test -p nym-kcp + +# Specific suites +cargo test -p nym-lp replay +cargo test -p nym-kcp session +``` + +**Test Coverage** (from code): + +| Component | Tests | Focus Areas | +|-----------|-------|-------------| +| Replay Protection | 14 | Edge cases, concurrency, overflow | +| KCP Session | 12 | Out-of-order, retransmit, window | +| PSK Derivation | 5 | Determinism, symmetry, salt | +| LP Session | 10 | Handshake, encrypt/decrypt | + +### Missing Tests + +- [ ] End-to-end registration flow +- [ ] Network failure scenarios +- [ ] Credential verification integration +- [ ] Load testing (concurrent connections) +- [ ] Performance benchmarks + +## Troubleshooting + +### Connection Refused + +```bash +# Check listener +sudo netstat -tlnp | grep 41264 + +# Check config +grep "lp.enabled" ~/.nym/gateways//config/config.toml + +# Check firewall +sudo ufw status | grep 41264 +``` + +### Handshake Failures + +```bash +# Check logs +journalctl -u nym-gateway | grep "handshake.*failed" + +# Common causes: +# - Wrong gateway LP public key +# - Clock skew > 30s (check with: timedatectl) +# - Replay detection (retry with fresh connection) +``` + +### High Rejection Rate + +```bash +# Check metrics +curl http://localhost:8080/metrics | grep lp_connections_completed_with_error + +# Check connection limit +curl http://localhost:8080/metrics | grep active_lp_connections +``` + +See [LP_DEPLOYMENT.md](./LP_DEPLOYMENT.md#troubleshooting) for detailed guide. + +## Security + +### Threat Model + +**Protected Against:** +- ✅ Passive eavesdropping (Noise encryption) +- ✅ Active MITM (mutual authentication) +- ✅ Replay attacks (counter-based validation) +- ✅ Packet injection (Poly1305 MAC) +- ✅ DoS (connection limits, timestamp validation) + +**Not Protected Against:** +- ❌ Network-level traffic analysis (IP visible) +- ❌ Gateway compromise (sees registration data) +- ⚠️ Per-IP DoS (global limit only, not per-IP) + +**Key Properties:** +- **Forward Secrecy**: Past sessions secure if keys compromised +- **Mutual Authentication**: Both parties prove identity +- **Replay Protection**: 1024-packet sliding window (verified: 144 bytes memory) +- **Constant-Time**: Replay checks are branchless (timing-attack resistant) + +See [LP_SECURITY.md](./LP_SECURITY.md) for complete security analysis. + +### Known Limitations + +1. **No network anonymity**: Client IP visible to gateway +2. **Not quantum-resistant**: X25519 vulnerable to Shor's algorithm +3. **Single-use sessions**: No session resumption +4. **No per-IP rate limiting**: Only global connection limit + +## Implementation Status + +### Implemented ✅ + +- Noise XKpsk3 handshake +- KCP reliability layer +- Replay protection (1024-packet window with SIMD) +- PSK derivation (ECDH + Blake3) +- dVPN and Mixnet registration modes +- E-cash credential verification +- WireGuard peer management +- Prometheus metrics +- DoS protection + +### Pending ⏳ + +- End-to-end integration tests +- Performance benchmarks +- External security audit +- Client implementation +- Gateway probe support +- Per-IP rate limiting + +## Documentation + +- **[LP_PROTOCOL.md](./LP_PROTOCOL.md)**: Complete protocol specification +- **[LP_DEPLOYMENT.md](./LP_DEPLOYMENT.md)**: Deployment and operations guide +- **[LP_SECURITY.md](./LP_SECURITY.md)**: Security analysis and threat model +- **[CODEMAP.md](../CODEMAP.md)**: Repository structure + +## Contributing + +### Getting Started + +1. Read [CODEMAP.md](../CODEMAP.md) for repository structure +2. Review [LP_PROTOCOL.md](./LP_PROTOCOL.md) for protocol details +3. Check [FUNCTION_LEXICON.md](../FUNCTION_LEXICON.md) for API reference + +### Areas Needing Work + +**High Priority:** +- Integration tests for end-to-end registration +- Performance benchmarks (latency, throughput, concurrent connections) +- Per-IP rate limiting +- Client-side implementation + +**Medium Priority:** +- Gateway probe support +- Load testing framework +- Fuzzing for packet parsers + +## License + +Same as parent Nym repository. + +## Support + +- **GitHub Issues**: https://github.com/nymtech/nym/issues +- **Discord**: https://discord.gg/nym + +--- + +**Protocol Version**: 1.0 +**Status**: Draft (pending security audit and integration tests) diff --git a/docs/LP_REGISTRATION_ARCHITECTURE.md b/docs/LP_REGISTRATION_ARCHITECTURE.md new file mode 100644 index 00000000000..41d2287c17f --- /dev/null +++ b/docs/LP_REGISTRATION_ARCHITECTURE.md @@ -0,0 +1,1400 @@ +# LP Registration - Component Architecture + +**Technical architecture deep-dive** + +--- + +## Table of Contents + +1. [System Overview](#1-system-overview) +2. [Gateway Architecture](#2-gateway-architecture) +3. [Client Architecture](#3-client-architecture) +4. [Shared Protocol Library](#4-shared-protocol-library) +5. [Data Flow Diagrams](#5-data-flow-diagrams) +6. [State Machines](#6-state-machines) +7. [Database Schema](#7-database-schema) +8. [Integration Points](#8-integration-points) + +--- + +## 1. System Overview + +### High-Level System Diagram + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL SYSTEMS │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ ┌──────────────────────┐ │ +│ │ Nym Blockchain │ │ WireGuard Daemon │ │ +│ │ (Nyx) │ │ (wg0 interface) │ │ +│ │ │ │ │ │ +│ │ • E-cash contract │ │ • Kernel module │ │ +│ │ • Verification │ │ • Peer management │ │ +│ │ keys │ │ • Tunnel routing │ │ +│ └──────────┬──────────┘ └─────────┬────────────┘ │ +│ │ │ │ +└─────────────┼──────────────────────────────┼───────────────────────────────┘ + │ │ + │ RPC calls │ Netlink/ioctl + │ (credential queries) │ (peer add/remove) + │ │ +┌─────────────▼──────────────────────────────▼───────────────────────────────┐ +│ GATEWAY COMPONENTS │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ nym-node (Gateway Mode) │ │ +│ │ gateway/src/node/ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ┌────────▼──────────┐ ┌─────────▼──────────┐ │ +│ │ LpListener │ │ Mixnet Listener │ │ +│ │ (LP Protocol) │ │ (Traditional) │ │ +│ │ :41264 │ │ :1789, :9000 │ │ +│ └────────┬──────────┘ └────────────────────┘ │ +│ │ │ +│ ┌────────▼────────────────────────────────────────┐ │ +│ │ Shared Gateway Services │ │ +│ │ ┌────────────┐ ┌──────────────┐ ┌─────────┐ │ │ +│ │ │ EcashMgr │ │ WG Controller│ │ Storage │ │ │ +│ │ │ (verify) │ │ (peer mgmt) │ │ (SQLite)│ │ │ +│ │ └────────────┘ └──────────────┘ └─────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ + ▲ + │ TCP :41264 + │ (LP Protocol) + │ +┌─────────────┴───────────────────────────────────────────────────────────────┐ +│ CLIENT COMPONENTS │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Application (nym-gateway-probe, nym-vpn-client) │ │ +│ │ │ │ +│ │ Uses: │ │ +│ │ • nym-registration-client (LP registration) │ │ +│ │ • nym-bandwidth-controller (e-cash credential acquisition) │ │ +│ │ • wireguard-rs (WireGuard tunnel setup) │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ┌────────▼──────────────┐ ┌─────────▼────────────┐ │ +│ │ LpRegistrationClient │ │ BandwidthController │ │ +│ │ (LP protocol client) │ │ (e-cash client) │ │ +│ └────────┬──────────────┘ └──────────────────────┘ │ +│ │ │ +│ ┌────────▼────────────────────────────────────┐ │ +│ │ common/nym-lp (Protocol Library) │ │ +│ │ • State machine │ │ +│ │ • Noise protocol │ │ +│ │ • Cryptographic primitives │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Code Locations**: +- Gateway: `gateway/src/node/lp_listener/` +- Client: `nym-registration-client/src/lp_client/` +- Protocol: `common/nym-lp/src/` + +--- + +## 2. Gateway Architecture + +### 2.1. Gateway Module Structure + +``` +gateway/src/node/ +│ +├─ lp_listener/ +│ │ +│ ├─ mod.rs [Main module, config, listener] +│ │ ├─ LpConfig (Configuration struct) +│ │ ├─ LpHandlerState (Shared state across connections) +│ │ └─ LpListener (TCP accept loop) +│ │ └─ run() ───────────────────┐ +│ │ │ +│ ├─ handler.rs [Per-connection handler] +│ │ └─ LpConnectionHandler <──────┘ spawned per connection +│ │ ├─ handle() (Main connection lifecycle) +│ │ ├─ receive_client_hello() +│ │ ├─ validate_timestamp() +│ │ └─ [emit metrics] +│ │ +│ ├─ registration.rs [Business logic] +│ │ ├─ process_registration() (Mode router: dVPN/Mixnet) +│ │ ├─ register_wg_peer() (WireGuard peer setup) +│ │ ├─ credential_verification() (E-cash verification) +│ │ └─ credential_storage_preparation() +│ │ +│ └─ handshake.rs (if exists) [Noise handshake helpers] +│ +├─ wireguard/ [WireGuard integration] +│ ├─ peer_controller.rs (PeerControlRequest handler) +│ └─ ... +│ +└─ storage/ [Database layer] + ├─ gateway_storage.rs + └─ models/ +``` + +### 2.2. Gateway Connection Flow + +``` +[TCP Accept Loop - LpListener::run()] + ↓ +┌────────────────────────────────────────────────────────────────┐ +│ loop { │ +│ stream = listener.accept().await? │ +│ ↓ │ +│ if active_connections >= max_connections { │ +│ send(LpMessage::Busy) │ +│ continue │ +│ } │ +│ ↓ │ +│ spawn(async move { │ +│ LpConnectionHandler::new(stream, state).handle().await │ +│ }) │ +│ } │ +└────────────────────────────────────────────────────────────────┘ + ↓ spawned task +┌────────────────────────────────────────────────────────────────┐ +│ [LpConnectionHandler::handle()] │ +│ gateway/src/node/lp_listener/handler.rs:101-216 │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] Setup │ +│ ├─ Convert gateway ed25519 → x25519 │ +│ ├─ Start metrics timer │ +│ └─ inc!(active_lp_connections) │ +│ │ +│ [2] Receive ClientHello │ +│ ├─ receive_client_hello(stream).await? │ +│ │ ├─ Read length-prefixed packet │ +│ │ ├─ Deserialize ClientHelloData │ +│ │ ├─ Extract: client_pub, salt, timestamp │ +│ │ └─ validate_timestamp(timestamp, tolerance)? │ +│ │ → if invalid: inc!(lp_client_hello_failed) │ +│ │ return Err(...) │ +│ └─ ✓ ClientHello valid │ +│ │ +│ [3] Derive PSK │ +│ └─ psk = nym_lp::derive_psk( │ +│ gw_lp_keypair.secret, │ +│ client_pub, │ +│ salt │ +│ ) │ +│ │ +│ [4] Noise Handshake │ +│ ├─ state_machine = LpStateMachine::new( │ +│ │ is_initiator: false, // responder │ +│ │ local_keypair: gw_lp_keypair, │ +│ │ remote_pubkey: client_pub, │ +│ │ psk: psk │ +│ │ ) │ +│ │ │ +│ ├─ loop { │ +│ │ packet = receive_packet(stream).await? │ +│ │ action = state_machine.process_input( │ +│ │ ReceivePacket(packet) │ +│ │ )? │ +│ │ match action { │ +│ │ SendPacket(p) => send_packet(stream, p).await? │ +│ │ HandshakeComplete => break │ +│ │ _ => continue │ +│ │ } │ +│ │ } │ +│ │ │ +│ ├─ observe!(lp_handshake_duration_seconds, duration) │ +│ └─ inc!(lp_handshakes_success) │ +│ │ +│ [5] Receive Registration Request │ +│ ├─ packet = receive_packet(stream).await? │ +│ ├─ action = state_machine.process_input(ReceivePacket(p)) │ +│ ├─ plaintext = match action { │ +│ │ DeliverData(data) => data, │ +│ │ _ => return Err(...) │ +│ │ } │ +│ └─ request = bincode::deserialize::< │ +│ LpRegistrationRequest │ +│ >(&plaintext)? │ +│ │ +│ [6] Process Registration ───────────────┐ │ +│ │ │ +└──────────────────────────────────────────┼─────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ [process_registration()] │ +│ gateway/src/node/lp_listener/registration.rs:136-288 │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] Validate timestamp (second check) │ +│ └─ if !request.validate_timestamp(30): return ERROR │ +│ │ +│ [2] Match on request.mode │ +│ ├─ RegistrationMode::Dvpn ───────────┐ │ +│ │ │ │ +│ └─ RegistrationMode::Mixnet{..} ─────┼────────────┐ │ +│ │ │ │ +└──────────────────────────────────────────┼───────────┼───────────┘ + │ │ + ┌───────────────────────────────┘ │ + │ │ + ▼ ▼ +┌───────────────────────────────┐ ┌──────────────────────────┐ +│ [dVPN Mode] │ │ [Mixnet Mode] │ +├───────────────────────────────┤ ├──────────────────────────┤ +│ │ │ │ +│ [A] register_wg_peer() │ │ [A] Generate client_id │ +│ ├─ Allocate IPs │ │ from request │ +│ ├─ Create Peer config │ │ │ +│ ├─ DB: insert_wg_peer() │ │ [B] Skip WireGuard │ +│ │ → get client_id │ │ │ +│ ├─ DB: create_bandwidth() │ │ [C] credential_verify() │ +│ ├─ WG: add_peer() │ │ (same as dVPN) │ +│ └─ Prepare GatewayData │ │ │ +│ │ │ [D] Return response │ +│ [B] credential_verification()│ │ (no gateway_data) │ +│ (see below) │ │ │ +│ │ └──────────────────────────┘ +│ [C] Return response with │ +│ gateway_data │ +│ │ +└───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [register_wg_peer()] │ +│ gateway/src/node/lp_listener/registration.rs:291-404 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] Allocate Private IPs │ +│ ├─ random_octet = rng.gen_range(1..255) │ +│ ├─ ipv4 = Ipv4Addr::new(10, 1, 0, random_octet) │ +│ └─ ipv6 = Ipv6Addr::new(0xfd00, 0, ..., random_octet) │ +│ │ +│ [2] Create Peer Config │ +│ └─ peer = Peer { │ +│ public_key: request.wg_public_key, │ +│ allowed_ips: [ipv4/32, ipv6/128], │ +│ persistent_keepalive: Some(25), │ +│ endpoint: None │ +│ } │ +│ │ +│ [3] CRITICAL ORDER - Database Operations │ +│ ├─ client_id = storage.insert_wireguard_peer( │ +│ │ &peer, │ +│ │ ticket_type │ +│ │ ).await? │ +│ │ ↓ │ +│ │ SQL: INSERT INTO wireguard_peers │ +│ │ (public_key, ticket_type, created_at) │ +│ │ VALUES (?, ?, NOW()) │ +│ │ RETURNING id │ +│ │ → client_id: i64 │ +│ │ │ +│ └─ credential_storage_preparation( │ +│ ecash_verifier, │ +│ client_id │ +│ ).await? │ +│ ↓ │ +│ SQL: INSERT INTO bandwidth │ +│ (client_id, available) │ +│ VALUES (?, 0) │ +│ │ +│ [4] Send to WireGuard Controller │ +│ ├─ (tx, rx) = oneshot::channel() │ +│ ├─ wg_controller.send( │ +│ │ PeerControlRequest::AddPeer { │ +│ │ peer: peer.clone(), │ +│ │ response_tx: tx │ +│ │ } │ +│ │ ).await? │ +│ │ │ +│ ├─ result = rx.await? // Wait for controller response │ +│ │ │ +│ └─ if result.is_err() { │ +│ // ROLLBACK: │ +│ storage.delete_bandwidth(client_id).await? │ +│ storage.delete_wireguard_peer(client_id).await? │ +│ return Err(WireGuardPeerAddFailed) │ +│ } │ +│ │ +│ [5] Prepare Gateway Data │ +│ └─ gateway_data = GatewayData { │ +│ public_key: wireguard_data.public_key, │ +│ endpoint: format!("{}:{}", announced_ip, port), │ +│ private_ipv4: ipv4, │ +│ private_ipv6: ipv6 │ +│ } │ +│ │ +│ [6] Return │ +│ └─ Ok((gateway_data, client_id)) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [credential_verification()] │ +│ gateway/src/node/lp_listener/registration.rs:87-133 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] Check Mock Mode │ +│ └─ if ecash_verifier.is_mock() { │ +│ inc!(lp_bandwidth_allocated_bytes_total, MOCK_BW) │ +│ return Ok(1073741824) // 1 GB │ +│ } │ +│ │ +│ [2] Create Verifier │ +│ └─ verifier = CredentialVerifier::new( │ +│ CredentialSpendingRequest(request.credential), │ +│ ecash_verifier.clone(), │ +│ BandwidthStorageManager::new(storage, client_id) │ +│ ) │ +│ │ +│ [3] Verify Credential (multi-step) │ +│ └─ allocated_bandwidth = verifier.verify().await? │ +│ ↓ │ +│ [Internal Steps]: │ +│ ├─ Check nullifier not spent: │ +│ │ SQL: SELECT COUNT(*) FROM spent_credentials │ +│ │ WHERE nullifier = ? │ +│ │ if count > 0: return Err(AlreadySpent) │ +│ │ │ +│ ├─ Verify BLS signature: │ +│ │ if !bls12_381_verify( │ +│ │ public_key: ecash_verifier.public_key(), │ +│ │ message: hash(gateway_id + bw + expiry), │ +│ │ signature: credential.signature │ +│ │ ): return Err(InvalidSignature) │ +│ │ │ +│ ├─ Mark nullifier spent: │ +│ │ SQL: INSERT INTO spent_credentials │ +│ │ (nullifier, expiry, spent_at) │ +│ │ VALUES (?, ?, NOW()) │ +│ │ │ +│ └─ Allocate bandwidth: │ +│ SQL: UPDATE bandwidth │ +│ SET available = available + ? │ +│ WHERE client_id = ? │ +│ → allocated_bandwidth = credential.bandwidth_amount │ +│ │ +│ [4] Update Metrics │ +│ ├─ inc_by!(lp_bandwidth_allocated_bytes_total, allocated) │ +│ └─ inc!(lp_credential_verification_success) │ +│ │ +│ [5] Return │ +│ └─ Ok(allocated_bandwidth) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ (Back to process_registration) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [Build Success Response] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ response = LpRegistrationResponse { │ +│ success: true, │ +│ error: None, │ +│ gateway_data: Some(gateway_data), // dVPN only │ +│ allocated_bandwidth, │ +│ session_id │ +│ } │ +│ │ +│ inc!(lp_registration_success_total) │ +│ inc!(lp_registration_dvpn_success) // or mixnet │ +│ observe!(lp_registration_duration_seconds, duration) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ (Back to handler) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [Send Response] │ +│ gateway/src/node/lp_listener/handler.rs:177-211 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] Serialize │ +│ └─ response_bytes = bincode::serialize(&response)? │ +│ │ +│ [2] Encrypt │ +│ ├─ action = state_machine.process_input( │ +│ │ SendData(response_bytes) │ +│ │ ) │ +│ └─ packet = match action { │ +│ SendPacket(p) => p, │ +│ _ => unreachable!() │ +│ } │ +│ │ +│ [3] Send │ +│ └─ send_packet(stream, &packet).await? │ +│ │ +│ [4] Cleanup │ +│ ├─ dec!(active_lp_connections) │ +│ ├─ inc!(lp_connections_completed_gracefully) │ +│ └─ observe!(lp_connection_duration_seconds, total_duration) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Code References**: +- Listener: `gateway/src/node/lp_listener/mod.rs:226-289` +- Handler: `gateway/src/node/lp_listener/handler.rs:101-478` +- Registration: `gateway/src/node/lp_listener/registration.rs:58-404` + +--- + +## 3. Client Architecture + +### 3.1. Client Module Structure + +``` +nym-registration-client/src/ +│ +└─ lp_client/ + ├─ mod.rs [Module exports] + ├─ client.rs [Main client implementation] + │ ├─ LpRegistrationClient + │ │ ├─ new() + │ │ ├─ connect() + │ │ ├─ perform_handshake() + │ │ ├─ send_registration_request() + │ │ ├─ receive_registration_response() + │ │ └─ [private helpers] + │ │ + │ ├─ send_packet() [Packet I/O] + │ └─ receive_packet() + │ + └─ error.rs [Error types] + └─ LpClientError +``` + +### 3.2. Client Workflow + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Application (e.g., nym-gateway-probe, nym-vpn-client) │ +└───────────────────────────────────┬───────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [Create LP Client] │ +│ nym-registration-client/src/lp_client/client.rs:64-132 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ let mut client = LpRegistrationClient::new_with_default_psk( │ +│ client_lp_keypair, // X25519 keypair │ +│ gateway_lp_public_key, // X25519 public (from ed25519) │ +│ gateway_lp_address, // SocketAddr (IP:41264) │ +│ client_ip, // Client's IP address │ +│ LpConfig::default() // Timeouts, TCP_NODELAY, etc. │ +│ ); │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [1] Connect to Gateway │ +│ client.rs:133-169 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ client.connect().await? │ +│ ↓ │ +│ stream = tokio::time::timeout( │ +│ self.config.connect_timeout, // e.g., 5 seconds │ +│ TcpStream::connect(self.gateway_lp_address) │ +│ ).await? │ +│ ↓ │ +│ stream.set_nodelay(self.config.tcp_nodelay)? // true │ +│ ↓ │ +│ self.tcp_stream = Some(stream) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [2] Perform Noise Handshake │ +│ client.rs:212-325 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ client.perform_handshake().await? │ +│ ↓ │ +│ [A] Generate ClientHello: │ +│ ├─ salt = random_bytes(32) │ +│ ├─ client_hello_data = ClientHelloData { │ +│ │ client_public_key: self.local_keypair.public, │ +│ │ salt, │ +│ │ timestamp: unix_timestamp(), │ +│ │ protocol_version: 1 │ +│ │ } │ +│ └─ packet = LpPacket { │ +│ header: LpHeader { session_id: 0, seq: 0 }, │ +│ message: ClientHello(client_hello_data) │ +│ } │ +│ │ +│ [B] Send ClientHello: │ +│ └─ Self::send_packet(stream, &packet).await? │ +│ │ +│ [C] Derive PSK: │ +│ └─ psk = nym_lp::derive_psk( │ +│ self.local_keypair.private, │ +│ &self.gateway_public_key, │ +│ &salt │ +│ ) │ +│ │ +│ [D] Create State Machine: │ +│ └─ state_machine = LpStateMachine::new( │ +│ is_initiator: true, │ +│ local_keypair: &self.local_keypair, │ +│ remote_pubkey: &self.gateway_public_key, │ +│ psk: &psk │ +│ )? │ +│ │ +│ [E] Exchange Handshake Messages: │ +│ └─ loop { │ +│ match state_machine.current_state() { │ +│ WaitingForHandshake => │ +│ // Send initial handshake packet │ +│ action = state_machine.process_input( │ +│ StartHandshake │ +│ )? │ +│ packet = match action { │ +│ SendPacket(p) => p, │ +│ _ => unreachable!() │ +│ } │ +│ Self::send_packet(stream, &packet).await? │ +│ │ +│ HandshakeInProgress => │ +│ // Receive gateway response │ +│ packet = Self::receive_packet(stream).await? │ +│ action = state_machine.process_input( │ +│ ReceivePacket(packet) │ +│ )? │ +│ if let SendPacket(p) = action { │ +│ Self::send_packet(stream, &p).await? │ +│ } │ +│ │ +│ HandshakeComplete => │ +│ break // Done! │ +│ │ +│ _ => return Err(...) │ +│ } │ +│ } │ +│ │ +│ [F] Store State Machine: │ +│ └─ self.state_machine = Some(state_machine) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [3] Send Registration Request │ +│ client.rs:433-507 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ client.send_registration_request( │ +│ wg_public_key, │ +│ bandwidth_controller, │ +│ ticket_type │ +│ ).await? │ +│ ↓ │ +│ [A] Acquire Bandwidth Credential: │ +│ └─ credential = bandwidth_controller │ +│ .get_ecash_ticket( │ +│ ticket_type, │ +│ gateway_identity, │ +│ DEFAULT_TICKETS_TO_SPEND // e.g., 1 │ +│ ).await? │ +│ .data // CredentialSpendingData │ +│ │ +│ [B] Build Request: │ +│ └─ request = LpRegistrationRequest::new_dvpn( │ +│ wg_public_key, │ +│ credential, │ +│ ticket_type, │ +│ self.client_ip │ +│ ) │ +│ │ +│ [C] Serialize: │ +│ └─ request_bytes = bincode::serialize(&request)? │ +│ │ +│ [D] Encrypt via State Machine: │ +│ ├─ state_machine = self.state_machine.as_mut()? │ +│ ├─ action = state_machine.process_input( │ +│ │ LpInput::SendData(request_bytes) │ +│ │ )? │ +│ └─ packet = match action { │ +│ LpAction::SendPacket(p) => p, │ +│ _ => return Err(...) │ +│ } │ +│ │ +│ [E] Send: │ +│ └─ Self::send_packet(stream, &packet).await? │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [4] Receive Registration Response │ +│ client.rs:615-715 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ gateway_data = client.receive_registration_response().await? │ +│ ↓ │ +│ [A] Receive Packet: │ +│ └─ packet = Self::receive_packet(stream).await? │ +│ │ +│ [B] Decrypt via State Machine: │ +│ ├─ state_machine = self.state_machine.as_mut()? │ +│ ├─ action = state_machine.process_input( │ +│ │ LpInput::ReceivePacket(packet) │ +│ │ )? │ +│ └─ response_data = match action { │ +│ LpAction::DeliverData(data) => data, │ +│ _ => return Err(UnexpectedAction) │ +│ } │ +│ │ +│ [C] Deserialize: │ +│ └─ response = bincode::deserialize::< │ +│ LpRegistrationResponse │ +│ >(&response_data)? │ +│ │ +│ [D] Validate: │ +│ ├─ if !response.success { │ +│ │ return Err(RegistrationRejected { │ +│ │ reason: response.error.unwrap_or_default() │ +│ │ }) │ +│ │ } │ +│ └─ gateway_data = response.gateway_data │ +│ .ok_or(MissingGatewayData)? │ +│ │ +│ [E] Return: │ +│ └─ Ok(gateway_data) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ [Application: Setup WireGuard Tunnel] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ // Client now has: │ +│ // • gateway_data.public_key (WireGuard public key) │ +│ // • gateway_data.endpoint (IP:port) │ +│ // • gateway_data.private_ipv4 (10.1.0.x) │ +│ // • gateway_data.private_ipv6 (fd00::x) │ +│ // • wg_private_key (from wg_keypair generated earlier) │ +│ │ +│ wg_config = format!(r#" │ +│ [Interface] │ +│ PrivateKey = {} │ +│ Address = {}/32, {}/128 │ +│ │ +│ [Peer] │ +│ PublicKey = {} │ +│ Endpoint = {} │ +│ AllowedIPs = 0.0.0.0/0, ::/0 │ +│ PersistentKeepalive = 25 │ +│ "#, │ +│ wg_private_key, │ +│ gateway_data.private_ipv4, │ +│ gateway_data.private_ipv6, │ +│ gateway_data.public_key, │ +│ gateway_data.endpoint │ +│ ) │ +│ │ +│ // Apply config via wg-quick or wireguard-rs │ +│ wireguard_tunnel.set_config(wg_config).await? │ +│ │ +│ ✅ VPN tunnel established! │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Code References**: +- Client main: `nym-registration-client/src/lp_client/client.rs:39-780` +- Packet I/O: `nym-registration-client/src/lp_client/client.rs:333-431` + +--- + +## 4. Shared Protocol Library + +### 4.1. nym-lp Module Structure + +``` +common/nym-lp/src/ +│ +├─ lib.rs [Public API exports] +│ ├─ pub use session::* +│ ├─ pub use state_machine::* +│ ├─ pub use psk::* +│ └─ ... +│ +├─ session.rs [LP session management] +│ └─ LpSession +│ ├─ new_initiator() +│ ├─ new_responder() +│ ├─ encrypt() +│ ├─ decrypt() +│ └─ [replay validation] +│ +├─ state_machine.rs [Noise protocol state machine] +│ ├─ LpStateMachine +│ │ ├─ new() +│ │ ├─ process_input() +│ │ └─ current_state() +│ │ +│ ├─ LpState (enum) +│ │ ├─ WaitingForHandshake +│ │ ├─ HandshakeInProgress +│ │ ├─ HandshakeComplete +│ │ └─ Failed +│ │ +│ ├─ LpInput (enum) +│ │ ├─ StartHandshake +│ │ ├─ ReceivePacket(LpPacket) +│ │ └─ SendData(Vec) +│ │ +│ └─ LpAction (enum) +│ ├─ SendPacket(LpPacket) +│ ├─ DeliverData(Vec) +│ └─ HandshakeComplete +│ +├─ noise_protocol.rs [Noise XKpsk3 implementation] +│ └─ LpNoiseProtocol +│ ├─ new() +│ ├─ build_initiator() +│ ├─ build_responder() +│ └─ into_transport_mode() +│ +├─ psk.rs [PSK derivation] +│ └─ derive_psk(secret_key, public_key, salt) -> [u8; 32] +│ +├─ keypair.rs [X25519 keypair management] +│ └─ Keypair +│ ├─ generate() +│ ├─ from_bytes() +│ └─ ed25519_to_x25519() +│ +├─ packet.rs [Packet structure] +│ ├─ LpPacket { header, message } +│ └─ LpHeader { session_id, seq, flags } +│ +├─ message.rs [Message types] +│ └─ LpMessage (enum) +│ ├─ ClientHello(ClientHelloData) +│ ├─ Handshake(Vec) +│ ├─ EncryptedData(Vec) +│ └─ Busy +│ +├─ codec.rs [Serialization] +│ ├─ serialize_lp_packet() +│ └─ parse_lp_packet() +│ +└─ replay/ [Replay protection] + ├─ validator.rs [Main validator] + │ └─ ReplayValidator + │ ├─ new() + │ └─ validate(nonce: u64) -> bool + │ + └─ simd/ [SIMD optimizations] + ├─ mod.rs + ├─ avx2.rs [AVX2 bitmap ops] + ├─ sse2.rs [SSE2 bitmap ops] + ├─ neon.rs [ARM NEON ops] + └─ scalar.rs [Fallback scalar ops] +``` + +### 4.2. State Machine State Transitions + +``` +┌────────────────────────────────────────────────────────────────┐ +│ LP State Machine (Initiator) │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ [Initial State] │ +│ WaitingForHandshake │ +│ │ │ +│ │ Input: StartHandshake │ +│ │ Action: SendPacket(Handshake msg 1) │ +│ ▼ │ +│ HandshakeInProgress │ +│ │ │ +│ │ Input: ReceivePacket(Handshake msg 2) │ +│ │ Action: SendPacket(Handshake msg 3) │ +│ │ HandshakeComplete │ +│ ▼ │ +│ HandshakeComplete ──────────────────┐ │ +│ │ │ │ +│ │ Input: SendData(plaintext) │ Input: ReceivePacket │ +│ │ Action: SendPacket(encrypted) │ Action: DeliverData │ +│ └─────────────┬────────────────────┘ │ +│ │ │ +│ │ (stays in HandshakeComplete) │ +│ │ │ +│ ┌─────────────▼────────────────────────┐ │ +│ │ Any state + error input: │ │ +│ │ → Failed │ │ +│ └──────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────┐ +│ LP State Machine (Responder) │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ [Initial State] │ +│ WaitingForHandshake │ +│ │ │ +│ │ Input: ReceivePacket(Handshake msg 1) │ +│ │ Action: SendPacket(Handshake msg 2) │ +│ ▼ │ +│ HandshakeInProgress │ +│ │ │ +│ │ Input: ReceivePacket(Handshake msg 3) │ +│ │ Action: HandshakeComplete │ +│ ▼ │ +│ HandshakeComplete ──────────────────┐ │ +│ │ │ │ +│ │ Input: SendData(plaintext) │ Input: ReceivePacket │ +│ │ Action: SendPacket(encrypted) │ Action: DeliverData │ +│ └─────────────┬────────────────────┘ │ +│ │ │ +│ │ (stays in HandshakeComplete) │ +│ │ │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Code References**: +- State machine: `common/nym-lp/src/state_machine.rs:96-420` +- Session: `common/nym-lp/src/session.rs:45-180` + +--- + +## 5. Data Flow Diagrams + +### 5.1. Successful dVPN Registration Data Flow + +``` +Client Gateway DB WG Controller Blockchain + │ │ │ │ │ + │ [TCP Connect] │ │ │ │ + ├─────────────────────>│ │ │ │ + │ │ │ │ │ + │ [ClientHello] │ │ │ │ + ├─────────────────────>│ │ │ │ + │ │ [validate time] │ │ │ + │ │ │ │ │ + │ [Noise Handshake] │ │ │ │ + │<────────────────────>│ │ │ │ + │ (3 messages) │ │ │ │ + │ │ │ │ │ + │ [Encrypted Request] │ │ │ │ + │ • wg_pub_key │ │ │ │ + │ • credential │ │ │ │ + │ • mode: Dvpn │ │ │ │ + ├─────────────────────>│ │ │ │ + │ │ [decrypt] │ │ │ + │ │ │ │ │ + │ │ [register_wg_peer] │ │ + │ │ │ │ │ + │ │ INSERT peer │ │ │ + │ ├─────────────────>│ │ │ + │ │ ← client_id: 123 │ │ │ + │ │ │ │ │ + │ │ INSERT bandwidth │ │ │ + │ ├─────────────────>│ │ │ + │ │ ← OK │ │ │ + │ │ │ │ │ + │ │ AddPeer request │ │ │ + │ ├────────────────────────────────────────> │ + │ │ │ wg set wg0 peer... │ │ + │ │ │ ← OK │ │ + │ │ ← AddPeer OK ────────────────────────┤ │ + │ │ │ │ │ + │ │ [credential_verification] │ │ + │ │ │ │ │ + │ │ SELECT nullifier │ │ │ + │ ├─────────────────>│ │ │ + │ │ ← count: 0 │ │ │ + │ │ │ │ │ + │ │ [verify BLS sig] │ │ │ + │ │ │ │ [query │ + │ │ │ │ public key]│ + │ │ │ │<─────────────┤ + │ │ │ │ ← pub_key ───┤ + │ │ │ │ │ + │ │ ✓ signature OK │ │ │ + │ │ │ │ │ + │ │ INSERT nullifier │ │ │ + │ ├─────────────────>│ │ │ + │ │ ← OK │ │ │ + │ │ │ │ │ + │ │ UPDATE bandwidth │ │ │ + │ ├─────────────────>│ │ │ + │ │ ← OK │ │ │ + │ │ │ │ │ + │ │ [build response] │ │ │ + │ │ [encrypt] │ │ │ + │ │ │ │ │ + │ [Encrypted Response] │ │ │ │ + │ • success: true │ │ │ │ + │ • gateway_data │ │ │ │ + │ • allocated_bw │ │ │ │ + │<─────────────────────┤ │ │ │ + │ │ │ │ │ + │ [decrypt] │ │ │ │ + │ ✓ Registration OK │ │ │ │ + │ │ │ │ │ + +[Client sets up WireGuard tunnel with gateway_data] +``` + +### 5.2. Error Flow: Credential Already Spent + +``` +Client Gateway DB + │ │ │ + │ ... (handshake)... │ │ + │ │ │ + │ [Encrypted Request] │ │ + │ • credential │ │ + │ (nullifier reused)│ │ + ├─────────────────────>│ │ + │ │ [decrypt] │ + │ │ │ + │ │ [credential_verification] + │ │ │ + │ │ SELECT nullifier │ + │ ├─────────────────>│ + │ │ ← count: 1 ✗ │ + │ │ │ + │ │ ✗ AlreadySpent │ + │ │ │ + │ │ [build error] │ + │ │ [encrypt] │ + │ │ │ + │ [Encrypted Response] │ │ + │ • success: false │ │ + │ • error: "Credential│ │ + │ already spent" │ │ + │<─────────────────────┤ │ + │ │ │ + │ ✗ Registration Failed│ │ + │ │ │ + +[Client must acquire new credential and retry] +``` + +**Code References**: +- Overall flow: See sequence diagrams in `LP_REGISTRATION_SEQUENCES.md` +- Data structures: `common/registration/src/lp_messages.rs` + +--- + +## 6. State Machines + +### 6.1. Replay Protection State + +**ReplayValidator maintains sliding window for nonce validation**: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ReplayValidator State │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ struct ReplayValidator { │ +│ nonce_high: u64, // Highest seen nonce │ +│ nonce_low: u64, // Lowest in window │ +│ seen_bitmap: [u64; 16] // Bitmap: 1024 bits total │ +│ } │ +│ │ +│ Window size: 1024 packets │ +│ Memory: 144 bytes per session │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [Validation Algorithm] │ +│ │ +│ validate(nonce: u64) -> Result { │ +│ // Case 1: nonce too old (outside window) │ +│ if nonce < nonce_low: │ +│ return Ok(false) // Reject: too old │ +│ │ +│ // Case 2: nonce within current window │ +│ if nonce <= nonce_high: │ +│ offset = (nonce - nonce_low) as usize │ +│ bucket_idx = offset / 64 │ +│ bit_idx = offset % 64 │ +│ bit_mask = 1u64 << bit_idx │ +│ ↓ │ +│ if seen_bitmap[bucket_idx] & bit_mask != 0: │ +│ return Ok(false) // Reject: duplicate │ +│ ↓ │ +│ // Mark as seen (SIMD-optimized if available) │ +│ seen_bitmap[bucket_idx] |= bit_mask │ +│ return Ok(true) // Accept │ +│ │ +│ // Case 3: nonce advances window │ +│ if nonce > nonce_high: │ +│ advance = nonce - nonce_high │ +│ ↓ │ +│ if advance >= 1024: │ +│ // Reset entire window │ +│ seen_bitmap.fill(0) │ +│ nonce_low = nonce │ +│ nonce_high = nonce │ +│ else: │ +│ // Shift window by 'advance' bits │ +│ shift_bitmap_left(&mut seen_bitmap, advance) │ +│ nonce_low += advance │ +│ nonce_high = nonce │ +│ ↓ │ +│ // Mark new nonce as seen │ +│ offset = (nonce - nonce_low) as usize │ +│ bucket_idx = offset / 64 │ +│ bit_idx = offset % 64 │ +│ seen_bitmap[bucket_idx] |= 1u64 << bit_idx │ +│ return Ok(true) // Accept │ +│ } │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +[Visualization of Sliding Window] + +Time ──────────────────────────────────────────────────────────> + +Packet nonces: 100 101 102 ... 1123 [1124 arrives] + │ │ + nonce_low nonce_high + +Bitmap (1024 bits): + [111111111111...111111111110000000000000000000000] + ↑ bit 0 ↑ bit 1023 (most recent) + (nonce 100) (nonce 1123) + +When nonce 1124 arrives: + 1. Shift bitmap left by 1 bit + 2. nonce_low = 101 + 3. nonce_high = 1124 + 4. Set bit 1023 (for nonce 1124) + +Bitmap becomes: + [11111111111...1111111111100000000000000000000] + ↑ bit 0 ↑ bit 1023 + (nonce 101) (nonce 1124) +``` + +**Code References**: +- Replay validator: `common/nym-lp/src/replay/validator.rs:25-125` +- SIMD ops: `common/nym-lp/src/replay/simd/` + +--- + +## 7. Database Schema + +### 7.1. Gateway Database Tables + +```sql +-- WireGuard peers table +CREATE TABLE wireguard_peers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- client_id + public_key BLOB NOT NULL UNIQUE, -- WireGuard public key [32 bytes] + ticket_type TEXT NOT NULL, -- "V1MixnetEntry", etc. + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP, + INDEX idx_public_key (public_key) +); + +-- Bandwidth tracking table +CREATE TABLE bandwidth ( + client_id INTEGER PRIMARY KEY, + available INTEGER NOT NULL DEFAULT 0, -- Bytes remaining + used INTEGER NOT NULL DEFAULT 0, -- Bytes consumed + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES wireguard_peers(id) + ON DELETE CASCADE +); + +-- Spent credentials (nullifier tracking) +CREATE TABLE spent_credentials ( + nullifier BLOB PRIMARY KEY, -- Credential nullifier [32 bytes] + expiry TIMESTAMP NOT NULL, -- Credential expiration + spent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + client_id INTEGER, -- Optional link to client + FOREIGN KEY (client_id) REFERENCES wireguard_peers(id) + ON DELETE SET NULL, + INDEX idx_nullifier (nullifier), -- Critical for performance! + INDEX idx_expiry (expiry) -- For cleanup queries +); + +-- LP session tracking (optional, for metrics/debugging) +CREATE TABLE lp_sessions ( + session_id INTEGER PRIMARY KEY, + client_ip TEXT NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + status TEXT, -- "success", "handshake_failed", "credential_rejected", etc. + client_id INTEGER, + FOREIGN KEY (client_id) REFERENCES wireguard_peers(id) + ON DELETE SET NULL +); +``` + +### 7.2. Database Operations by Component + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Registration Flow DB Ops │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [1] register_wg_peer() │ +│ ├─ INSERT INTO wireguard_peers │ +│ │ (public_key, ticket_type) │ +│ │ VALUES (?, ?) │ +│ │ RETURNING id │ +│ │ → client_id │ +│ │ │ +│ └─ INSERT INTO bandwidth │ +│ (client_id, available) │ +│ VALUES (?, 0) │ +│ │ +│ [2] credential_verification() │ +│ ├─ SELECT COUNT(*) FROM spent_credentials │ +│ │ WHERE nullifier = ? │ +│ │ → count (should be 0) │ +│ │ │ +│ ├─ INSERT INTO spent_credentials │ +│ │ (nullifier, expiry, client_id) │ +│ │ VALUES (?, ?, ?) │ +│ │ │ +│ └─ UPDATE bandwidth │ +│ SET available = available + ?, │ +│ updated_at = NOW() │ +│ WHERE client_id = ? │ +│ │ +│ [3] Connection lifecycle (optional) │ +│ ├─ INSERT INTO lp_sessions │ +│ │ (session_id, client_ip, status) │ +│ │ VALUES (?, ?, 'in_progress') │ +│ │ │ +│ └─ UPDATE lp_sessions │ +│ SET completed_at = NOW(), │ +│ status = 'success', │ +│ client_id = ? │ +│ WHERE session_id = ? │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +[Cleanup/Maintenance Queries] + +-- Remove expired nullifiers (run daily) +DELETE FROM spent_credentials +WHERE expiry < datetime('now', '-30 days'); + +-- Find stale WireGuard peers (not seen in 7 days) +SELECT p.id, p.public_key, p.last_seen +FROM wireguard_peers p +WHERE p.last_seen < datetime('now', '-7 days'); + +-- Bandwidth usage report +SELECT + p.public_key, + b.available, + b.used, + b.updated_at +FROM wireguard_peers p +JOIN bandwidth b ON b.client_id = p.id +ORDER BY b.used DESC +LIMIT 100; +``` + +**Code References**: +- Database models: Gateway storage module +- Queries: `gateway/src/node/lp_listener/registration.rs` + +--- + +## 8. Integration Points + +### 8.1. External System Integration + +``` +┌──────────────────────────────────────────────────────────────┐ +│ LP Registration Integrations │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ [1] Blockchain (Nym Chain / Nyx) │ +│ ├─ E-cash Contract │ +│ │ ├─ Query: Get public verification keys │ +│ │ ├─ Used by: EcashManager in gateway │ +│ │ └─ Frequency: Cached, refreshed periodically │ +│ │ │ +│ └─ Mixnet Contract (optional, future) │ +│ ├─ Query: Gateway info, capabilities │ +│ └─ Used by: Client gateway selection │ +│ │ +│ [2] WireGuard Daemon │ +│ ├─ Interface: Netlink / wg(8) command │ +│ │ ├─ AddPeer: wg set wg0 peer allowed-ips ... │ +│ │ ├─ RemovePeer: wg set wg0 peer remove │ +│ │ └─ ListPeers: wg show wg0 dump │ +│ │ │ +│ ├─ Used by: WireGuard Controller (gateway) │ +│ ├─ Communication: mpsc channel (async) │ +│ └─ Frequency: Per registration/deregistration │ +│ │ +│ [3] Gateway Storage (SQLite/PostgreSQL) │ +│ ├─ Tables: wireguard_peers, bandwidth, spent_credentials │ +│ ├─ Used by: LP registration, credential verification │ +│ ├─ Access: SQLx (async, type-safe) │ +│ └─ Transactions: Required for peer registration │ +│ │ +│ [4] Metrics System (Prometheus) │ +│ ├─ Exporter: Built into nym-node │ +│ ├─ Endpoint: http://:8080/metrics │ +│ ├─ Metrics: lp_* namespace (see main doc) │ +│ └─ Scrape interval: Typically 15-60s │ +│ │ +│ [5] BandwidthController (Client-side) │ +│ ├─ Purpose: Acquire e-cash credentials │ +│ ├─ Methods: │ +│ │ └─ get_ecash_ticket(type, gateway, count) │ +│ │ → CredentialSpendingData │ +│ │ │ +│ ├─ Blockchain interaction: Queries + blind signing │ +│ └─ Used by: LP client before registration │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 8.2. Module Dependencies + +``` +[Gateway Dependencies] + +nym-node (gateway mode) + ├─ gateway/src/node/lp_listener/ + │ ├─ Depends on: + │ │ ├─ common/nym-lp (protocol library) + │ │ ├─ common/registration (message types) + │ │ ├─ gateway/storage (database) + │ │ ├─ gateway/wireguard (WG controller) + │ │ └─ common/bandwidth-controller (e-cash verification) + │ │ + │ └─ Provides: + │ └─ LP registration service (:41264) + │ + ├─ gateway/src/node/wireguard/ + │ ├─ Depends on: + │ │ ├─ wireguard-rs (WG tunnel) + │ │ └─ gateway/storage (peer tracking) + │ │ + │ └─ Provides: + │ ├─ PeerController (mpsc handler) + │ └─ WireGuard daemon interface + │ + └─ gateway/src/node/storage/ + ├─ Depends on: + │ └─ sqlx (database access) + │ + └─ Provides: + ├─ GatewayStorage trait + └─ Database operations + +[Client Dependencies] + +nym-vpn-client (or other app) + ├─ nym-registration-client/ + │ ├─ Depends on: + │ │ ├─ common/nym-lp (protocol library) + │ │ ├─ common/registration (message types) + │ │ └─ common/bandwidth-controller (credentials) + │ │ + │ └─ Provides: + │ └─ LpRegistrationClient + │ + ├─ common/bandwidth-controller/ + │ ├─ Depends on: + │ │ ├─ Blockchain RPC client + │ │ └─ E-cash cryptography + │ │ + │ └─ Provides: + │ ├─ BandwidthController + │ └─ Credential acquisition + │ + └─ wireguard-rs/ + ├─ Depends on: + │ └─ System WireGuard + │ + └─ Provides: + └─ Tunnel management + +[Shared Dependencies] + +common/nym-lp/ + ├─ Depends on: + │ ├─ snow (Noise protocol) + │ ├─ x25519-dalek (ECDH) + │ ├─ chacha20poly1305 (AEAD) + │ ├─ blake3 (KDF, hashing) + │ ├─ bincode (serialization) + │ └─ tokio (async runtime) + │ + └─ Provides: + ├─ LpStateMachine + ├─ LpSession + ├─ Noise protocol + ├─ PSK derivation + ├─ Replay protection + └─ Message types + +common/registration/ + ├─ Depends on: + │ ├─ serde (serialization) + │ └─ common/crypto (credential types) + │ + └─ Provides: + ├─ LpRegistrationRequest + ├─ LpRegistrationResponse + └─ GatewayData +``` + +**Code References**: +- Gateway dependencies: `gateway/Cargo.toml` +- Client dependencies: `nym-registration-client/Cargo.toml` +- Protocol dependencies: `common/nym-lp/Cargo.toml` + +--- + +## Summary + +This document provides complete architectural details for: + +1. **System Overview**: High-level component interaction +2. **Gateway Architecture**: Module structure, connection flow, data processing +3. **Client Architecture**: Workflow from connection to WireGuard setup +4. **Shared Protocol Library**: nym-lp module organization and state machines +5. **Data Flow**: Successful and error case flows with database operations +6. **State Machines**: Handshake states and replay protection +7. **Database Schema**: Tables, indexes, and operations +8. **Integration Points**: External systems and module dependencies + +**All diagrams include**: +- Component boundaries +- Data flow arrows +- Code references (file:line) +- Database operations +- External system calls + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-11-11 +**Maintainer**: @drazen diff --git a/docs/LP_REGISTRATION_SEQUENCES.md b/docs/LP_REGISTRATION_SEQUENCES.md new file mode 100644 index 00000000000..9d015d4e3c3 --- /dev/null +++ b/docs/LP_REGISTRATION_SEQUENCES.md @@ -0,0 +1,1441 @@ +# LP Registration - Detailed Sequence Diagrams + +**Technical deep-dive for engineering team** + +--- + +## Table of Contents + +- [LP Registration - Detailed Sequence Diagrams](#lp-registration---detailed-sequence-diagrams) + - [Table of Contents](#table-of-contents) + - [1. Happy Path: Successful dVPN Registration](#1-happy-path-successful-dvpn-registration) + - [2. Error Scenario: Timestamp Validation Failure](#2-error-scenario-timestamp-validation-failure) + - [3. Error Scenario: Credential Rejected](#3-error-scenario-credential-rejected) + - [4. Noise XKpsk3 Handshake Detail](#4-noise-xkpsk3-handshake-detail) + - [7. PSK Derivation Flow](#7-psk-derivation-flow) + - [8. Message Format Specifications](#8-message-format-specifications) + - [8.1. Packet Framing (Transport Layer)](#81-packet-framing-transport-layer) + - [8.2. LpPacket Structure](#82-lppacket-structure) + - [8.3. ClientHello Message](#83-clienthello-message) + - [8.4. Noise Handshake Messages](#84-noise-handshake-messages) + - [8.5. LpRegistrationRequest](#85-lpregistrationrequest) + - [8.6. LpRegistrationResponse](#86-lpregistrationresponse) + - [8.7. Encrypted Data Format](#87-encrypted-data-format) + - [Summary](#summary) + +--- + +## 1. Happy Path: Successful dVPN Registration + +**Complete flow from TCP connect to WireGuard peer setup** + +``` +Client Gateway +(LpRegistrationClient) (LpConnectionHandler) + | | + | [0] Setup Phase | + |──────────────────────────────────────────────────────────| + | | + | Generate LP keypair (X25519) | Load gateway identity (Ed25519) + | client_lp_keypair = LpKeypair::default() | Convert to X25519: + | → secret_key: [32 bytes] | gw_lp_keypair = ed25519_to_x25519(gw_identity) + | → public_key: [32 bytes] | → secret_key: [32 bytes] + | | → public_key: [32 bytes] + | | + | [1] TCP Connection | + |──────────────────────────────────────────────────────────| + | | + |-- TCP SYN ──────────────────────────────────────────────>| bind(0.0.0.0:41264) + | | accept() + |<─ TCP SYN-ACK ───────────────────────────────────────────| + | | + |-- TCP ACK ──────────────────────────────────────────────>| spawn(handle_connection) + | | ↓ + | | inc!(lp_connections_total) + | | inc!(active_lp_connections) + | | + | ✓ Connection established | + | Duration: ~12ms | + | [client.rs:133-169] | [mod.rs:271-289] + | | + | | + | [2] ClientHello (Cleartext PSK Setup) | + |──────────────────────────────────────────────────────────| + | | + | Generate fresh salt: | + | salt = random_bytes(32) | + | | + | Build ClientHello: | + | ┌──────────────────────────────────────────────────┐ | + | │ LpPacket { │ | + | │ header: LpHeader { │ | + | │ session_id: 0, │ | + | │ sequence_number: 0, │ | + | │ flags: 0, │ | + | │ }, │ | + | │ message: ClientHello(ClientHelloData { │ | + | │ client_public_key: client_lp_keypair.public, │ | + | │ salt: [32 bytes], │ | + | │ timestamp: unix_timestamp(), │ | + | │ protocol_version: 1, │ | + | │ }) │ | + | │ } │ | + | └──────────────────────────────────────────────────┘ | + | | + | Serialize (bincode): | + | packet_bytes = serialize_lp_packet(client_hello) | + | | + | Frame (length-prefix): | + | frame = [len as u32 BE (4 bytes)] + packet_bytes | + | | + |-- [4 byte len][ClientHello packet] ────────────────────>| receive_client_hello() + | | ↓ + | | Read 4 bytes → packet_len + | | Validate: packet_len <= 65536 + | | Read packet_len bytes → packet_buf + | | Deserialize → ClientHelloData + | | ↓ + | | Extract: + | | client_public_key: PublicKey + | | salt: [u8; 32] + | | timestamp: u64 + | | ↓ + | | validate_timestamp(timestamp): + | | now = SystemTime::now() + | | client_time = UNIX_EPOCH + Duration(timestamp) + | | diff = abs(now - client_time) + | | if diff > 30s: + | | inc!(lp_client_hello_failed{reason="timestamp"}) + | | return ERROR + | | ↓ + | | ✓ Timestamp valid (within ±30s) + | | + | Duration: ~8ms | [handler.rs:275-323, 233-261] + | | + | | + | [3] PSK Derivation (Both Sides) | + |──────────────────────────────────────────────────────────| + | | + | Client computes PSK: | Gateway computes PSK: + | psk = derive_psk( | psk = derive_psk( + | client_lp_keypair.secret, | gw_lp_keypair.secret, + | gw_lp_keypair.public, | client_public_key, + | salt | salt + | ) | ) + | ↓ | ↓ + | shared_secret = ECDH(client_secret, gw_public) | shared_secret = ECDH(gw_secret, client_public) + | → [32 bytes] | → [32 bytes] (same as client!) + | ↓ | ↓ + | hasher = Blake3::new_keyed(PSK_KDF_KEY) | hasher = Blake3::new_keyed(PSK_KDF_KEY) + | hasher.update(b"nym-lp-psk-v1") | hasher.update(b"nym-lp-psk-v1") + | hasher.update(shared_secret) | hasher.update(shared_secret) + | hasher.update(salt) | hasher.update(salt) + | ↓ | ↓ + | psk = hasher.finalize_xof().read(32 bytes) | psk = hasher.finalize_xof().read(32 bytes) + | → [32 bytes PSK] | → [32 bytes PSK] (same as client!) + | | + | [psk.rs:28-52] | [psk.rs:28-52] + | | + | | + | [4] Noise XKpsk3 Handshake (3-way) | + |──────────────────────────────────────────────────────────| + | | + | Create state machine as INITIATOR: | Create state machine as RESPONDER: + | state_machine = LpStateMachine::new( | state_machine = LpStateMachine::new( + | is_initiator: true, | is_initiator: false, + | local_keypair: client_lp_keypair, | local_keypair: gw_lp_keypair, + | remote_pubkey: gw_lp_keypair.public, | remote_pubkey: client_public_key, + | psk: psk | psk: psk + | ) | ) + | ↓ | ↓ + | noise = NoiseBuilder() | noise = NoiseBuilder() + | .pattern("Noise_XKpsk3_25519_ChaChaPoly_BLAKE2s") | .pattern("Noise_XKpsk3_25519_ChaChaPoly_BLAKE2s") + | .local_private_key(client_secret) | .local_private_key(gw_secret) + | .remote_public_key(gw_public) | .remote_public_key(client_public) + | .psk(3, psk) // PSK in 3rd message | .psk(3, psk) + | .build_initiator() | .build_responder() + | ↓ | ↓ + | state = HandshakeInProgress | state = WaitingForHandshake + | | + | ──────────────────────────────────────────────────────────────────── + | Handshake Message 1: -> e (ephemeral key exchange) + | ──────────────────────────────────────────────────────────────────── + | | + | action = state_machine.process_input(StartHandshake) | + | ↓ | + | noise.write_message(&[], &mut msg_buf) | + | → msg_buf = client_ephemeral_public [32 bytes] | + | ↓ | + | packet = LpPacket { | + | header: LpHeader { session_id: 0, seq: 1 }, | + | message: Handshake(msg_buf) | + | } | + | | + |-- [len][Handshake: e (32 bytes)] ──────────────────────>| receive_packet() + | | ↓ + | | action = state_machine.process_input( + | | ReceivePacket(packet) + | | ) + | | ↓ + | | noise.read_message(&handshake_data, &mut buf) + | | → client_e_pub extracted + | | → No payload expected (buf empty) + | | + | ──────────────────────────────────────────────────────────────────── + | Handshake Message 2: <- e, ee, s, es (respond with gateway identity) + | ──────────────────────────────────────────────────────────────────── + | | + | | noise.write_message(&[], &mut msg_buf) + | | → e: gw_ephemeral_public [32 bytes] + | | → ee: DH(gw_e_priv, client_e_pub) + | | → s: gw_static_public [32 bytes] (encrypted) + | | → es: DH(gw_e_priv, client_static_pub) + | | ↓ + | | msg_buf = [gw_e_pub (32)] + [encrypted_gw_static (48)] + | | → Total: 80 bytes + | | ↓ + | | packet = LpPacket { + | | header: LpHeader { session_id: 0, seq: 1 }, + | | message: Handshake(msg_buf) + | | } + | | + |<─ [len][Handshake: e,ee,s,es (80 bytes)] ────────────────| send_packet() + | | + | action = state_machine.process_input( | + | ReceivePacket(packet) | + | ) | + | ↓ | + | noise.read_message(&handshake_data, &mut buf) | + | → gw_e_pub extracted | + | → DH(client_e_priv, gw_e_pub) computed | + | → gw_static_pub decrypted and authenticated | + | → DH(client_static_priv, gw_e_pub) computed | + | ↓ | + | ✓ Gateway authenticated | + | | + | ──────────────────────────────────────────────────────────────────── + | Handshake Message 3: -> s, se, psk (final auth + PSK) + | ──────────────────────────────────────────────────────────────────── + | | + | noise.write_message(&[], &mut msg_buf) | + | → s: client_static_public [32 bytes] (encrypted) | + | → se: DH(client_static_priv, gw_e_pub) | + | → psk: Mix in pre-shared key | + | ↓ | + | msg_buf = [encrypted_client_static (48)] | + | → Total: 48 bytes | + | ↓ | + | packet = LpPacket { | + | header: LpHeader { session_id: 0, seq: 2 }, | + | message: Handshake(msg_buf) | + | } | + | | + |-- [len][Handshake: s,se,psk (48 bytes)] ────────────────>| receive_packet() + | | ↓ + | | action = state_machine.process_input( + | | ReceivePacket(packet) + | | ) + | | ↓ + | | noise.read_message(&handshake_data, &mut buf) + | | → client_static_pub decrypted and authenticated + | | → DH(gw_static_priv, client_e_pub) computed + | | → PSK mixed into key material + | | ↓ + | | ✓ Client authenticated + | | ✓ PSK verified (implicitly) + | | + | ──────────────────────────────────────────────────────────────────── + | Handshake Complete! Derive transport keys + | ──────────────────────────────────────────────────────────────────── + | | + | transport = noise.into_transport_mode() | transport = noise.into_transport_mode() + | ↓ | ↓ + | tx_cipher = ChaCha20-Poly1305 (client→gw key) | rx_cipher = ChaCha20-Poly1305 (client→gw key) + | rx_cipher = ChaCha20-Poly1305 (gw→client key) | tx_cipher = ChaCha20-Poly1305 (gw→client key) + | replay_validator = ReplayValidator::new() | replay_validator = ReplayValidator::new() + | → nonce_high: u64 = 0 | → nonce_high: u64 = 0 + | → nonce_low: u64 = 0 | → nonce_low: u64 = 0 + | → seen_bitmap: [u64; 16] = [0; 16] | → seen_bitmap: [u64; 16] = [0; 16] + | ↓ | ↓ + | state = HandshakeComplete | state = HandshakeComplete + | | + | ✓ Encrypted channel established | ✓ Encrypted channel established + | Duration: ~45ms (3 round-trips) | inc!(lp_handshakes_success) + | [client.rs:212-325] | [handler.rs:149-175] + | [state_machine.rs:96-420] | [state_machine.rs:96-420] + | | + | | + | [5] Send Registration Request (Encrypted) | + |──────────────────────────────────────────────────────────| + | | + | Acquire bandwidth credential: | + | credential = bandwidth_controller | + | .get_ecash_ticket( | + | ticket_type, | + | gateway_identity, | + | DEFAULT_TICKETS_TO_SPEND | + | ).await? | + | ↓ | + | CredentialSpendingData { | + | nullifier: [32 bytes], | + | signature: BLS12-381 signature, | + | bandwidth_amount: u64, | + | expiry: u64 | + | } | + | ↓ | + | Generate WireGuard keypair: | + | wg_keypair = wireguard_rs::KeyPair::new(&mut rng) | + | wg_public_key = wg_keypair.public | + | ↓ | + | Build request: | + | ┌──────────────────────────────────────────────────┐ | + | │ LpRegistrationRequest { │ | + | │ wg_public_key: wg_public_key, │ | + | │ credential: credential, │ | + | │ ticket_type: TicketType::V1MixnetEntry, │ | + | │ mode: RegistrationMode::Dvpn, │ | + | │ client_ip: IpAddr::V4(...), │ | + | │ timestamp: unix_timestamp() │ | + | │ } │ | + | └──────────────────────────────────────────────────┘ | + | ↓ | + | request_bytes = bincode::serialize(&request)? | + | → ~300-500 bytes (depends on credential size) | + | ↓ | + | action = state_machine.process_input( | + | SendData(request_bytes) | + | ) | + | ↓ | + | ciphertext = tx_cipher.encrypt( | + | nonce: seq_num, | + | plaintext: request_bytes, | + | aad: header_bytes | + | ) | + | → ciphertext = request_bytes + [16 byte auth tag] | + | ↓ | + | packet = LpPacket { | + | header: LpHeader { session_id: assigned, seq: 3 }, | + | message: EncryptedData(ciphertext) | + | } | + | | + |-- [len][EncryptedData: encrypted request] ──────────────>| receive_packet() + | | ↓ + | | action = state_machine.process_input( + | | ReceivePacket(packet) + | | ) + | | ↓ + | | Check replay (seq_num against window): + | | replay_validator.validate(seq_num)? + | | → Check if seq_num already seen + | | → Update sliding window bitmap + | | → If duplicate: reject + | | ↓ + | | plaintext = rx_cipher.decrypt( + | | nonce: seq_num, + | | ciphertext: encrypted_data, + | | aad: header_bytes + | | ) + | | ↓ + | | request = bincode::deserialize::< + | | LpRegistrationRequest + | | >(&plaintext)? + | | + | Duration: ~5ms | [handler.rs:177-211] + | [client.rs:433-507] | + | | + | | + | [6] Process Registration (Gateway Business Logic) | + |──────────────────────────────────────────────────────────| + | | + | | process_registration(request, state, session_id) + | | ↓ + | | [6.1] Validate timestamp: + | | if !request.validate_timestamp(30): + | | inc!(lp_registration_failed_timestamp) + | | return ERROR + | | ↓ + | | ✓ Timestamp valid + | | + | | [registration.rs:147-151] + | | ↓ + | | [6.2] Handle dVPN mode: + | | ↓ + | | ┌──────────────────────────────────────┐ + | | │ register_wg_peer( │ + | | │ request.wg_public_key, │ + | | │ request.client_ip, │ + | | │ request.ticket_type, │ + | | │ state │ + | | │ ) │ + | | └───────────────┬──────────────────────┘ + | | ↓ + | | [6.2.1] Allocate private IPs: + | | random_octet = rng.gen_range(1..255) + | | client_ipv4 = 10.1.0.{random_octet} + | | client_ipv6 = fd00::{random_octet} + | | ↓ + | | [6.2.2] Create WireGuard peer config: + | | peer = Peer { + | | public_key: request.wg_public_key, + | | allowed_ips: [ + | | client_ipv4/32, + | | client_ipv6/128 + | | ], + | | persistent_keepalive: Some(25), + | | endpoint: None + | | } + | | ↓ + | | [6.2.3] CRITICAL ORDER - Store in DB first: + | | client_id = storage.insert_wireguard_peer( + | | &peer, + | | ticket_type + | | ).await? + | | ↓ + | | SQL: INSERT INTO wireguard_peers + | | (public_key, ticket_type) + | | VALUES (?, ?) + | | RETURNING id + | | → client_id: i64 (auto-increment) + | | ↓ + | | [6.2.4] Create bandwidth entry: + | | credential_storage_preparation( + | | ecash_verifier, + | | client_id + | | ).await? + | | ↓ + | | SQL: INSERT INTO bandwidth + | | (client_id, available) + | | VALUES (?, 0) + | | ↓ + | | [6.2.5] Send to WireGuard controller: + | | (tx, rx) = oneshot::channel() + | | wg_controller.send( + | | PeerControlRequest::AddPeer { + | | peer: peer.clone(), + | | response_tx: tx + | | } + | | ).await? + | | ↓ + | | result = rx.await? + | | if result.is_err(): + | | // Rollback: remove from DB + | | return ERROR + | | ↓ + | | ✓ WireGuard peer added successfully + | | ↓ + | | [6.2.6] Prepare gateway data: + | | gateway_data = GatewayData { + | | public_key: wireguard_data.public_key, + | | endpoint: format!( + | | "{}:{}", + | | wireguard_data.announced_ip, + | | wireguard_data.listen_port + | | ), + | | private_ipv4: client_ipv4, + | | private_ipv6: client_ipv6 + | | } + | | + | | [registration.rs:291-404] + | | ↓ + | | [6.3] Verify e-cash credential: + | | ↓ + | | ┌──────────────────────────────────────┐ + | | │ credential_verification( │ + | | │ ecash_verifier, │ + | | │ request.credential, │ + | | │ client_id │ + | | │ ) │ + | | └───────────────┬──────────────────────┘ + | | ↓ + | | [6.3.1] Check if mock mode: + | | if ecash_verifier.is_mock(): + | | return Ok(MOCK_BANDWIDTH) // 1GB + | | ↓ + | | [6.3.2] Real verification: + | | verifier = CredentialVerifier::new( + | | CredentialSpendingRequest(credential), + | | ecash_verifier.clone(), + | | BandwidthStorageManager::new( + | | storage, + | | client_id + | | ) + | | ) + | | ↓ + | | [6.3.3] Check nullifier not spent: + | | SQL: SELECT COUNT(*) FROM spent_credentials + | | WHERE nullifier = ? + | | if count > 0: + | | inc!(lp_credential_verification_failed{ + | | reason="already_spent" + | | }) + | | return ERROR + | | ↓ + | | [6.3.4] Verify BLS signature: + | | blinding_factor = credential.blinding_factor + | | signature = credential.signature + | | message = hash( + | | gateway_identity + + | | bandwidth_amount + + | | expiry + | | ) + | | ↓ + | | if !bls12_381_verify( + | | public_key: ecash_verifier.public_key(), + | | message: message, + | | signature: signature + | | ): + | | inc!(lp_credential_verification_failed{ + | | reason="invalid_signature" + | | }) + | | return ERROR + | | ↓ + | | ✓ Signature valid + | | ↓ + | | [6.3.5] Mark nullifier spent: + | | SQL: INSERT INTO spent_credentials + | | (nullifier, expiry) + | | VALUES (?, ?) + | | ↓ + | | [6.3.6] Allocate bandwidth: + | | SQL: UPDATE bandwidth + | | SET available = available + ? + | | WHERE client_id = ? + | | → allocated_bandwidth = credential.bandwidth_amount + | | ↓ + | | ✓ Credential verified & bandwidth allocated + | | inc_by!( + | | lp_bandwidth_allocated_bytes_total, + | | allocated_bandwidth + | | ) + | | + | | [registration.rs:87-133] + | | ↓ + | | [6.4] Build success response: + | | response = LpRegistrationResponse { + | | success: true, + | | error: None, + | | gateway_data: Some(gateway_data), + | | allocated_bandwidth, + | | session_id + | | } + | | ↓ + | | inc!(lp_registration_success_total) + | | inc!(lp_registration_dvpn_success) + | | + | Duration: ~150ms (DB + WG + ecash verify) | [registration.rs:136-288] + | | + | | + | [7] Send Registration Response (Encrypted) | + |──────────────────────────────────────────────────────────| + | | + | | response_bytes = bincode::serialize(&response)? + | | ↓ + | | action = state_machine.process_input( + | | SendData(response_bytes) + | | ) + | | ↓ + | | ciphertext = tx_cipher.encrypt( + | | nonce: seq_num, + | | plaintext: response_bytes, + | | aad: header_bytes + | | ) + | | ↓ + | | packet = LpPacket { + | | header: LpHeader { session_id, seq: 4 }, + | | message: EncryptedData(ciphertext) + | | } + | | + |<─ [len][EncryptedData: encrypted response] ──────────────| send_packet() + | | + | receive_packet() | + | ↓ | + | action = state_machine.process_input( | + | ReceivePacket(packet) | + | ) | + | ↓ | + | Check replay: replay_validator.validate(seq_num)? | + | ↓ | + | plaintext = rx_cipher.decrypt( | + | nonce: seq_num, | + | ciphertext: encrypted_data, | + | aad: header_bytes | + | ) | + | ↓ | + | response = bincode::deserialize::< | + | LpRegistrationResponse | + | >(&plaintext)? | + | ↓ | + | Validate response: | + | if !response.success: | + | return Err(RegistrationRejected { | + | reason: response.error | + | }) | + | ↓ | + | gateway_data = response.gateway_data | + | .ok_or(MissingGatewayData)? | + | ↓ | + | ✓ Registration complete! | + | | + | [client.rs:615-715] | [handler.rs:177-211] + | | + | | + | [8] Connection Cleanup | + |──────────────────────────────────────────────────────────| + | | + | TCP close (FIN) | + |-- FIN ──────────────────────────────────────────────────>| + |<─ ACK ───────────────────────────────────────────────────| + |<─ FIN ───────────────────────────────────────────────────| + |-- ACK ──────────────────────────────────────────────────>| + | | + | ✓ Connection closed gracefully | dec!(active_lp_connections) + | | inc!(lp_connections_completed_gracefully) + | | observe!(lp_connection_duration_seconds, duration) + | | + | | + | [9] Client Has WireGuard Configuration | + |──────────────────────────────────────────────────────────| + | | + | Client can now configure WireGuard tunnel: | + | ┌──────────────────────────────────────────────────┐ | + | │ [Interface] │ | + | │ PrivateKey = │ | + | │ Address = 10.1.0.42/32, fd00::42/128 │ | + | │ │ | + | │ [Peer] │ | + | │ PublicKey = │ | + | │ Endpoint = │ | + | │ AllowedIPs = 0.0.0.0/0, ::/0 │ | + | │ PersistentKeepalive = 25 │ | + | └──────────────────────────────────────────────────┘ | + | | + | Total Registration Time: ~221ms | + | ├─ TCP Connect: 12ms | + | ├─ ClientHello: 8ms | + | ├─ Noise Handshake: 45ms | + | ├─ Registration Request: 5ms | + | ├─ Gateway Processing: 150ms | + | └─ Response Receive: 8ms | + | | + | ✅ SUCCESS |✅ SUCCESS + | | + +``` + +**Code References**: +- Client: `nym-registration-client/src/lp_client/client.rs:39-715` +- Gateway Handler: `gateway/src/node/lp_listener/handler.rs:101-478` +- Registration Logic: `gateway/src/node/lp_listener/registration.rs:58-404` +- State Machine: `common/nym-lp/src/state_machine.rs:96-420` +- Noise Protocol: `common/nym-lp/src/noise_protocol.rs:40-88` +- PSK Derivation: `common/nym-lp/src/psk.rs:28-52` +- Replay Protection: `common/nym-lp/src/replay/validator.rs:25-125` + +--- + +## 2. Error Scenario: Timestamp Validation Failure + +**Client clock skew exceeds tolerance** + +``` +Client Gateway + | | + | [1] TCP Connect | + |-- TCP SYN ──────────────────────────────────────────────>| accept() + |<─ TCP SYN-ACK ───────────────────────────────────────────| + |-- TCP ACK ──────────────────────────────────────────────>| + | | + | | + | [2] ClientHello with Bad Timestamp | + |──────────────────────────────────────────────────────────| + | | + | Client system time is WRONG: | + | client_time = SystemTime::now() // e.g., 2025-01-01 | + | ↓ | + | packet = LpPacket { | + | message: ClientHello { | + | timestamp: client_time.as_secs(), // 1735689600 | + | ... | + | } | + | } | + | | + |-- [len][ClientHello: timestamp=1735689600] ─────────────>| receive_client_hello() + | | ↓ + | | now = SystemTime::now() + | | → e.g., 1752537600 (2025-11-11) + | | client_time = UNIX_EPOCH + Duration(1735689600) + | | ↓ + | | diff = abs(now - client_time) + | | → abs(1752537600 - 1735689600) + | | → 16848000 seconds (~195 days!) + | | ↓ + | | if diff > timestamp_tolerance_secs (30): + | | inc!(lp_client_hello_failed{ + | | reason="timestamp_too_old" + | | }) + | | ↓ + | | error_msg = format!( + | | "ClientHello timestamp too old: {} seconds diff", + | | diff + | | ) + | | ↓ + | | // Gateway CLOSES connection + | | return Err(TimestampValidationFailed) + | | + |<─ TCP FIN ───────────────────────────────────────────────| Connection closed + | | + | ❌ Error: Connection closed unexpectedly | + | Client logs: "Failed to receive handshake response" | + | | + | [client.rs:212] | [handler.rs:233-261, 275-323] + | | + | | + | [Mitigation] | + |──────────────────────────────────────────────────────────| + | | + | Option 1: Fix client system time | + | → NTP sync recommended | + | | + | Option 2: Increase gateway tolerance | Option 2: Increase gateway tolerance + | | Edit config.toml: + | | [lp] + | | timestamp_tolerance_secs = 300 + | | (5 minutes instead of 30s) + | | +``` + +**Code References**: +- Timestamp validation: `gateway/src/node/lp_listener/handler.rs:233-261` +- ClientHello receive: `gateway/src/node/lp_listener/handler.rs:275-323` +- Config: `gateway/src/node/lp_listener/mod.rs:78-136` + +--- + +## 3. Error Scenario: Credential Rejected + +**E-cash credential nullifier already spent (double-spend attempt)** + +``` +Client Gateway + | | + | ... (TCP Connect + Handshake successful) ... | + | | + | | + | [1] Send Registration with REUSED Credential | + |──────────────────────────────────────────────────────────| + | | + | credential = { | + | nullifier: 0xABCD... (ALREADY SPENT!) | + | signature: , | + | bandwidth_amount: 1073741824, | + | expiry: | + | } | + | ↓ | + | request = LpRegistrationRequest { | + | credential: credential, // reused! | + | ... | + | } | + | | + |-- [Encrypted Request: reused credential] ───────────────>| process_registration() + | | ↓ + | | credential_verification( + | | ecash_verifier, + | | request.credential, + | | client_id + | | ) + | | ↓ + | | [Check nullifier in DB]: + | | SQL: SELECT COUNT(*) FROM spent_credentials + | | WHERE nullifier = 0xABCD... + | | ↓ + | | count = 1 (already exists!) + | | ↓ + | | inc!(lp_credential_verification_failed{ + | | reason="already_spent" + | | }) + | | inc!(lp_registration_failed_credential) + | | ↓ + | | error_response = LpRegistrationResponse { + | | success: false, + | | error: Some( + | | "Credential already spent (nullifier seen)" + | | ), + | | gateway_data: None, + | | allocated_bandwidth: 0, + | | session_id: 0 + | | } + | | ↓ + | | Encrypt & send response + | | + |<─ [Encrypted Response: error] ───────────────────────────| send_packet() + | | + | Decrypt response | + | ↓ | + | response.success == false | + | response.error == "Credential already spent..." | + | ↓ | + | ❌ Error: RegistrationRejected { | + | reason: "Credential already spent (nullifier seen)" | + | } | + | | + | [client.rs:615-715] | [registration.rs:87-133] + | | + | | + | [Recovery Action] | + |──────────────────────────────────────────────────────────| + | | + | Client must acquire NEW credential: | + | new_credential = bandwidth_controller | + | .get_ecash_ticket( | + | ticket_type, | + | gateway_identity, | + | DEFAULT_TICKETS_TO_SPEND | + | ).await? | + | ↓ | + | Retry registration with new credential | + | | +``` + +**Other Credential Rejection Reasons**: + +1. **Invalid BLS Signature**: + ``` + reason: "invalid_signature" + Cause: Credential tampered with or issued by wrong authority + ``` + +2. **Credential Expired**: + ``` + reason: "expired" + Cause: credential.expiry < SystemTime::now() + ``` + +3. **Bandwidth Amount Mismatch**: + ``` + reason: "bandwidth_mismatch" + Cause: Credential bandwidth doesn't match ticket type + ``` + +**Code References**: +- Credential verification: `gateway/src/node/lp_listener/registration.rs:87-133` +- Nullifier check: Database query in credential storage manager +- Error response: `common/registration/src/lp_messages.rs` + +--- + +## 4. Noise XKpsk3 Handshake Detail + +**Cryptographic operations and authentication flow** + +``` +Initiator (Client) Responder (Gateway) + | | + | [Pre-Handshake: PSK Derivation] | + |──────────────────────────────────────────────────────────| + | | + | Both sides have: | + | • Client static keypair: (c_s_priv, c_s_pub) | + | • Gateway static keypair: (g_s_priv, g_s_pub) | + | • PSK derived from ECDH(c_s, g_s) + salt | + | | + | Initialize Noise: | Initialize Noise: + | protocol = "Noise_XKpsk3_25519_ChaChaPoly_BLAKE2s" | protocol = "Noise_XKpsk3_25519_ChaChaPoly_BLAKE2s" + | local_static = c_s_priv | local_static = g_s_priv + | remote_static = g_s_pub (known) | remote_static = c_s_pub (from ClientHello) + | psk_position = 3 (in 3rd message) | psk_position = 3 + | psk = [32 bytes derived PSK] | psk = [32 bytes derived PSK] + | ↓ | ↓ + | state = HandshakeState::initialize() | state = HandshakeState::initialize() + | chaining_key = HASH("Noise_XKpsk3...") | chaining_key = HASH("Noise_XKpsk3...") + | h = HASH(protocol_name) | h = HASH(protocol_name) + | h = HASH(h || g_s_pub) // Mix in responder static | h = HASH(h || g_s_pub) + | | + | | + | ═══════════════════════════════════════════════════════════════════ + | Message 1: -> e + | ═══════════════════════════════════════════════════════════════════ + | | + | [Initiator Actions]: | + | Generate ephemeral keypair: | + | c_e_priv, c_e_pub = X25519::generate() | + | ↓ | + | Mix ephemeral public into hash: | + | h = HASH(h || c_e_pub) | + | ↓ | + | Build message: | + | msg1 = c_e_pub (32 bytes, plaintext) | + | ↓ | + | Send: | + | | + |-- msg1: [c_e_pub (32 bytes)] ───────────────────────────>| [Responder Actions]: + | | ↓ + | | Extract: + | | c_e_pub = msg1[0..32] + | | ↓ + | | Mix into hash: + | | h = HASH(h || c_e_pub) + | | ↓ + | | Store: c_e_pub for later DH + | | + | | + | ═══════════════════════════════════════════════════════════════════ + | Message 2: <- e, ee, s, es + | ═══════════════════════════════════════════════════════════════════ + | | + | | [Responder Actions]: + | | ↓ + | | Generate ephemeral keypair: + | | g_e_priv, g_e_pub = X25519::generate() + | | ↓ + | | [e] Mix ephemeral public into hash: + | | h = HASH(h || g_e_pub) + | | payload = g_e_pub + | | ↓ + | | [ee] Compute ECDH (ephemeral-ephemeral): + | | ee = DH(g_e_priv, c_e_pub) + | | (chaining_key, _) = HKDF( + | | chaining_key, + | | ee, + | | 2 outputs + | | ) + | | ↓ + | | [s] Encrypt gateway static public: + | | // Derive temp key from chaining_key + | | (_, key) = HKDF(chaining_key, ..., 2) + | | ↓ + | | encrypted_g_s = AEAD_ENCRYPT( + | | key: key, + | | nonce: 0, + | | plaintext: g_s_pub, + | | aad: h + | | ) + | | → 32 bytes payload + 16 bytes tag = 48 bytes + | | ↓ + | | h = HASH(h || encrypted_g_s) + | | payload = payload || encrypted_g_s + | | ↓ + | | [es] Compute ECDH (ephemeral-static): + | | es = DH(g_e_priv, c_s_pub) + | | (chaining_key, _) = HKDF( + | | chaining_key, + | | es, + | | 2 outputs + | | ) + | | ↓ + | | Build message: + | | msg2 = g_e_pub (32) || encrypted_g_s (48) + | | → Total: 80 bytes + | | ↓ + | | Send: + | | + |<─ msg2: [g_e_pub (32)] + [encrypted_g_s (48)] ───────────| send_packet() + | | + | [Initiator Actions]: | + | ↓ | + | Extract: | + | g_e_pub = msg2[0..32] | + | encrypted_g_s = msg2[32..80] | + | ↓ | + | [e] Mix gateway ephemeral into hash: | + | h = HASH(h || g_e_pub) | + | ↓ | + | [ee] Compute ECDH (ephemeral-ephemeral): | + | ee = DH(c_e_priv, g_e_pub) | + | (chaining_key, _) = HKDF(chaining_key, ee, 2) | + | ↓ | + | [s] Decrypt gateway static public: | + | (_, key) = HKDF(chaining_key, ..., 2) | + | ↓ | + | decrypted_g_s = AEAD_DECRYPT( | + | key: key, | + | nonce: 0, | + | ciphertext: encrypted_g_s, | + | aad: h | + | ) | + | ↓ | + | if decrypted_g_s != g_s_pub (known): | + | ❌ ERROR: Gateway authentication failed | + | ✓ Gateway authenticated | + | ↓ | + | h = HASH(h || encrypted_g_s) | + | ↓ | + | [es] Compute ECDH (static-ephemeral): | + | es = DH(c_s_priv, g_e_pub) | + | (chaining_key, _) = HKDF(chaining_key, es, 2) | + | | + | | + | ═══════════════════════════════════════════════════════════════════ + | Message 3: -> s, se, psk + | ═══════════════════════════════════════════════════════════════════ + | | + | [Initiator Actions]: | + | ↓ | + | [s] Encrypt client static public: | + | (_, key) = HKDF(chaining_key, ..., 2) | + | ↓ | + | encrypted_c_s = AEAD_ENCRYPT( | + | key: key, | + | nonce: 0, | + | plaintext: c_s_pub, | + | aad: h | + | ) | + | → 32 bytes payload + 16 bytes tag = 48 bytes | + | ↓ | + | h = HASH(h || encrypted_c_s) | + | ↓ | + | [se] Compute ECDH (static-ephemeral): | + | se = DH(c_s_priv, g_e_pub) | + | (chaining_key, _) = HKDF(chaining_key, se, 2) | + | ↓ | + | [psk] Mix in pre-shared key: | + | (chaining_key, temp_key) = HKDF( | + | chaining_key, | + | psk, ← PRE-SHARED KEY | + | 2 outputs | + | ) | + | ↓ | + | h = HASH(h || temp_key) | + | ↓ | + | Build message: | + | msg3 = encrypted_c_s (48 bytes) | + | ↓ | + | Send: | + | | + |-- msg3: [encrypted_c_s (48)] ───────────────────────────>| [Responder Actions]: + | | ↓ + | | Extract: + | | encrypted_c_s = msg3[0..48] + | | ↓ + | | [s] Decrypt client static public: + | | (_, key) = HKDF(chaining_key, ..., 2) + | | ↓ + | | decrypted_c_s = AEAD_DECRYPT( + | | key: key, + | | nonce: 0, + | | ciphertext: encrypted_c_s, + | | aad: h + | | ) + | | ↓ + | | if decrypted_c_s != c_s_pub (from ClientHello): + | | ❌ ERROR: Client authentication failed + | | ✓ Client authenticated + | | ↓ + | | h = HASH(h || encrypted_c_s) + | | ↓ + | | [se] Compute ECDH (ephemeral-static): + | | se = DH(g_e_priv, c_s_pub) + | | (chaining_key, _) = HKDF(chaining_key, se, 2) + | | ↓ + | | [psk] Mix in pre-shared key: + | | (chaining_key, temp_key) = HKDF( + | | chaining_key, + | | psk, ← PRE-SHARED KEY (same as client!) + | | 2 outputs + | | ) + | | ↓ + | | h = HASH(h || temp_key) + | | ↓ + | | if PSKs differ, decryption would fail + | | ✓ PSK implicitly verified + | | + | | + | ═══════════════════════════════════════════════════════════════════ + | Handshake Complete: Derive Transport Keys + | ═══════════════════════════════════════════════════════════════════ + | | + | [Split chaining_key into transport keys]: | [Split chaining_key into transport keys]: + | (client_to_server_key, server_to_client_key) = | (client_to_server_key, server_to_client_key) = + | HKDF(chaining_key, empty, 2 outputs) | HKDF(chaining_key, empty, 2 outputs) + | ↓ | ↓ + | tx_cipher = ChaCha20Poly1305::new(client_to_server_key) | rx_cipher = ChaCha20Poly1305::new(client_to_server_key) + | rx_cipher = ChaCha20Poly1305::new(server_to_client_key) | tx_cipher = ChaCha20Poly1305::new(server_to_client_key) + | ↓ | ↓ + | tx_nonce = 0 | rx_nonce = 0 + | rx_nonce = 0 | tx_nonce = 0 + | ↓ | ↓ + | ✅ Transport mode established | ✅ Transport mode established + | | + | | + | [Security Properties Achieved]: | + |──────────────────────────────────────────────────────────| + | | + | ✅ Mutual authentication: | + | • Gateway authenticated via (s) in msg2 | + | • Client authenticated via (s) in msg3 | + | | + | ✅ Forward secrecy: | + | • Ephemeral keys (c_e, g_e) destroyed after handshake | + | • Compromise of static keys doesn't decrypt past sessions + | | + | ✅ PSK strengthening: | + | • Even if X25519 is broken, PSK protects against MITM | + | • PSK derived from separate ECDH + salt | + | | + | ✅ Key confirmation: | + | • Both sides prove knowledge of PSK | + | • AEAD auth tags verify all steps | + | | +``` + +**Code References**: +- Noise protocol impl: `common/nym-lp/src/noise_protocol.rs:40-88` +- State machine: `common/nym-lp/src/state_machine.rs:96-420` +- Session management: `common/nym-lp/src/session.rs:45-180` + +--- + +## 7. PSK Derivation Flow + +**Detailed cryptographic derivation** + +``` +Client Side Gateway Side + | | + | [Inputs] | [Inputs] + |──────────────────────────────────────────────────────────| + | | + | • client_static_keypair: | • gateway_ed25519_identity: + | - secret_key: [32 bytes] X25519 | - secret_key: [32 bytes] Ed25519 + | - public_key: [32 bytes] X25519 | - public_key: [32 bytes] Ed25519 + | ↓ | ↓ + | • gateway_ed25519_public: [32 bytes] | [Convert Ed25519 → X25519]: + | (from gateway identity) | gateway_lp_keypair = ed25519_to_x25519( + | ↓ | gateway_ed25519_identity + | [Convert Ed25519 → X25519]: | ) + | gateway_x25519_public = ed25519_to_x25519( | ↓ + | gateway_ed25519_public | • gateway_lp_keypair: + | ) | - secret_key: [32 bytes] X25519 + | ↓ | - public_key: [32 bytes] X25519 + | • salt: [32 bytes] (from ClientHello) | ↓ + | | • client_x25519_public: [32 bytes] + | | (from ClientHello) + | | ↓ + | | • salt: [32 bytes] (from ClientHello) + | | + | | + | [Step 1: ECDH Shared Secret] | [Step 1: ECDH Shared Secret] + |──────────────────────────────────────────────────────────| + | | + | shared_secret = ECDH( | shared_secret = ECDH( + | client_static_keypair.secret_key, | gateway_lp_keypair.secret_key, + | gateway_x25519_public | client_x25519_public + | ) | ) + | ↓ | ↓ + | // X25519 scalar multiplication: | // X25519 scalar multiplication: + | // shared_secret = client_secret * gateway_public | // shared_secret = gateway_secret * client_public + | // = client_secret * gateway_secret * G | // = gateway_secret * client_secret * G + | // (commutative!) | // (same result!) + | ↓ | ↓ + | shared_secret: [32 bytes] | shared_secret: [32 bytes] (IDENTICAL to client!) + | Example: 0x7a3b9f2c... | Example: 0x7a3b9f2c... (same) + | | + | | + | [Step 2: Blake3 Key Derivation Function] | [Step 2: Blake3 Key Derivation Function] + |──────────────────────────────────────────────────────────| + | | + | // Initialize Blake3 in keyed mode | // Initialize Blake3 in keyed mode + | hasher = Blake3::new_keyed(PSK_KDF_KEY) | hasher = Blake3::new_keyed(PSK_KDF_KEY) + | where PSK_KDF_KEY = b"nym-lp-psk-kdf-v1-key-32bytes!" | where PSK_KDF_KEY = b"nym-lp-psk-kdf-v1-key-32bytes!" + | (hardcoded 32-byte domain separation key) | (hardcoded 32-byte domain separation key) + | ↓ | ↓ + | // Update with context string (domain separation) | // Update with context string + | hasher.update(b"nym-lp-psk-v1") | hasher.update(b"nym-lp-psk-v1") + | → 13 bytes context | → 13 bytes context + | ↓ | ↓ + | // Update with shared secret | // Update with shared secret + | hasher.update(shared_secret.as_bytes()) | hasher.update(shared_secret.as_bytes()) + | → 32 bytes ECDH output | → 32 bytes ECDH output + | ↓ | ↓ + | // Update with salt (freshness per-session) | // Update with salt + | hasher.update(&salt) | hasher.update(&salt) + | → 32 bytes random salt | → 32 bytes random salt + | ↓ | ↓ + | // Total hashed: 13 + 32 + 32 = 77 bytes | // Total hashed: 77 bytes + | ↓ | ↓ + | | + | | + | [Step 3: Extract PSK (32 bytes)] | [Step 3: Extract PSK (32 bytes)] + |──────────────────────────────────────────────────────────| + | | + | // Finalize in XOF (extendable output function) mode | // Finalize in XOF mode + | xof = hasher.finalize_xof() | xof = hasher.finalize_xof() + | ↓ | ↓ + | // Read exactly 32 bytes | // Read exactly 32 bytes + | psk = [0u8; 32] | psk = [0u8; 32] + | xof.fill(&mut psk) | xof.fill(&mut psk) + | ↓ | ↓ + | psk: [32 bytes] | psk: [32 bytes] (IDENTICAL to client!) + | Example: 0x4f8a1c3e... | Example: 0x4f8a1c3e... (same) + | ↓ | ↓ + | | + | ✅ PSK derived successfully | ✅ PSK derived successfully + | | + | [psk.rs:28-52] | [psk.rs:28-52] + | | + | | + | [Properties of This Scheme] | + |──────────────────────────────────────────────────────────| + | | + | ✅ Session uniqueness: | + | • Fresh salt per connection → unique PSK per session | + | • Even with same keypairs, PSK changes each time | + | | + | ✅ Perfect forward secrecy (within PSK derivation): | + | • Salt is ephemeral (generated once, never reused) | + | • Compromise of static keys + old salt still needed | + | | + | ✅ Authenticated key agreement: | + | • Only parties with correct keypairs derive same PSK | + | • MITM cannot compute shared_secret without private keys + | | + | ✅ Domain separation: | + | • Context "nym-lp-psk-v1" prevents cross-protocol attacks + | • PSK_KDF_KEY ensures output is LP-specific | + | | + | ✅ Future-proof: | + | • Version in context allows protocol upgrades | + | • Blake3 is quantum-resistant hash function | + | | +``` + +**Code References**: +- PSK derivation: `common/nym-lp/src/psk.rs:28-52` +- Keypair conversion: `common/nym-lp/src/keypair.rs` +- Constants: `common/nym-lp/src/psk.rs:15-26` + +--- + +## 8. Message Format Specifications + +### 8.1. Packet Framing (Transport Layer) + +**All LP messages use length-prefixed framing over TCP**: + +``` +┌────────────────┬─────────────────────────────────┐ +│ 4 bytes │ N bytes │ +│ (u32 BE) │ (packet data) │ +│ packet_len │ serialized LpPacket │ +└────────────────┴─────────────────────────────────┘ + +Example: + [0x00, 0x00, 0x00, 0x50] → packet_len = 80 (decimal) + [... 80 bytes of bincode-serialized LpPacket ...] +``` + +**Code**: `nym-registration-client/src/lp_client/client.rs:333-431` + +--- + +### 8.2. LpPacket Structure + +**All LP messages wrapped in `LpPacket`**: + +```rust +struct LpPacket { + header: LpHeader, + message: LpMessage, +} + +struct LpHeader { + session_id: u32, // Assigned by gateway after handshake + sequence_number: u32, // Monotonic counter (used as AEAD nonce) + flags: u8, // Reserved for future use +} + +enum LpMessage { + ClientHello(ClientHelloData), + Handshake(Vec), // Noise handshake messages + EncryptedData(Vec), // Encrypted registration/response + Busy, // Gateway at capacity +} +``` + +**Serialization**: bincode (binary, compact) + +**Code**: `common/nym-lp/src/packet.rs:15-82`, `common/nym-lp/src/message.rs:12-64` + +--- + +### 8.3. ClientHello Message + +**Sent first (cleartext), establishes PSK parameters**: + +```rust +struct ClientHelloData { + client_public_key: [u8; 32], // X25519 public key + salt: [u8; 32], // Random salt for PSK derivation + timestamp: u64, // Unix timestamp (seconds) + protocol_version: u8, // Always 1 for now +} +``` + +**Wire format** (bincode): +``` +┌─────────────────────────────────────────────────────────┐ +│ Offset │ Size │ Field │ +├──────────┼────────┼──────────────────────────────────────┤ +│ 0 │ 32 │ client_public_key │ +│ 32 │ 32 │ salt │ +│ 64 │ 8 │ timestamp (u64 LE) │ +│ 72 │ 1 │ protocol_version (u8) │ +├──────────┴────────┴──────────────────────────────────────┤ +│ Total: 73 bytes │ +└─────────────────────────────────────────────────────────┘ +``` + +**Code**: `common/nym-lp/src/message.rs:66-95` + +--- + +### 8.4. Noise Handshake Messages + +**Encapsulated in `LpMessage::Handshake(Vec)`**: + +**Message 1** (-> e): +``` +┌─────────────────────────┐ +│ 32 bytes │ +│ client_ephemeral_pub │ +└─────────────────────────┘ +``` + +**Message 2** (<- e, ee, s, es): +``` +┌──────────────────────────┬─────────────────────────────────┐ +│ 32 bytes │ 48 bytes │ +│ gateway_ephemeral_pub │ encrypted_gateway_static_pub │ +│ │ (32 payload + 16 auth tag) │ +└──────────────────────────┴─────────────────────────────────┘ +Total: 80 bytes +``` + +**Message 3** (-> s, se, psk): +``` +┌─────────────────────────────────┐ +│ 48 bytes │ +│ encrypted_client_static_pub │ +│ (32 payload + 16 auth tag) │ +└─────────────────────────────────┘ +``` + +**Code**: `common/nym-lp/src/noise_protocol.rs:40-88` + +--- + +### 8.5. LpRegistrationRequest + +**Sent encrypted after handshake complete**: + +```rust +struct LpRegistrationRequest { + wg_public_key: [u8; 32], // WireGuard public key + credential: CredentialSpendingData, // E-cash credential (~200-300 bytes) + ticket_type: TicketType, // Enum (1 byte) + mode: RegistrationMode, // Enum: Dvpn or Mixnet{client_id} + client_ip: IpAddr, // 4 bytes (IPv4) or 16 bytes (IPv6) + timestamp: u64, // Unix timestamp (8 bytes) +} + +enum RegistrationMode { + Dvpn, + Mixnet { client_id: [u8; 32] }, +} + +struct CredentialSpendingData { + nullifier: [u8; 32], + signature: Vec, // BLS12-381 signature (~96 bytes) + bandwidth_amount: u64, + expiry: u64, + // ... other fields +} +``` + +**Approximate size**: 300-500 bytes (depends on credential size) + +**Code**: `common/registration/src/lp_messages.rs:10-85` + +--- + +### 8.6. LpRegistrationResponse + +**Sent encrypted from gateway**: + +```rust +struct LpRegistrationResponse { + success: bool, // 1 byte + error: Option, // Variable (if error) + gateway_data: Option, // ~100 bytes (if success) + allocated_bandwidth: i64, // 8 bytes + session_id: u32, // 4 bytes +} + +struct GatewayData { + public_key: [u8; 32], // WireGuard public key + endpoint: String, // "ip:port" (variable) + private_ipv4: Ipv4Addr, // 4 bytes + private_ipv6: Ipv6Addr, // 16 bytes +} +``` + +**Typical size**: +- Success response: ~150-200 bytes +- Error response: ~50-100 bytes (depends on error message length) + +**Code**: `common/registration/src/lp_messages.rs:87-145` + +--- + +### 8.7. Encrypted Data Format + +**After handshake, all data encrypted with ChaCha20-Poly1305**: + +``` +Plaintext: + ┌────────────────────────────────┐ + │ N bytes │ + │ serialized message │ + └────────────────────────────────┘ + +Encryption: + ciphertext = ChaCha20Poly1305::encrypt( + key: transport_key, // Derived from Noise handshake + nonce: sequence_number, // From LpHeader + plaintext: message_bytes, + aad: header_bytes // LpHeader as additional auth data + ) + +Ciphertext: + ┌────────────────────────────────┬─────────────────┐ + │ N bytes │ 16 bytes │ + │ encrypted message │ auth tag │ + └────────────────────────────────┴─────────────────┘ +``` + +**Code**: `common/nym-lp/src/state_machine.rs:250-350` + +--- + +## Summary + +This document provides complete technical specifications for: + +1. **Happy Path**: Full successful dVPN registration flow +2. **Error Scenarios**: Timestamp, credential, handshake, and WireGuard failures +3. **Noise Handshake**: Cryptographic operations and authentication +4. **PSK Derivation**: Detailed key derivation flow +5. **Message Formats**: Byte-level packet specifications + +**All flows include**: +- Exact message formats +- Cryptographic operations +- Database operations +- Error handling +- Code references (file:line) +- Metrics emitted + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-11-11 +**Maintainer**: @drazen diff --git a/docs/LP_REGISTRATION_WALKTHROUGH.md b/docs/LP_REGISTRATION_WALKTHROUGH.md new file mode 100644 index 00000000000..694ea7ec02d --- /dev/null +++ b/docs/LP_REGISTRATION_WALKTHROUGH.md @@ -0,0 +1,261 @@ +# LP Registration Protocol - Technical Walkthrough + +**Branch**: `drazen/lp-reg` +**Status**: Implementation complete, testing in progress +**Audience**: Engineering team, technical demo + +--- + +## Executive Summary + +LP Registration is a **fast, direct registration protocol** that allows clients to connect to Nym gateways without traversing the mixnet. It's designed primarily for dVPN use cases where users need quick WireGuard peer setup with sub-second latency. + +### Key Characteristics + +| Aspect | LP Registration | Traditional Mixnet Registration | +|--------|----------------|--------------------------------| +| **Latency** | Sub-second (100ms-1s) | Multi-second (3-10s) | +| **Transport** | Direct TCP (port 41264) | Through mixnet layers | +| **Reliability** | Guaranteed delivery | Probabilistic delivery | +| **Anonymity** | Client IP visible to gateway | Network-level anonymity | +| **Use Case** | dVPN, low-latency services | Privacy-critical applications | +| **Security** | Noise XKpsk3 + ChaCha20-Poly1305 | Sphinx packet encryption | + +### Protocol Stack + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ WireGuard Peer Registration (dVPN) / Mixnet Client. │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ LP Registration Layer │ +│ LpRegistrationRequest / LpRegistrationResponse │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Noise XKpsk3 Protocol Layer │ +│ ChaCha20-Poly1305 Encryption + Authentication │ +│ Replay Protection (1024-pkt window) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Transport Layer │ +│ TCP (length-prefixed packet framing) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Architecture Overview + +### High-Level Component Diagram + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ CLIENT SIDE │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ nym-registration-client (Client Library) │ │ +│ │ nym-registration-client/src/lp_client/client.rs:39-62 │ │ +│ │ │ │ +│ │ • LpRegistrationClient │ │ +│ │ • TCP connection management │ │ +│ │ • Packet serialization/framing │ │ +│ │ • Integration with BandwidthController │ │ +│ └────────────────────┬────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────┴─────────────────────────────────────────┐ │ +│ │ common/nym-lp (Protocol Library) │ │ +│ │ common/nym-lp/src/ (multiple modules) │ │ +│ │ │ │ +│ │ • LpStateMachine (state_machine.rs:96-420) │ │ +│ │ • Noise XKpsk3 (noise_protocol.rs:40-88) │ │ +│ │ • PSK derivation (psk.rs:28-52) │ │ +│ │ • ReplayValidator (replay/validator.rs:25-125) │ │ +│ │ • Message types (message.rs, packet.rs) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + │ + │ TCP (port 41264) + │ Length-prefixed packets + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ GATEWAY SIDE │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ LpListener (TCP Accept Loop) │ │ +│ │ gateway/src/node/lp_listener/mod.rs:226-270 │ │ +│ │ │ │ +│ │ • Binds to 0.0.0.0:41264 │ │ +│ │ • Spawns LpConnectionHandler per connection │ │ +│ │ • Metrics: active_lp_connections │ │ +│ └────────────────────┬────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼─────────────────────────────────────────┐ │ +│ │ LpConnectionHandler (Per-Connection) │ │ +│ │ gateway/src/node/lp_listener/handler.rs:101-216 │ │ +│ │ │ │ +│ │ 1. Receive ClientHello & validate timestamp │ │ +│ │ 2. Derive PSK from ECDH + salt │ │ +│ │ 3. Perform Noise handshake │ │ +│ │ 4. Receive encrypted registration request │ │ +│ │ 5. Process registration (delegate to registration.rs) │ │ +│ │ 6. Send encrypted response │ │ +│ │ 7. Emit metrics & close │ │ +│ └────────────────────┬─────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼─────────────────────────────────────────┐ │ +│ │ Registration Processor (Business Logic) │ │ +│ │ gateway/src/node/lp_listener/registration.rs:136-288 │ │ +│ │ │ │ +│ │ Mode: dVPN Mode: Mixnet │ │ +│ │ ├─ register_wg_peer() ├─ (skip WireGuard) │ │ +│ │ ├─ credential_verification() ├─ credential_verification() │ │ +│ │ └─ return GatewayData └─ return bandwidth only │ │ +│ └────────┬───────────────────────────────┬─────────────────────┘ │ +│ │ │ │ +│ ┌────────▼───────────────────┐ ┌───────▼─────────────────────┐ │ +│ │ WireGuard Controller │ │ E-cash Verifier │ │ +│ │ (PeerControlRequest) │ │ (EcashManager trait) │ │ +│ │ │ │ │ │ +│ │ • Add/Remove WG peers │ │ • Verify BLS signature │ │ +│ │ • Manage peer lifecycle │ │ • Check nullifier spent │ │ +│ │ • Monitor bandwidth usage │ │ • Allocate bandwidth │ │ +│ └─────────────────────────────┘ └────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ GatewayStorage (Database) │ │ +│ │ │ │ +│ │ Tables: │ │ +│ │ • wireguard_peers (public_key, client_id, ticket_type) │ │ +│ │ • bandwidth (client_id, available) │ │ +│ │ • spent_credentials (nullifier, expiry) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Roadmap + +### ✅ Completed Components + +1. **Protocol Library** (`common/nym-lp/`) + - Noise XKpsk3 implementation + - PSK derivation (Blake3 KDF) + - Replay protection with SIMD optimization + - Message types and packet framing + +2. **Gateway Listener** (`gateway/src/node/lp_listener/`) + - TCP accept loop with connection limits + - Per-connection handler with lifecycle management + - dVPN and Mixnet registration modes + - Comprehensive metrics + +3. **Client Library** (`nym-registration-client/`) + - Connection management with timeouts + - Noise handshake as initiator + - E-cash credential integration + - Error handling and retries + +4. **Testing Tools** (`nym-gateway-probe/`) + - LP-only test mode (`--only-lp-registration`) + - Mock e-cash mode (`--use-mock-ecash`) + - Detailed test results + + +## Detailed Documentation + +### For Protocol Deep-Dive +📄 **[LP_REGISTRATION_SEQUENCES.md](./LP_REGISTRATION_SEQUENCES.md)** +- Complete sequence diagrams for all flows +- Happy path with byte-level message formats +- Error scenarios and recovery paths +- Noise handshake details + +### For Architecture Understanding +📄 **[LP_REGISTRATION_ARCHITECTURE.md](./LP_REGISTRATION_ARCHITECTURE.md)** +- Component interaction diagrams +- Data flow through gateway modules +- Client-side architecture +- State transitions + + +--- + +## Code Navigation + +### Key Entry Points + +| Component | File Path | Description | +|-----------|-----------|-------------| +| **Gateway Listener** | `gateway/src/node/lp_listener/mod.rs:226` | `LpListener::run()` - main loop | +| **Connection Handler** | `gateway/src/node/lp_listener/handler.rs:101` | `LpConnectionHandler::handle()` - per-connection | +| **Registration Logic** | `gateway/src/node/lp_listener/registration.rs:136` | `process_registration()` - business logic | +| **Client Entry** | `nym-registration-client/src/lp_client/client.rs:39` | `LpRegistrationClient` struct | +| **Protocol Core** | `common/nym-lp/src/state_machine.rs:96` | `LpStateMachine` - Noise protocol | +| **Probe Test** | `nym-gateway-probe/src/lib.rs:861` | `lp_registration_probe()` - integration test | + +--- + +## Metrics and Observability + +### Prometheus Metrics + +**Connection Metrics**: +- `lp_connections_total{result="success|error"}` - Counter +- `lp_active_lp_connections` - Gauge +- `lp_connection_duration_seconds` - Histogram (buckets: 0.01, 0.1, 1, 5, 10, 30) + +**Handshake Metrics**: +- `lp_handshakes_success` - Counter +- `lp_handshakes_failed{reason="..."}` - Counter +- `lp_handshake_duration_seconds` - Histogram + +**Registration Metrics**: +- `lp_registration_attempts_total` - Counter +- `lp_registration_success_total{mode="dvpn|mixnet"}` - Counter +- `lp_registration_failed_total{reason="..."}` - Counter +- `lp_registration_duration_seconds` - Histogram + +**Bandwidth Metrics**: +- `lp_bandwidth_allocated_bytes_total` - Counter +- `lp_credential_verification_success` - Counter +- `lp_credential_verification_failed{reason="..."}` - Counter + +## Performance Characteristics + +### Latency Breakdown + +``` +Total Registration Time: ~221ms (typical) +├─ TCP Connect: 10-20ms +├─ Noise Handshake: 40-60ms (3 round-trips) +│ ├─ ClientHello send: <5ms +│ ├─ Msg 1 (-> e): <5ms +│ ├─ Msg 2 (<- e,ee,s,es): 20-30ms (crypto ops) +│ └─ Msg 3 (-> s,se,psk): 10-20ms +├─ Registration Request: 100-150ms +│ ├─ Request encrypt & send: <5ms +│ ├─ Gateway processing: 90-140ms +│ │ ├─ WireGuard peer setup: 20-40ms +│ │ ├─ Database operations: 30-50ms +│ │ ├─ E-cash verification: 40-60ms (or <1ms with mock) +│ │ └─ Response preparation: <5ms +│ └─ Response receive & decrypt: <5ms +└─ Connection cleanup: <5ms +``` + +### Resource Usage + +- **Memory per session**: 144 bytes (state machine + replay window) +- **Max concurrent connections**: 10,000 (configurable) +- **CPU**: Minimal (ChaCha20 is efficient, SIMD optimizations) +- **Database**: 3-5 queries per registration (indexed lookups) \ No newline at end of file diff --git a/docs/LP_SECURITY.md b/docs/LP_SECURITY.md new file mode 100644 index 00000000000..5496ba38a5f --- /dev/null +++ b/docs/LP_SECURITY.md @@ -0,0 +1,729 @@ +# LP (Lewes Protocol) Security Considerations + +## Threat Model + +### Attacker Capabilities + +**Network Attacker (Dolev-Yao Model):** +- ✅ Can observe all network traffic +- ✅ Can inject, modify, drop, or replay packets +- ✅ Can perform active MITM attacks +- ✅ Cannot break cryptographic primitives (ChaCha20, Poly1305, X25519) +- ✅ Cannot forge digital signatures (BLS12-381) + +**Gateway Compromise:** +- ✅ Attacker gains full access to gateway server +- ✅ Can read all gateway state (keys, credentials, database) +- ✅ Can impersonate gateway to clients +- ❌ Cannot decrypt past sessions (forward secrecy) +- ❌ Cannot impersonate clients without their keys + +**Client Compromise:** +- ✅ Attacker gains access to client device +- ✅ Can read client LP private key +- ✅ Can impersonate client to gateways +- ❌ Cannot decrypt other clients' sessions + +### Security Goals + +**Confidentiality:** +- Registration requests encrypted end-to-end +- E-cash credentials protected from eavesdropping +- WireGuard keys transmitted securely + +**Integrity:** +- All messages authenticated with Poly1305 MAC +- Tampering detected and rejected +- Replay attacks prevented + +**Authentication:** +- Mutual authentication via Noise XKpsk3 +- Gateway proves possession of LP private key +- Client proves possession of LP private key + PSK + +**Forward Secrecy:** +- Compromise of long-term keys doesn't reveal past sessions +- Ephemeral keys provide PFS +- Session keys destroyed after use + +**Non-Goals:** +- **Network anonymity**: LP reveals client IP to gateway (use mixnet for anonymity) +- **Traffic analysis resistance**: Packet timing visible to network observer +- **Deniability**: Parties can prove who they communicated with + +## Cryptographic Design + +### Noise Protocol XKpsk3 + +**Pattern:** +``` +XKpsk3: + <- s + ... + -> e + <- e, ee, s, es + -> s, se, psk +``` + +**Security Properties:** + +| Property | Provided | Rationale | +|----------|----------|-----------| +| Confidentiality (forward) | ✅ Strong | Ephemeral keys + PSK | +| Confidentiality (backward) | ✅ Weak | PSK compromise affects future | +| Authentication (initiator) | ✅ Strong | Static key + PSK | +| Authentication (responder) | ✅ Strong | Static key known upfront | +| Identity hiding (initiator) | ✅ Yes | Static key encrypted | +| Identity hiding (responder) | ❌ No | Static key in handshake msg 2 | + +**Why XKpsk3:** + +1. **Known responder identity**: Client knows gateway's LP public key from descriptor +2. **Mutual authentication**: Both sides prove identity +3. **PSK binding**: Links session to out-of-band PSK (prevents MITM with compromised static key alone) +4. **Forward secrecy**: Ephemeral keys provide PFS even if static keys leaked + +**Alternative patterns considered:** + +- **IKpsk2**: No forward secrecy (rejected) +- **XXpsk3**: More round trips, unknown identities (not needed) +- **NKpsk0**: No client authentication (rejected) + +### PSK Derivation Security + +**Formula:** +``` +shared_secret = X25519(client_lp_private, gateway_lp_public) +psk = Blake3_derive_key("nym-lp-psk-v1", shared_secret, salt) +``` + +**Security Analysis:** + +1. **ECDH Security**: Based on Curve25519 hardness (128-bit security) + - Resistant to quantum attacks up to Grover's algorithm (64-bit post-quantum) + - Well-studied, no known vulnerabilities + +2. **Blake3 KDF Security**: + - Output indistinguishable from random (PRF security) + - Domain separation via context string prevents cross-protocol attacks + - Collision resistance: 128 bits (birthday bound on 256-bit hash) + +3. **Salt Freshness**: + - Timestamp component prevents long-term PSK reuse + - Nonce component provides per-session uniqueness + - Both transmitted in ClientHello (integrity protected by timestamp validation + Noise handshake) + +**Attack Scenarios:** + +| Attack | Feasibility | Mitigation | +|--------|-------------|------------| +| Brute force PSK | ❌ Infeasible | 2^128 operations (Curve25519 DL) | +| Quantum attack on ECDH | ⚠️ Future threat | Shor's algorithm breaks X25519 in polynomial time | +| Salt replay | ❌ Prevented | Timestamp validation (30s window) | +| Cross-protocol PSK reuse | ❌ Prevented | Domain separation ("nym-lp-psk-v1") | + +**Quantum Resistance:** + +LP is **not quantum-resistant** due to X25519 use. Future upgrade path: + +```rust +// Hybrid PQ-KEM (future) +let classical_secret = X25519(client_priv, gateway_pub); +let pq_secret = Kyber768::encaps(gateway_pq_pub); +let psk = Blake3_derive_key( + "nym-lp-psk-v2-pq", + classical_secret || pq_secret, + salt +); +``` + +### Replay Protection Analysis + +**Algorithm: Sliding Window with Bitmap** + +```rust +Window size: 1024 packets +Bitmap: [u64; 16] = 1024 bits + +For counter C: + - Accept if C >= next (new packet) + - Reject if C + 1024 < next (too old) + - Reject if bitmap[C % 1024] == 1 (duplicate) + - Otherwise accept and mark +``` + +**Security Properties:** + +1. **Replay Window**: 1024 packets + - Sufficient for expected reordering in TCP+KCP + - Small enough to limit replay attack surface + +2. **Memory Efficiency**: 128 bytes bitmap + - Tracks 1024 unique counters + - O(1) lookup and insertion + +3. **Overflow Handling**: Wraps at u64::MAX + - Properly handles counter wraparound + - Unlikely to occur (2^64 packets = trillions) + +**Attack Scenarios:** + +| Attack | Feasibility | Mitigation | +|--------|-------------|------------| +| Replay within window | ❌ Prevented | Bitmap tracking | +| Replay outside window | ❌ Prevented | Window boundary check | +| Counter overflow | ⚠️ Theoretical | Wraparound handling + 2^64 limit | +| Timing attack | ❌ Mitigated | Branchless execution | + +**Timing Attack Resistance:** + +```rust +// Constant-time check (branchless) +pub fn will_accept_branchless(&self, counter: u64) -> ReplayResult<()> { + let is_growing = counter >= self.next; + let too_far_back = /* calculated */; + let duplicate = self.check_bit_branchless(counter); + + // Single branch at end (constant-time up to this point) + let result = if is_growing { Ok(()) } + else if too_far_back { Err(OutOfWindow) } + else if duplicate { Err(Duplicate) } + else { Ok(()) }; + result.unwrap() +} +``` + +**SIMD Optimizations:** + +- AVX2, SSE2, NEON: SIMD clears are constant-time +- Scalar fallback: Also constant-time (no data-dependent branches) +- No timing channels revealed through replay check + +## Denial of Service (DoS) Protection + +### Connection-Level DoS + +**Attack:** Flood gateway with TCP connections + +**Mitigations:** + +1. **Max connections limit** (default: 10,000): + ```rust + if active_connections >= max_connections { + return; // Drop new connection + } + ``` + - Prevents memory exhaustion (~5 KB per connection) + - Configurable based on gateway capacity + +2. **TCP SYN cookies** (kernel-level): + ```bash + sysctl -w net.ipv4.tcp_syncookies=1 + ``` + - Prevents SYN flood attacks + - No state allocated until 3-way handshake completes + +3. **Connection rate limiting** (iptables): + ```bash + iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --update --seconds 60 --hitcount 100 -j DROP + ``` + - Limits new connections per IP + - 100 connections/minute threshold + +**Residual Risk:** + +- ⚠️ **No per-IP limit in application**: Current implementation only has global limit +- **Recommendation**: Add per-IP tracking: + ```rust + let connections_from_ip = ip_tracker.get(remote_addr.ip()); + if connections_from_ip >= per_ip_limit { + return; // Reject + } + ``` + +### Handshake-Level DoS + +**Attack:** Start handshakes but never complete them + +**Mitigations:** + +1. **Handshake timeout**: Noise state machine times out + - Implementation: Tokio task timeout (implicit) + - Recommended: Explicit 15-second timeout + +2. **State cleanup**: Connection dropped if handshake fails + ```rust + if handshake_fails { + drop(connection); // Frees memory immediately + } + ``` + +3. **No resource allocation before handshake**: + - Replay validator created only after handshake + - Minimal memory usage during handshake (~200 bytes) + +**Attack Scenarios:** + +| Attack | Resource Consumed | Mitigation | +|--------|-------------------|------------| +| Half-open connections | TCP state (~4 KB) | SYN cookies | +| Incomplete handshakes | Noise state (~200 B) | Timeout + cleanup | +| Slow clients | Connection slot | Timeout + max connections | + +### Timestamp-Based DoS + +**Attack:** Replay old ClientHello messages + +**Mitigation:** + +```rust +let timestamp_age = now - client_hello.timestamp; +if timestamp_age > 30_seconds { + return Err(TimestampTooOld); +} +if timestamp_age < -30_seconds { + return Err(TimestampFromFuture); +} +``` + +**Properties:** + +- 30-second window limits replay attack surface +- Clock skew tolerance: ±30 seconds (reasonable for NTP) +- Metrics track rejections: `lp_timestamp_validation_rejected` + +**Residual Risk:** + +- ⚠️ 30-second window allows replay of ClientHello within window +- **Mitigation**: Replay protection on post-handshake messages + +### Credential Verification DoS + +**Attack:** Flood gateway with fake credentials + +**Mitigations:** + +1. **Fast rejection path**: + ```rust + // Check signature before database lookup + if !verify_bls_signature(&credential) { + return Err(InvalidSignature); // Fast path + } + // Only then check database + ``` + +2. **Database indexing**: + ```sql + CREATE INDEX idx_nullifiers ON spent_credentials(nullifier); + ``` + - O(log n) nullifier lookup instead of O(n) + +3. **Rate limiting** (future): + - Limit credential verification attempts per IP + - Exponential backoff for repeated failures + +**Performance Impact:** + +- BLS signature verification: ~5ms per credential +- Database lookup: ~1ms (with index) +- Total: ~6ms per invalid credential + +**Attack Cost:** + +- Attacker must generate BLS signatures (computationally expensive) +- Invalid signatures rejected before database query +- Real cost is in valid-looking but fake credentials (still requires crypto) + +## Threat Scenarios + +### Scenario 1: Passive Eavesdropper + +**Attacker:** Network observer (ISP, hostile network) + +**Capabilities:** +- Observe all LP traffic (including ClientHello) +- Analyze packet sizes, timing, patterns + +**Protections:** +- ✅ ClientHello metadata visible but not sensitive (timestamp, nonce) +- ✅ Noise handshake encrypts all subsequent messages +- ✅ Registration request fully encrypted (credential not visible) +- ✅ ChaCha20-Poly1305 provides IND-CCA2 security + +**Leakage:** +- ⚠️ Client IP address visible (inherent to TCP) +- ⚠️ Packet timing reveals registration events +- ⚠️ Connection to known gateway suggests Nym usage + +**Recommendation:** Use LP for fast registration, mixnet for anonymity-critical operations. + +### Scenario 2: Active MITM + +**Attacker:** On-path adversary (malicious router, hostile WiFi) + +**Capabilities:** +- Intercept, modify, drop, inject packets +- Cannot break cryptography + +**Protections:** +- ✅ Noise XKpsk3 mutual authentication prevents impersonation +- ✅ Client verifies gateway's LP static public key +- ✅ Gateway verifies client via PSK derivation +- ✅ Any packet modification detected via Poly1305 MAC + +**Attack Attempts:** + +1. **Impersonate Gateway**: + - Attacker doesn't have gateway's LP private key + - Cannot complete handshake (Noise fails at `es` mix) + - Client rejects connection + +2. **Impersonate Client**: + - Attacker doesn't know client's LP private key + - Cannot derive correct PSK + - Noise fails at `psk` mix in message 3 + - Gateway rejects connection + +3. **Modify Messages**: + - Poly1305 MAC fails + - Noise decryption fails + - Connection aborted + +**Residual Risk:** +- ⚠️ DoS possible (drop packets, connection killed) +- ✅ Cannot learn registration data or credentials + +### Scenario 3: Gateway Compromise + +**Attacker:** Full access to gateway server + +**Capabilities:** +- Read all gateway state (keys, database, memory) +- Modify gateway behavior +- Impersonate gateway to clients + +**Impact:** + +1. **Current Sessions**: Compromised + - Attacker can decrypt ongoing registration requests + - Can steal credentials from current sessions + +2. **Past Sessions**: Protected (forward secrecy) + - Ephemeral keys already destroyed + - Cannot decrypt recorded traffic + +3. **Future Sessions**: Compromised until key rotation + - Attacker can impersonate gateway + - Can steal credentials from new registrations + +**Mitigations:** + +1. **Key Rotation**: + ```bash + # Generate new LP keypair + ./nym-node generate-lp-keypair + # Update gateway descriptor (automatic on restart) + ``` + - Invalidates attacker's stolen keys + - Clients fetch new public key from descriptor + +2. **Monitoring**: + - Detect anomalous credential verification patterns + - Alert on unusual database access + - Monitor for key file modifications + +3. **Defense in Depth**: + - E-cash credentials have limited value (time-bound, nullifiers) + - WireGuard keys rotatable by client + - No long-term sensitive data stored + +**Credential Reuse Prevention:** + +- Nullifier stored in database +- Nullifier = Hash(credential_data) +- Even with database access, attacker cannot create new credentials +- Can only steal credentials submitted during compromise window + +### Scenario 4: Replay Attack + +**Attacker:** Records past LP sessions, replays later + +**Attack Attempts:** + +1. **Replay ClientHello**: + - Timestamp validation rejects messages > 30s old + - Nonce in salt changes per session + - Cannot reuse old ClientHello + +2. **Replay Handshake Messages**: + - Noise uses ephemeral keys (fresh each session) + - Replaying old handshake messages fails (wrong ephemeral key) + - Handshake fails, no session established + +3. **Replay Post-Handshake Packets**: + - Counter-based replay protection + - Bitmap tracks last 1024 packets + - Duplicate counters rejected + - Cannot replay old encrypted messages + +4. **Replay Entire Session**: + - Different ephemeral keys each time + - Cannot replay connection to gateway + - Even if gateway state reset, timestamp rejects old ClientHello + +**Success Probability:** Negligible (< 2^-128) + +### Scenario 5: Quantum Adversary (Future) + +**Attacker:** Quantum computer with Shor's algorithm + +**Capabilities:** +- Break X25519 ECDH in polynomial time +- Recover LP static private keys from public keys +- Does NOT break symmetric crypto (ChaCha20, Blake3) + +**Impact:** + +1. **Recorded Traffic**: Vulnerable + - Attacker records all LP traffic now + - Breaks X25519 later with quantum computer + - Recovers PSKs from recorded ClientHellos + - Decrypts recorded sessions + +2. **Real-Time Interception**: Full compromise + - Can impersonate gateway (knows private key) + - Can decrypt all traffic + - Complete MITM attack + +**Mitigations (Future):** + +1. **Hybrid PQ-KEM**: + ```rust + // Use both classical and post-quantum KEM + let classical = X25519(client_priv, gateway_pub); + let pq = Kyber768::encaps(gateway_pq_pub); + let psk = Blake3(classical || pq, salt); + ``` + +2. **Post-Quantum Noise**: + - Noise specification supports PQ KEMs + - Can upgrade to Kyber, NTRU, or SIKE + - Requires protocol version 2 + +**Timeline:** +- Quantum threat: ~10-20 years away +- PQ upgrade: Can be deployed when threat becomes real +- Backward compatibility: Support both classical and PQ + +## Security Recommendations + +### For Gateway Operators + +**High Priority:** + +1. **Enable all DoS protections**: + ```toml + [lp] + max_connections = 10000 # Adjust based on capacity + timestamp_tolerance_secs = 30 # Don't increase unnecessarily + ``` + +2. **Secure key storage**: + ```bash + chmod 600 ~/.nym/gateways//keys/lp_x25519.pem + # Encrypt disk if possible + ``` + +3. **Monitor metrics**: + - Alert on high `lp_handshakes_failed` + - Alert on unusual `lp_timestamp_validation_rejected` + - Track `lp_credential_verification_failed` patterns + +4. **Keep database secure**: + - Regular backups + - Index on `nullifier` column + - Periodic cleanup of old nullifiers + +**Medium Priority:** + +5. **Implement per-IP rate limiting** (future): + ```rust + const MAX_CONNECTIONS_PER_IP: usize = 10; + ``` + +6. **Regular key rotation**: + - Rotate LP keypair every 6-12 months + - Coordinate with network updates + +7. **Firewall hardening**: + ```bash + # Only allow LP port + ufw default deny incoming + ufw allow 41264/tcp + ``` + +### For Client Developers + +**High Priority:** + +1. **Verify gateway LP public key**: + ```rust + // Fetch from trusted source (network descriptor) + let gateway_lp_pubkey = fetch_gateway_descriptor(gateway_id) + .await? + .lp_public_key; + + // Pin for future connections + save_pinned_key(gateway_id, gateway_lp_pubkey); + ``` + +2. **Handle errors securely**: + ```rust + match registration_result { + Err(LpError::Replay(_)) => { + // DO NOT retry immediately (might be replay attack) + log::warn!("Replay detected, waiting before retry"); + tokio::time::sleep(Duration::from_secs(60)).await; + } + Err(e) => { + // Other errors safe to retry + } + } + ``` + +3. **Use fresh credentials**: + - Don't reuse credentials across registrations + - Check credential expiry before attempting registration + +**Medium Priority:** + +4. **Implement connection timeout**: + ```rust + tokio::time::timeout( + Duration::from_secs(30), + registration_client.register_lp(...) + ).await? + ``` + +5. **Secure local key storage**: + - Use OS keychain for LP private keys + - Don't log or expose keys + +### For Network Operators + +**High Priority:** + +1. **Deploy monitoring infrastructure**: + - Prometheus + Grafana for metrics + - Alerting on security-relevant metrics + - Correlation of events across gateways + +2. **Incident response plan**: + - Procedure for gateway compromise + - Key rotation workflow + - Client notification mechanism + +3. **Regular security audits**: + - External audit of Noise implementation + - Penetration testing of LP endpoints + - Review of credential verification logic + +**Medium Priority:** + +4. **Threat intelligence**: + - Monitor for known attacks on Noise protocol + - Track quantum computing advances + - Plan PQ migration timeline + +## Compliance Considerations + +### Data Protection (GDPR, etc.) + +**Personal Data Collected:** +- Client IP address (connection metadata) +- Credential nullifiers (pseudonymous identifiers) +- Timestamps (connection events) + +**Data Retention:** +- IP addresses: Not stored beyond connection duration +- Nullifiers: Stored until credential expiry + grace period +- Logs: Configurable retention (default: 7 days) + +**Privacy Protections:** +- Nullifiers pseudonymous (not linkable to real identity) +- No PII collected or stored +- Credentials use blind signatures (gateway doesn't learn identity) + +### Security Compliance + +**SOC 2 / ISO 27001 Requirements:** + +1. **Access Control**: + - LP keys protected (file permissions) + - Database access restricted + - Principle of least privilege + +2. **Encryption in Transit**: + - Noise protocol provides end-to-end encryption + - TLS for metrics endpoint (if exposed) + +3. **Logging and Monitoring**: + - Security events logged + - Metrics for anomaly detection + - Audit trail for credential usage + +4. **Incident Response**: + - Key rotation procedure + - Backup and recovery + - Communication plan + +## Audit Checklist + +Before production deployment: + +- [ ] Noise implementation reviewed by cryptographer +- [ ] Replay protection tested with edge cases (overflow, concurrency) +- [ ] DoS limits tested (connection flood, credential spam) +- [ ] Timing attack resistance verified (replay check, credential verification) +- [ ] Key storage secured (file permissions, encryption at rest) +- [ ] Monitoring and alerting configured +- [ ] Incident response plan documented +- [ ] Penetration testing performed +- [ ] Code review completed +- [ ] Dependencies audited (cargo-audit, cargo-deny) + +## References + +### Security Specifications + +- **Noise Protocol Framework**: https://noiseprotocol.org/ +- **XKpsk3 Analysis**: https://noiseexplorer.com/patterns/XKpsk3/ +- **Curve25519**: https://cr.yp.to/ecdh.html +- **ChaCha20-Poly1305**: RFC 8439 +- **Blake3**: https://github.com/BLAKE3-team/BLAKE3-specs + +### Security Audits + +- [ ] Noise implementation audit (pending) +- [ ] Cryptographic review (pending) +- [ ] Penetration test report (pending) + +### Known Vulnerabilities + +*None currently identified. This section will be updated as issues are discovered.* + +## Responsible Disclosure + +If you discover a security vulnerability in LP: + +1. **DO NOT** publish vulnerability details publicly +2. Email security@nymtech.net with: + - Description of vulnerability + - Steps to reproduce + - Potential impact + - Suggested mitigation (if any) +3. Allow 90 days for patch development before public disclosure +4. Coordinate disclosure timeline with Nym team + +**Bug Bounty**: Check https://nymtech.net/security for current bounty program. diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index cf1b8f286b3..b96bda54fa1 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -65,6 +65,7 @@ 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-upgrade-mode-check = { path = "../common/upgrade-mode-check" } +nym-metrics = { path = "../common/nym-metrics" } nym-wireguard = { path = "../common/wireguard" } nym-wireguard-private-metadata-server = { path = "../common/wireguard-private-metadata/server" } @@ -75,6 +76,12 @@ 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 } [dev-dependencies] diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 8df528674b9..f12189b1d0d 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -15,6 +15,8 @@ pub struct Config { pub upgrade_mode_watcher: UpgradeModeWatcher, + pub lp: crate::node::lp_listener::LpConfig, + pub debug: Debug, } @@ -24,6 +26,7 @@ impl Config { network_requester: impl Into, ip_packet_router: impl Into, upgrade_mode_watcher: impl Into, + lp: impl Into, debug: impl Into, ) -> Self { Config { @@ -31,6 +34,7 @@ impl Config { network_requester: network_requester.into(), ip_packet_router: ip_packet_router.into(), upgrade_mode_watcher: upgrade_mode_watcher.into(), + lp: lp.into(), debug: debug.into(), } } diff --git a/gateway/src/error.rs b/gateway/src/error.rs index 849f658a264..0e6d4f73bcc 100644 --- a/gateway/src/error.rs +++ b/gateway/src/error.rs @@ -125,6 +125,36 @@ 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, + }, + + #[error("Failed to parse ip address: {source}")] + IpAddrParseError { + #[from] + source: defguard_wireguard_rs::net::IpAddrParseError, + }, + + #[error("Invalid SystemTime: {0}")] + InvalidSystemTime(#[from] std::time::SystemTimeError), } impl From for GatewayError { diff --git a/gateway/src/node/client_handling/websocket/common_state.rs b/gateway/src/node/client_handling/websocket/common_state.rs index f3e9f711fad..f129dfbe884 100644 --- a/gateway/src/node/client_handling/websocket/common_state.rs +++ b/gateway/src/node/client_handling/websocket/common_state.rs @@ -3,7 +3,7 @@ use crate::node::ActiveClientsStore; use nym_credential_verification::upgrade_mode::UpgradeModeDetails; -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; @@ -23,7 +23,8 @@ pub(crate) struct Config { #[derive(Clone)] pub(crate) struct CommonHandlerState { pub(crate) cfg: Config, - pub(crate) ecash_verifier: Arc, + pub(crate) ecash_verifier: + Arc, pub(crate) storage: GatewayStorage, pub(crate) local_identity: Arc, pub(crate) metrics: NymNodeMetrics, diff --git a/gateway/src/node/internal_service_providers/authenticator/mod.rs b/gateway/src/node/internal_service_providers/authenticator/mod.rs index f63a86fcc2d..a98c31868e4 100644 --- a/gateway/src/node/internal_service_providers/authenticator/mod.rs +++ b/gateway/src/node/internal_service_providers/authenticator/mod.rs @@ -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; @@ -40,7 +39,7 @@ pub struct Authenticator { custom_topology_provider: Option>, custom_gateway_transceiver: Option>, wireguard_gateway_data: WireguardGatewayData, - ecash_verifier: Arc, + ecash_verifier: Arc, used_private_network_ips: Vec, shutdown: ShutdownTracker, on_start: Option>, @@ -52,7 +51,9 @@ impl Authenticator { upgrade_mode_state: UpgradeModeDetails, wireguard_gateway_data: WireguardGatewayData, used_private_network_ips: Vec, - ecash_verifier: Arc, + ecash_verifier: Arc< + dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync, + >, shutdown: ShutdownTracker, ) -> Self { Self { diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs new file mode 100644 index 00000000000..3fd487e76ae --- /dev/null +++ b/gateway/src/node/lp_listener/handler.rs @@ -0,0 +1,996 @@ +// Copyright 2025 - Nym Technologies SA +// 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::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"); + + // The state machine now accepts only Ed25519 keys and internally derives X25519 keys. + // This simplifies the API by removing manual key conversion from the caller. + // Gateway's Ed25519 identity is used for both PSQ authentication and X25519 derivation. + + // Receive client's public key and salt via ClientHello message + // The client initiates by sending ClientHello as first packet + let (_client_pubkey, client_ed25519_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); + } + }; + + // Create LP handshake as responder + // Pass Ed25519 keys directly - X25519 derivation and PSK generation happen internally + let handshake = LpGatewayHandshake::new_responder( + ( + self.state.local_identity.private_key(), + self.state.local_identity.public_key(), + ), + &client_ed25519_pubkey, + &salt, + )?; + + // 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)?.as_secs(); + + let age = now.abs_diff(client_timestamp); + 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, + nym_crypto::asymmetric::ed25519::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) => { + // 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)?.as_secs(); + now.abs_diff(timestamp) + }, + self.state.lp_config.timestamp_tolerance_secs + ); + + // Convert bytes to X25519 PublicKey (for Noise protocol) + let client_pubkey = PublicKey::from_bytes(&hello_data.client_lp_public_key) + .map_err(|e| { + GatewayError::LpProtocolError(format!("Invalid client public key: {}", e)) + })?; + + // Convert bytes to Ed25519 PublicKey (for PSQ authentication) + let client_ed25519_pubkey = nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( + &hello_data.client_ed25519_public_key, + ) + .map_err(|e| { + GatewayError::LpProtocolError(format!( + "Invalid client Ed25519 public key: {}", + e + )) + })?; + + // Extract salt for PSK derivation + let salt = hello_data.salt; + + Ok((client_pubkey, client_ed25519_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 { + // 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 + ))); + } + + // Decrypt the packet payload using the established session + let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to decrypt registration request: {}", e)) + })?; + + // Deserialize the decrypted bytes into LpRegistrationRequest + bincode::deserialize(&decrypted_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!( + "Failed to deserialize registration request: {}", + e + )) + }) + } + + /// 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)) + })?; + + // Encrypt data first (this increments Noise internal counter) + let encrypted_message = session + .encrypt_data(&data) + .map_err(|e| GatewayError::LpProtocolError(format!("Failed to encrypt data: {}", e)))?; + + // Create LP packet with encrypted message (this increments LP protocol counter) + let packet = session.next_packet(encrypted_message).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to create 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 { + 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"); + } + } +} + +#[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::message::{ClientHelloData, EncryptedDataPayload, HandshakeData, 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, + 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( + 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::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( + stream: &mut R, + ) -> Result { + // 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::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, + reserved: 0, + 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, + reserved: 0, + 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, + reserved: 0, + session_id: 100, + counter: 10, + }, + LpMessage::Handshake(HandshakeData(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, &HandshakeData(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, + reserved: 0, + session_id: 200, + counter: 20, + }, + LpMessage::EncryptedData(EncryptedDataPayload(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, &EncryptedDataPayload(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 client_ed25519_key = [8u8; 32]; + let hello_data = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + 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, + reserved: 0, + 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.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 + // Create separate Ed25519 keypair and derive X25519 from it (like production code) + use nym_crypto::asymmetric::ed25519; + use rand::rngs::OsRng; + + let client_ed25519_keypair = ed25519::KeyPair::new(&mut OsRng); + let client_x25519_public = client_ed25519_keypair.public_key().to_x25519().unwrap(); + + let hello_data = ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + client_ed25519_keypair.public_key().to_bytes(), + ); + let packet = LpPacket::new( + LpHeader { + protocol_version: 1, + reserved: 0, + 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(), "Expected Ok, got: {:?}", result); + + let (x25519_pubkey, ed25519_pubkey, salt) = result.unwrap(); + assert_eq!(x25519_pubkey.as_bytes(), &client_x25519_public.to_bytes()); + assert_eq!( + ed25519_pubkey.to_bytes(), + client_ed25519_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 + // Use proper separate Ed25519 and X25519 keys (like production code) + use nym_crypto::asymmetric::ed25519; + use rand::rngs::OsRng; + + let client_ed25519_keypair = ed25519::KeyPair::new(&mut OsRng); + let client_x25519_public = client_ed25519_keypair.public_key().to_x25519().unwrap(); + + let mut hello_data = ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + client_ed25519_keypair.public_key().to_bytes(), + ); + + // 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, + reserved: 0, + 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"), + } + } +} diff --git a/gateway/src/node/lp_listener/handshake.rs b/gateway/src/node/lp_listener/handshake.rs new file mode 100644 index 00000000000..2e79e836879 --- /dev/null +++ b/gateway/src/node/lp_listener/handshake.rs @@ -0,0 +1,178 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::error::GatewayError; +use nym_lp::{ + 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 + /// + /// # Arguments + /// * `gateway_ed25519_keypair` - Gateway's Ed25519 identity keypair (for PSQ auth and X25519 derivation) + /// * `client_ed25519_public_key` - Client's Ed25519 public key (from ClientHello) + /// * `salt` - Salt from ClientHello (for PSK derivation) + pub fn new_responder( + gateway_ed25519_keypair: ( + &nym_crypto::asymmetric::ed25519::PrivateKey, + &nym_crypto::asymmetric::ed25519::PublicKey, + ), + client_ed25519_public_key: &nym_crypto::asymmetric::ed25519::PublicKey, + salt: &[u8; 32], + ) -> Result { + let state_machine = LpStateMachine::new( + false, // responder + gateway_ed25519_keypair, + client_ed25519_public_key, + salt, + ) + .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 { + 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 { + 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) + } +} diff --git a/gateway/src/node/lp_listener/messages.rs b/gateway/src/node/lp_listener/messages.rs new file mode 100644 index 00000000000..51e18a28051 --- /dev/null +++ b/gateway/src/node/lp_listener/messages.rs @@ -0,0 +1,10 @@ +// Copyright 2025 - Nym Technologies SA +// 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, +}; diff --git a/gateway/src/node/lp_listener/mod.rs b/gateway/src/node/lp_listener/mod.rs new file mode 100644 index 00000000000..11c989c16d0 --- /dev/null +++ b/gateway/src/node/lp_listener/mod.rs @@ -0,0 +1,321 @@ +// Copyright 2025 - Nym Technologies SA +// 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, + + /// Use mock ecash manager for testing (default: false) + /// + /// When enabled, the LP listener will use a mock ecash verifier that + /// accepts any credential without blockchain verification. This is + /// useful for testing the LP protocol implementation without requiring + /// a full blockchain/contract setup. + /// + /// WARNING: Only use this for local testing! Never enable in production. + #[serde(default = "default_use_mock_ecash")] + pub use_mock_ecash: bool, +} + +impl Default for LpConfig { + fn default() -> Self { + Self { + enabled: true, + 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(), + use_mock_ecash: default_use_mock_ecash(), + } + } +} + +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 +} + +fn default_use_mock_ecash() -> bool { + false // Always default to real ecash for security +} + +/// Shared state for LP connection handlers +#[derive(Clone)] +pub struct LpHandlerState { + /// Ecash verifier for bandwidth credentials + pub ecash_verifier: + Arc, + + /// Storage backend for persistence + pub storage: GatewayStorage, + + /// Gateway's identity keypair + pub local_identity: Arc, + + /// 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>, + + /// WireGuard gateway data (contains keypair and config) + pub wireguard_data: Option, + + /// 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() + } +} diff --git a/gateway/src/node/lp_listener/registration.rs b/gateway/src/node/lp_listener/registration.rs new file mode 100644 index 00000000000..2439721c8b0 --- /dev/null +++ b/gateway/src/node/lp_listener/registration.rs @@ -0,0 +1,420 @@ +// Copyright 2025 - Nym Technologies SA +// 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 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, + client_id: i64, +) -> Result { + // Check if bandwidth entry already exists (idempotent) + let existing_bandwidth = ecash_verifier + .storage() + .get_available_bandwidth(client_id) + .await?; + + // Only create if it doesn't exist + if existing_bandwidth.is_none() { + 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 exist".to_string()))?; + Ok(bandwidth) +} + +/// Verify credential and allocate bandwidth using CredentialVerifier +async fn credential_verification( + ecash_verifier: Arc, + credential: CredentialSpendingData, + client_id: i64, +) -> Result { + 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"); + + // For mock ecash mode (local testing), skip cryptographic verification + // and just return a dummy bandwidth value since we don't have blockchain access + let allocated = if ecash_verifier.is_mock() { + // Return a reasonable test bandwidth value (e.g., 1GB in bytes) + const MOCK_BANDWIDTH: i64 = 1024 * 1024 * 1024; + inc!("lp_credential_verification_success"); + inc_by!("lp_bandwidth_allocated_bytes_total", MOCK_BANDWIDTH); + Ok::(MOCK_BANDWIDTH) + } else { + 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()) + } + } + }?; + + Ok(allocated) +} + +/// Process an LP registration request +pub async fn process_registration( + request: LpRegistrationRequest, + state: &LpHandlerState, +) -> LpRegistrationResponse { + let session_id = rand::random::(); + 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.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 + #[allow(clippy::expect_used)] + let client_id = i64::from_be_bytes( + client_id_bytes[0..8] + .try_into() + .expect("This cannot fail, since the id is 32 bytes long"), + ); + + 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], + 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 IPs from centralized pool managed by PeerController + let registration_data = nym_wireguard::PeerRegistrationData::new(peer_key.clone()); + + // Request IP allocation from PeerController + let (tx, rx) = oneshot::channel(); + wg_controller + .send(PeerControlRequest::RegisterPeer { + registration_data, + response_tx: tx, + }) + .await + .map_err(|e| { + GatewayError::InternalError(format!("Failed to send IP allocation request: {}", e)) + })?; + + // Wait for IP allocation from pool + let ip_pair = rx + .await + .map_err(|e| { + GatewayError::InternalError(format!("Failed to receive IP allocation: {}", e)) + })? + .map_err(|e| { + error!("Failed to allocate IPs from pool: {}", e); + GatewayError::InternalError(format!("Failed to allocate IPs: {:?}", e)) + })?; + + let client_ipv4 = ip_pair.ipv4; + let client_ipv6 = ip_pair.ipv6; + + info!( + "Allocated IPs for peer {}: {} / {}", + peer_key, client_ipv4, client_ipv6 + ); + + // Create WireGuard peer with allocated IPs + let mut peer = Peer::new(peer_key.clone()); + peer.preshared_key = Some(Key::new(state.local_identity.public_key().to_bytes())); + peer.endpoint = None; + peer.allowed_ips = vec![ + format!("{client_ipv4}/32").parse()?, + format!("{client_ipv6}/128").parse()?, + ]; + peer.persistent_keepalive_interval = Some(25); + + // Store peer in database FIRST (before adding to controller) + // This ensures bandwidth storage exists when controller's generate_bandwidth_manager() is called + 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)) + })?; + + // Create bandwidth entry for the client + // This must happen BEFORE AddPeer because generate_bandwidth_manager() expects it to exist + credential_storage_preparation(state.ecash_verifier.clone(), client_id).await?; + + // Now send peer to WireGuard 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?; + + // 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, + )) +} diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index ba891bd7165..1edbdd3e24c 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -11,7 +11,7 @@ use crate::node::internal_service_providers::{ use crate::node::stale_data_cleaner::StaleMessagesCleaner; use futures::channel::oneshot; use nym_credential_verification::ecash::{ - credential_sender::CredentialHandlerConfig, EcashManager, + credential_sender::CredentialHandlerConfig, EcashManager, MockEcashManager, }; use nym_credential_verification::upgrade_mode::{ UpgradeModeCheckConfig, UpgradeModeDetails, UpgradeModeState, @@ -37,6 +37,7 @@ use zeroize::Zeroizing; pub use crate::node::upgrade_mode::watcher::UpgradeModeWatcher; pub use client_handling::active_clients::ActiveClientsStore; +pub use lp_listener::LpConfig; pub use nym_credential_verification::upgrade_mode::UpgradeModeCheckRequestSender; pub use nym_gateway_stats_storage::PersistentStatsStorage; pub use nym_gateway_storage::{ @@ -48,6 +49,7 @@ pub use nym_sdk::{NymApiTopologyProvider, NymApiTopologyProviderConfig, UserAgen pub(crate) mod client_handling; pub(crate) mod internal_service_providers; +pub mod lp_listener; mod stale_data_cleaner; pub mod upgrade_mode; @@ -104,7 +106,8 @@ pub struct GatewayTasksBuilder { shutdown_tracker: ShutdownTracker, // populated and cached as necessary - ecash_manager: Option>, + ecash_manager: + Option>, wireguard_peers: Option>, @@ -211,7 +214,23 @@ impl GatewayTasksBuilder { Ok(nyxd_client) } - async fn build_ecash_manager(&self) -> Result, GatewayError> { + async fn build_ecash_manager( + &self, + ) -> Result< + Arc, + GatewayError, + > { + // Check if we should use mock ecash for testing + if self.config.lp.use_mock_ecash { + info!("Using MockEcashManager for LP testing (credentials NOT verified)"); + let mock_manager = MockEcashManager::new(Box::new(self.storage.clone())); + return Ok(Arc::new(mock_manager) + as Arc< + dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync, + >); + } + + // Production path: use real EcashManager with blockchain verification let handler_config = CredentialHandlerConfig { revocation_bandwidth_penalty: self .config @@ -244,16 +263,28 @@ impl GatewayTasksBuilder { "EcashCredentialHandler", ); - Ok(Arc::new(ecash_manager)) + Ok(Arc::new(ecash_manager) + as Arc< + dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync, + >) } - async fn ecash_manager(&mut self) -> Result, GatewayError> { + async fn ecash_manager( + &mut self, + ) -> Result< + Arc, + GatewayError, + > { match self.ecash_manager.clone() { - Some(cached) => Ok(cached), + Some(cached) => Ok(cached + as Arc), 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, + >) } } } @@ -287,6 +318,44 @@ impl GatewayTasksBuilder { )) } + pub async fn build_lp_listener( + &mut self, + active_clients_store: ActiveClientsStore, + ) -> Result { + // Get WireGuard peer controller if available + let wg_peer_controller = self + .wireguard_data + .as_ref() + .map(|wg_data| wg_data.inner.peer_tx().clone()); + + 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, @@ -562,6 +631,7 @@ impl GatewayTasksBuilder { wireguard_data.inner.config().announced_metadata_port, ); + let use_userspace = wireguard_data.use_userspace; let wg_handle = nym_wireguard::start_wireguard( ecash_manager, self.metrics.clone(), @@ -569,6 +639,7 @@ impl GatewayTasksBuilder { self.upgrade_mode_state.upgrade_mode_status(), self.shutdown_tracker.clone_shutdown_token(), wireguard_data, + use_userspace, ) .await?; diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 5c91e350782..8a41137fa50 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -41,6 +41,7 @@ x25519-dalek = { workspace = true, features = [ "static_secrets", ] } +nym-api-requests = { path = "../nym-api/nym-api-requests" } nym-authenticator-requests = { path = "../common/authenticator-requests" } nym-bandwidth-controller = { path = "../common/bandwidth-controller" } nym-bin-common = { path = "../common/bin-common" } @@ -59,6 +60,13 @@ nym-credentials = { path = "../common/credentials" } nym-http-api-client-macro = { path = "../common/http-api-client-macro" } nym-http-api-client = { path = "../common/http-api-client" } nym-node-status-client = { path = "../nym-node-status-api/nym-node-status-client" } +nym-node-requests = { path = "../nym-node/nym-node-requests" } +nym-registration-client = { path = "../nym-registration-client" } +nym-lp = { path = "../common/nym-lp" } +nym-mixnet-contract-common = { path = "../common/cosmwasm-smart-contracts/mixnet-contract" } +nym-network-defaults = { path = "../common/network-defaults" } +nym-registration-common = { path = "../common/registration" } +time = { workspace = true } # TEMP: REMOVE BEFORE PR nym-topology = { path = "../common/topology" } diff --git a/nym-gateway-probe/src/bandwidth_helpers.rs b/nym-gateway-probe/src/bandwidth_helpers.rs index 4753265166f..1f0048d7222 100644 --- a/nym-gateway-probe/src/bandwidth_helpers.rs +++ b/nym-gateway-probe/src/bandwidth_helpers.rs @@ -4,12 +4,14 @@ use anyhow::{Context, bail}; use nym_bandwidth_controller::error::BandwidthControllerError; use nym_client_core::client::base_client::storage::OnDiskPersistent; +use nym_credentials::CredentialSpendingData; use nym_credentials_interface::TicketType; use nym_node_status_client::models::AttachedTicketMaterials; use nym_sdk::bandwidth::BandwidthImporter; use nym_sdk::mixnet::{DisconnectedMixnetClient, EphemeralCredentialStorage}; use nym_validator_client::nyxd::error::NyxdError; use std::time::Duration; +use time::OffsetDateTime; use tracing::{error, info}; pub(crate) async fn import_bandwidth( @@ -155,3 +157,93 @@ pub(crate) async fn acquire_bandwidth( bail!("failed to acquire bandwidth after {MAX_RETRIES} attempts") } + +/// Create a dummy credential for mock ecash testing +/// +/// Gateway with --lp-use-mock-ecash accepts any credential without verification, +/// so we only need to provide properly structured data with correct types. +/// +/// This is useful for local testing without requiring blockchain access or funded accounts. +/// +/// This uses a pre-serialized test credential from the wireguard tests - since MockEcashManager +/// doesn't verify anything, any valid CredentialSpendingData structure will work. +#[allow(clippy::expect_used)] // Test helper with hardcoded valid data +pub(crate) fn create_dummy_credential( + _gateway_identity: &[u8; 32], + _ticket_type: TicketType, +) -> CredentialSpendingData { + // This is a valid serialized CredentialSpendingData taken from integration tests + // See: common/wireguard-private-metadata/tests/src/lib.rs:CREDENTIAL_BYTES + const CREDENTIAL_BYTES: [u8; 1245] = [ + 0, 0, 4, 133, 96, 179, 223, 185, 136, 23, 213, 166, 59, 203, 66, 69, 209, 181, 227, 254, + 16, 102, 98, 237, 59, 119, 170, 111, 31, 194, 51, 59, 120, 17, 115, 229, 79, 91, 11, 139, + 154, 2, 212, 23, 68, 70, 167, 3, 240, 54, 224, 171, 221, 1, 69, 48, 60, 118, 119, 249, 123, + 35, 172, 227, 131, 96, 232, 209, 187, 123, 4, 197, 102, 90, 96, 45, 125, 135, 140, 99, 1, + 151, 17, 131, 143, 157, 97, 107, 139, 232, 212, 87, 14, 115, 253, 255, 166, 167, 186, 43, + 90, 96, 173, 105, 120, 40, 10, 163, 250, 224, 214, 200, 178, 4, 160, 16, 130, 59, 76, 193, + 39, 240, 3, 101, 141, 209, 183, 226, 186, 207, 56, 210, 187, 7, 164, 240, 164, 205, 37, 81, + 184, 214, 193, 195, 90, 205, 238, 225, 195, 104, 12, 123, 203, 57, 233, 243, 215, 145, 195, + 196, 57, 38, 125, 172, 18, 47, 63, 165, 110, 219, 180, 40, 58, 116, 92, 254, 160, 98, 48, + 92, 254, 232, 107, 184, 80, 234, 60, 160, 235, 249, 76, 41, 38, 165, 28, 40, 136, 74, 48, + 166, 50, 245, 23, 201, 140, 101, 79, 93, 235, 128, 186, 146, 126, 180, 134, 43, 13, 186, + 19, 195, 48, 168, 201, 29, 216, 95, 176, 198, 132, 188, 64, 39, 212, 150, 32, 52, 53, 38, + 228, 199, 122, 226, 217, 75, 40, 191, 151, 48, 164, 242, 177, 79, 14, 122, 105, 151, 85, + 88, 199, 162, 17, 96, 103, 83, 178, 128, 9, 24, 30, 74, 108, 241, 85, 240, 166, 97, 241, + 85, 199, 11, 198, 226, 234, 70, 107, 145, 28, 208, 114, 51, 12, 234, 108, 101, 202, 112, + 48, 185, 22, 159, 67, 109, 49, 27, 149, 90, 109, 32, 226, 112, 7, 201, 208, 209, 104, 31, + 97, 134, 204, 145, 27, 181, 206, 181, 106, 32, 110, 136, 115, 249, 201, 111, 5, 245, 203, + 71, 121, 169, 126, 151, 178, 236, 59, 221, 195, 48, 135, 115, 6, 50, 227, 74, 97, 107, 107, + 213, 90, 2, 203, 154, 138, 47, 128, 52, 134, 128, 224, 51, 65, 240, 90, 8, 55, 175, 180, + 178, 204, 206, 168, 110, 51, 57, 189, 169, 48, 169, 136, 121, 99, 51, 170, 178, 214, 74, 1, + 96, 151, 167, 25, 173, 180, 171, 155, 10, 55, 142, 234, 190, 113, 90, 79, 80, 244, 71, 166, + 30, 235, 113, 150, 133, 1, 218, 17, 109, 111, 223, 24, 216, 177, 41, 2, 204, 65, 221, 212, + 207, 236, 144, 6, 65, 224, 55, 42, 1, 1, 161, 134, 118, 127, 111, 220, 110, 127, 240, 71, + 223, 129, 12, 93, 20, 220, 60, 56, 71, 146, 184, 95, 132, 69, 28, 56, 53, 192, 213, 22, + 119, 230, 152, 225, 182, 188, 163, 219, 37, 175, 247, 73, 14, 247, 38, 72, 243, 1, 48, 131, + 59, 8, 13, 96, 143, 185, 127, 241, 161, 217, 24, 149, 193, 40, 16, 30, 202, 151, 28, 119, + 240, 153, 101, 156, 61, 193, 72, 245, 199, 181, 12, 231, 65, 166, 67, 142, 121, 207, 202, + 58, 197, 113, 188, 248, 42, 124, 105, 48, 161, 241, 55, 209, 36, 194, 27, 63, 233, 144, + 189, 85, 117, 234, 9, 139, 46, 31, 206, 114, 95, 131, 29, 240, 13, 81, 142, 140, 133, 33, + 30, 41, 141, 37, 80, 217, 95, 221, 76, 115, 86, 201, 165, 51, 252, 9, 28, 209, 1, 48, 150, + 74, 248, 212, 187, 222, 66, 210, 3, 200, 19, 217, 171, 184, 42, 148, 53, 150, 57, 50, 6, + 227, 227, 62, 49, 42, 148, 148, 157, 82, 191, 58, 24, 34, 56, 98, 120, 89, 105, 176, 85, + 15, 253, 241, 41, 153, 195, 136, 1, 48, 142, 126, 213, 101, 223, 79, 133, 230, 105, 38, + 161, 149, 2, 21, 136, 150, 42, 72, 218, 85, 146, 63, 223, 58, 108, 186, 183, 248, 62, 20, + 47, 34, 113, 160, 177, 204, 181, 16, 24, 212, 224, 35, 84, 51, 168, 56, 136, 11, 1, 48, + 135, 242, 62, 149, 230, 178, 32, 224, 119, 26, 234, 163, 237, 224, 114, 95, 112, 140, 170, + 150, 96, 125, 136, 221, 180, 78, 18, 11, 12, 184, 2, 198, 217, 119, 43, 69, 4, 172, 109, + 55, 183, 40, 131, 172, 161, 88, 183, 101, 1, 48, 173, 216, 22, 73, 42, 255, 211, 93, 249, + 87, 159, 115, 61, 91, 55, 130, 17, 216, 60, 34, 122, 55, 8, 244, 244, 153, 151, 57, 5, 144, + 178, 55, 249, 64, 211, 168, 34, 148, 56, 89, 92, 203, 70, 124, 219, 152, 253, 165, 0, 32, + 203, 116, 63, 7, 240, 222, 82, 86, 11, 149, 167, 72, 224, 55, 190, 66, 201, 65, 168, 184, + 96, 47, 194, 241, 168, 124, 7, 74, 214, 250, 37, 76, 32, 218, 69, 122, 103, 215, 145, 169, + 24, 212, 229, 168, 106, 10, 144, 31, 13, 25, 178, 242, 250, 106, 159, 40, 48, 163, 165, 61, + 130, 57, 146, 4, 73, 32, 254, 233, 125, 135, 212, 29, 111, 4, 177, 114, 15, 210, 170, 82, + 108, 110, 62, 166, 81, 209, 106, 176, 156, 14, 133, 242, 60, 127, 120, 242, 28, 97, 0, 1, + 32, 103, 93, 109, 89, 240, 91, 1, 84, 150, 50, 206, 157, 203, 49, 220, 120, 234, 175, 234, + 150, 126, 225, 94, 163, 164, 199, 138, 114, 62, 99, 106, 112, 1, 32, 171, 40, 220, 82, 241, + 203, 76, 146, 111, 139, 182, 179, 237, 182, 115, 75, 128, 201, 107, 43, 214, 0, 135, 217, + 160, 68, 150, 232, 144, 114, 237, 98, 32, 30, 134, 232, 59, 93, 163, 253, 244, 13, 202, 52, + 147, 168, 83, 121, 123, 95, 21, 210, 209, 225, 223, 143, 49, 10, 205, 238, 1, 22, 83, 81, + 70, 1, 32, 26, 76, 6, 234, 160, 50, 139, 102, 161, 232, 155, 106, 130, 171, 226, 210, 233, + 178, 85, 247, 71, 123, 55, 53, 46, 67, 148, 137, 156, 207, 208, 107, 1, 32, 102, 31, 4, 98, + 110, 156, 144, 61, 229, 140, 198, 84, 196, 238, 128, 35, 131, 182, 137, 125, 241, 95, 69, + 131, 170, 27, 2, 144, 75, 72, 242, 102, 3, 32, 121, 80, 45, 173, 56, 65, 218, 27, 40, 251, + 197, 32, 169, 104, 123, 110, 90, 78, 153, 166, 38, 9, 129, 228, 99, 8, 1, 116, 142, 233, + 162, 69, 32, 216, 169, 159, 116, 95, 12, 63, 176, 195, 6, 183, 123, 135, 75, 61, 112, 106, + 83, 235, 176, 41, 27, 248, 48, 71, 165, 170, 12, 92, 103, 103, 81, 32, 58, 74, 75, 145, + 192, 94, 153, 69, 80, 128, 241, 3, 16, 117, 192, 86, 161, 103, 44, 174, 211, 196, 182, 124, + 55, 11, 107, 142, 49, 88, 6, 41, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 0, 37, 139, 240, 0, 0, + 0, 0, 0, 0, 0, 1, + ]; + + let mut credential = CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES) + .expect("Failed to deserialize test credential - this is a bug in the test harness"); + + // Update spend_date to today to pass validation + credential.spend_date = OffsetDateTime::now_utc().date(); + + credential +} diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 76b8c5c567a..54243865571 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -60,7 +60,7 @@ pub mod nodes; mod types; use crate::bandwidth_helpers::{acquire_bandwidth, import_bandwidth}; -use crate::nodes::NymApiDirectory; +use crate::nodes::{DirectoryNode, NymApiDirectory}; use nym_node_status_client::models::AttachedTicketMaterials; pub use types::{IpPingReplies, ProbeOutcome, ProbeResult}; @@ -146,6 +146,7 @@ pub struct TestedNodeDetails { authenticator_address: Option, authenticator_version: AuthenticatorVersion, ip_address: Option, + lp_address: Option, } pub struct Probe { @@ -154,6 +155,8 @@ pub struct Probe { amnezia_args: String, netstack_args: NetstackArgs, credentials_args: CredentialArgs, + /// Pre-queried gateway node (used when --gateway-ip is specified) + direct_gateway_node: Option, } impl Probe { @@ -169,8 +172,28 @@ impl Probe { amnezia_args: "".into(), netstack_args, credentials_args, + direct_gateway_node: None, } } + + /// Create a probe with a pre-queried gateway node (for direct IP mode) + pub fn new_with_gateway( + entrypoint: NodeIdentity, + tested_node: TestedNode, + netstack_args: NetstackArgs, + credentials_args: CredentialArgs, + gateway_node: DirectoryNode, + ) -> Self { + Self { + entrypoint, + tested_node, + amnezia_args: "".into(), + netstack_args, + credentials_args, + direct_gateway_node: Some(gateway_node), + } + } + pub fn with_amnezia(&mut self, args: &str) -> &Self { self.amnezia_args = args.to_string(); self @@ -178,10 +201,11 @@ impl Probe { pub async fn probe( self, - directory: NymApiDirectory, + directory: Option, nyxd_url: Url, ignore_egress_epoch_role: bool, only_wireguard: bool, + only_lp_registration: bool, min_mixnet_performance: Option, ) -> anyhow::Result { let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?; @@ -217,6 +241,8 @@ impl Probe { nyxd_url, tested_entry, only_wireguard, + only_lp_registration, + false, // Not using mock ecash in regular probe mode ) .await } @@ -225,13 +251,23 @@ impl Probe { pub async fn probe_run_locally( self, config_dir: &PathBuf, - mnemonic: &str, - directory: NymApiDirectory, + mnemonic: Option<&str>, + directory: Option, nyxd_url: Url, ignore_egress_epoch_role: bool, only_wireguard: bool, + only_lp_registration: bool, min_mixnet_performance: Option, + use_mock_ecash: bool, ) -> anyhow::Result { + // If only testing LP registration, use the dedicated LP-only path + // This skips mixnet setup entirely and allows testing local gateways + if only_lp_registration { + return self + .probe_lp_only(config_dir, directory, nyxd_url, use_mock_ecash) + .await; + } + let tested_entry = self.tested_node.is_same_as_entry(); let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; @@ -277,7 +313,11 @@ impl Probe { info!("Credential store contains {} ticketbooks", ticketbook_count); - if ticketbook_count < 1 { + // Only acquire real bandwidth if not using mock ecash + if ticketbook_count < 1 && !use_mock_ecash { + let mnemonic = mnemonic.ok_or_else(|| { + anyhow::anyhow!("mnemonic is required when not using mock ecash (--use-mock-ecash)") + })?; for ticketbook_type in [ TicketType::V1MixnetEntry, TicketType::V1WireguardEntry, @@ -285,6 +325,8 @@ impl Probe { ] { acquire_bandwidth(mnemonic, &disconnected_mixnet_client, ticketbook_type).await?; } + } else if use_mock_ecash { + info!("Using mock ecash mode - skipping bandwidth acquisition"); } let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; @@ -297,14 +339,115 @@ impl Probe { nyxd_url, tested_entry, only_wireguard, + only_lp_registration, + use_mock_ecash, + ) + .await + } + + /// Probe LP registration only, skipping all mixnet tests + /// This is useful for testing local dev gateways that aren't registered in nym-api + pub async fn probe_lp_only( + self, + config_dir: &PathBuf, + directory: Option, + nyxd_url: Url, + use_mock_ecash: bool, + ) -> anyhow::Result { + let tested_entry = self.tested_node.is_same_as_entry(); + let (mixnet_entry_gateway_id, node_info) = self.lookup_gateway(&directory).await?; + + if config_dir.is_file() { + bail!("provided configuration directory is a file"); + } + + if !config_dir.exists() { + std::fs::create_dir_all(config_dir)?; + } + + let storage_paths = StoragePaths::new_from_dir(config_dir)?; + let storage = storage_paths + .initialise_default_persistent_storage() + .await?; + + let key_store = storage.key_store(); + let mut rng = OsRng; + + // Generate client keys if they don't exist + if key_store.load_keys().await.is_err() { + tracing::log::debug!("Generating new client keys"); + nym_client_core::init::generate_new_client_keys(&mut rng, key_store).await?; + } + + // Check if node has LP address + let (lp_address, ip_address) = match (node_info.lp_address, node_info.ip_address) { + (Some(lp_addr), Some(ip_addr)) => (lp_addr, ip_addr), + _ => { + bail!("Gateway does not have LP address configured"); + } + }; + + info!("Testing LP registration for gateway {}", node_info.identity); + + // Create bandwidth controller for credential preparation + let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( + &NymNetworkDetails::new_from_env(), + )?; + let client = nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; + let bw_controller = nym_bandwidth_controller::BandwidthController::new( + storage.credential_store().clone(), + client, + ); + + // Run LP registration probe + let lp_outcome = lp_registration_probe( + node_info.identity, + lp_address, + ip_address, + &bw_controller, + use_mock_ecash, ) .await + .unwrap_or_default(); + + // Return result with only LP outcome + Ok(ProbeResult { + node: node_info.identity.to_string(), + used_entry: mixnet_entry_gateway_id.to_string(), + outcome: types::ProbeOutcome { + as_entry: types::Entry::NotTested, + as_exit: if tested_entry { + None + } else { + Some(types::Exit::fail_to_connect()) + }, + wg: None, + lp: Some(lp_outcome), + }, + }) } pub async fn lookup_gateway( &self, - directory: &NymApiDirectory, + directory: &Option, ) -> anyhow::Result<(NodeIdentity, TestedNodeDetails)> { + // If we have a pre-queried gateway node (direct IP mode), use that + if let Some(direct_node) = &self.direct_gateway_node { + info!("Using pre-queried gateway node from direct IP query"); + let node_info = direct_node.to_testable_node()?; + info!("connecting to entry gateway: {}", direct_node.identity()); + debug!( + "authenticator version: {:?}", + node_info.authenticator_version + ); + return Ok((self.entrypoint, node_info)); + } + + // Otherwise, use the directory (original behavior) + let directory = directory + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Directory is required when not using --gateway-ip"))?; + // Setup the entry gateways let entry_gateway = directory.entry_gateway(&self.entrypoint)?; @@ -340,6 +483,8 @@ impl Probe { nyxd_url: Url, tested_entry: bool, only_wireguard: bool, + only_lp_registration: bool, + use_mock_ecash: bool, ) -> anyhow::Result where T: MixnetClientStorage + Clone + 'static, @@ -361,6 +506,7 @@ impl Probe { }, as_exit: None, wg: None, + lp: None, }, }); } @@ -373,7 +519,7 @@ impl Probe { info!("Our nym address: {nym_address}"); // Now that we have a connected mixnet client, we can start pinging - let (outcome, mixnet_client) = if only_wireguard { + let (outcome, mixnet_client) = if only_wireguard || only_lp_registration { ( Ok(ProbeOutcome { as_entry: if tested_entry { @@ -383,6 +529,7 @@ impl Probe { }, as_exit: None, wg: None, + lp: None, }), mixnet_client, ) @@ -396,7 +543,10 @@ impl Probe { .await }; - let wg_outcome = if let (Some(authenticator), Some(ip_address)) = + let wg_outcome = if only_lp_registration { + // Skip WireGuard test when only testing LP registration + WgProbeResults::default() + } else if let (Some(authenticator), Some(ip_address)) = (node_info.authenticator_address, node_info.ip_address) { // Start the mixnet listener that the auth clients use to receive messages. @@ -449,9 +599,43 @@ impl Probe { WgProbeResults::default() }; + // Test LP registration if node has LP address + let lp_outcome = if let (Some(lp_address), Some(ip_address)) = + (node_info.lp_address, node_info.ip_address) + { + info!("Node has LP address, testing LP registration..."); + + // Prepare bandwidth credential for LP registration + let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( + &NymNetworkDetails::new_from_env(), + )?; + let client = + nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; + let bw_controller = nym_bandwidth_controller::BandwidthController::new( + storage.credential_store().clone(), + client, + ); + + let outcome = lp_registration_probe( + node_info.identity, + lp_address, + ip_address, + &bw_controller, + use_mock_ecash, + ) + .await + .unwrap_or_default(); + + Some(outcome) + } else { + info!("Node does not have LP address, skipping LP registration test"); + None + }; + // Disconnect the mixnet client gracefully outcome.map(|mut outcome| { outcome.wg = Some(wg_outcome); + outcome.lp = lp_outcome; ProbeResult { node: node_info.identity.to_string(), used_entry: mixnet_entry_gateway_id.to_string(), @@ -640,6 +824,165 @@ async fn wg_probe( Ok(wg_outcome) } +async fn lp_registration_probe( + gateway_identity: NodeIdentity, + gateway_lp_address: std::net::SocketAddr, + gateway_ip: IpAddr, + bandwidth_controller: &nym_bandwidth_controller::BandwidthController< + nym_validator_client::nyxd::NyxdClient, + St, + >, + use_mock_ecash: bool, +) -> anyhow::Result +where + St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, + ::StorageError: Send + Sync, +{ + use nym_crypto::asymmetric::ed25519; + use nym_registration_client::LpRegistrationClient; + + info!( + "Starting LP registration probe for gateway at {}", + gateway_lp_address + ); + + let mut lp_outcome = types::LpProbeResults::default(); + + // Generate Ed25519 keypair for this connection (X25519 will be derived internally by LP) + let mut rng = rand::thread_rng(); + let client_ed25519_keypair = std::sync::Arc::new(ed25519::KeyPair::new(&mut rng)); + + // Create LP registration client (uses Ed25519 keys directly, derives X25519 internally) + let mut client = LpRegistrationClient::new_with_default_psk( + client_ed25519_keypair, + gateway_identity, + gateway_lp_address, + gateway_ip, + ); + + // Step 1: Connect to gateway + info!("Connecting to LP listener at {}...", gateway_lp_address); + match client.connect().await { + Ok(_) => { + info!("Successfully connected to LP listener"); + lp_outcome.can_connect = true; + } + Err(e) => { + let error_msg = format!("Failed to connect to LP listener: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + + // Step 2: Perform handshake + info!("Performing LP handshake..."); + match client.perform_handshake().await { + Ok(_) => { + info!("LP handshake completed successfully"); + lp_outcome.can_handshake = true; + } + Err(e) => { + let error_msg = format!("LP handshake failed: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + + // Step 3: Send registration request + info!("Sending LP registration request..."); + + // Generate WireGuard keypair for dVPN registration + let mut rng = rand::thread_rng(); + let wg_keypair = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng); + + // Convert gateway identity to ed25519 public key + let gateway_ed25519_pubkey = match nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( + &gateway_identity.to_bytes(), + ) { + Ok(key) => key, + Err(e) => { + let error_msg = format!("Failed to convert gateway identity: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + }; + + // Generate credential based on mode + let ticket_type = TicketType::V1WireguardEntry; + if use_mock_ecash { + info!("Using mock ecash credential for LP registration"); + let credential = crate::bandwidth_helpers::create_dummy_credential( + &gateway_ed25519_pubkey.to_bytes(), + ticket_type, + ); + + match client + .send_registration_request_with_credential( + &wg_keypair, + &gateway_ed25519_pubkey, + credential, + ticket_type, + ) + .await + { + Ok(_) => { + info!("LP registration request sent successfully with mock ecash"); + } + Err(e) => { + let error_msg = format!("Failed to send LP registration request: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + } else { + info!("Using real bandwidth controller for LP registration"); + match client + .send_registration_request( + &wg_keypair, + &gateway_ed25519_pubkey, + bandwidth_controller, + ticket_type, + ) + .await + { + Ok(_) => { + info!("LP registration request sent successfully with real ecash"); + } + Err(e) => { + let error_msg = format!("Failed to send LP registration request: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + } + + // Step 4: Receive registration response + info!("Waiting for LP registration response..."); + match client.receive_registration_response().await { + Ok(gateway_data) => { + info!("LP registration successful! Received gateway data:"); + info!(" - Gateway public key: {:?}", gateway_data.public_key); + info!(" - Private IPv4: {}", gateway_data.private_ipv4); + info!(" - Private IPv6: {}", gateway_data.private_ipv6); + info!(" - Endpoint: {}", gateway_data.endpoint); + lp_outcome.can_register = true; + } + Err(e) => { + let error_msg = format!("Failed to receive LP registration response: {}", e); + error!("{}", error_msg); + lp_outcome.error = Some(error_msg); + return Ok(lp_outcome); + } + } + + Ok(lp_outcome) +} + fn mixnet_debug_config( min_gateway_performance: Option, ignore_egress_epoch_role: bool, @@ -686,6 +1029,7 @@ async fn do_ping( as_entry: entry, as_exit: exit, wg: None, + lp: None, }), mixnet_client, ) diff --git a/nym-gateway-probe/src/main.rs b/nym-gateway-probe/src/main.rs index d2237d43287..d5ebc4cdd5c 100644 --- a/nym-gateway-probe/src/main.rs +++ b/nym-gateway-probe/src/main.rs @@ -15,6 +15,7 @@ client_defaults!( #[cfg(unix)] #[tokio::main] +#[allow(clippy::exit)] // Intentional exit on error for CLI tool async fn main() -> anyhow::Result<()> { match run::run().await { Ok(ref result) => { @@ -31,6 +32,7 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(unix))] #[tokio::main] +#[allow(clippy::exit)] // Intentional exit for unsupported platform async fn main() -> anyhow::Result<()> { eprintln!("This tool is only supported on Unix systems"); std::process::exit(1) diff --git a/nym-gateway-probe/src/nodes.rs b/nym-gateway-probe/src/nodes.rs index 0726bb3b7d0..84128b15b31 100644 --- a/nym-gateway-probe/src/nodes.rs +++ b/nym-gateway-probe/src/nodes.rs @@ -3,14 +3,25 @@ use crate::TestedNodeDetails; use anyhow::{Context, anyhow, bail}; +use nym_api_requests::models::{ + AuthenticatorDetails, DeclaredRoles, DescribedNodeType, HostInformation, IpPacketRouterDetails, + NetworkRequesterDetails, NymNodeData, OffsetDateTimeJsonSchemaWrapper, WebSockets, + WireguardDetails, +}; use nym_authenticator_requests::AuthenticatorVersion; +use nym_bin_common::build_information::BinaryBuildInformationOwned; use nym_http_api_client::UserAgent; +use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT; +use nym_node_requests::api::client::NymNodeApiClientExt; +use nym_node_requests::api::v1::node::models::AuxiliaryDetails as NodeAuxiliaryDetails; use nym_sdk::mixnet::NodeIdentity; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::NymNodeDescription; use rand::prelude::IteratorRandom; use std::collections::HashMap; -use tracing::{debug, info}; +use std::time::Duration; +use time::OffsetDateTime; +use tracing::{debug, info, warn}; use url::Url; // in the old behaviour we were getting all skimmed nodes to retrieve performance @@ -118,16 +129,189 @@ impl DirectoryNode { .first() .copied(); + // Derive LP address from gateway IP + default LP control port (41264) + // TODO: Update this when LP address is exposed in node description API + let lp_address = ip_address.map(|ip| std::net::SocketAddr::new(ip, 41264)); + Ok(TestedNodeDetails { identity: self.identity(), exit_router_address, authenticator_address, authenticator_version, ip_address, + lp_address, }) } } +/// Query a gateway directly by address using its self-described HTTP API endpoints. +/// This bypasses the need for directory service lookup. +/// +/// # Arguments +/// * `address` - The address of the gateway (IP, IP:PORT, or HOST:PORT format) +/// +/// # Returns +/// A `DirectoryNode` containing all gateway metadata, or an error if the query fails +pub async fn query_gateway_by_ip(address: String) -> anyhow::Result { + info!("Querying gateway directly at address: {}", address); + + // Parse the address to check if it contains a port + let addresses_to_try = if address.contains(':') { + // Address already has port specified, use it directly + vec![ + format!("http://{}", address), + format!("https://{}", address), + ] + } else { + // No port specified, try multiple ports in order of likelihood + vec![ + format!("http://{}:{}", address, DEFAULT_NYM_NODE_HTTP_PORT), // Standard port 8080 + format!("https://{}", address), // HTTPS proxy (443) + format!("http://{}", address), // HTTP proxy (80) + ] + }; + + let user_agent: UserAgent = nym_bin_common::bin_info_local_vergen!().into(); + let mut last_error = None; + + for address in addresses_to_try { + debug!("Trying to connect to gateway at: {}", address); + + // Build client with timeout + let client = match nym_node_requests::api::Client::builder(address.clone()) { + Ok(builder) => match builder + .with_timeout(Duration::from_secs(5)) + .no_hickory_dns() + .with_user_agent(user_agent.clone()) + .build() + { + Ok(c) => c, + Err(e) => { + warn!("Failed to build client for {}: {}", address, e); + last_error = Some(e.into()); + continue; + } + }, + Err(e) => { + warn!("Failed to create client builder for {}: {}", address, e); + last_error = Some(e.into()); + continue; + } + }; + + // Check if the node is up + match client.get_health().await { + Ok(health) if health.status.is_up() => { + info!("Successfully connected to gateway at {}", address); + + // Query all required metadata concurrently + let host_info_result = client.get_host_information().await; + let roles_result = client.get_roles().await; + let build_info_result = client.get_build_information().await; + let aux_details_result = client.get_auxiliary_details().await; + let websockets_result = client.get_mixnet_websockets().await; + + // These are optional, so we use ok() to ignore errors + let ipr_result = client.get_ip_packet_router().await.ok(); + let authenticator_result = client.get_authenticator().await.ok(); + let wireguard_result = client.get_wireguard().await.ok(); + + // Check required fields + let host_info = host_info_result.context("Failed to get host information")?; + let roles = roles_result.context("Failed to get roles")?; + let build_info = build_info_result.context("Failed to get build information")?; + let aux_details: NodeAuxiliaryDetails = aux_details_result.unwrap_or_default(); + let websockets = websockets_result.context("Failed to get websocket info")?; + + // Verify node signature + if !host_info.verify_host_information() { + bail!("Gateway host information signature verification failed"); + } + + // Verify it's actually a gateway + if !roles.gateway_enabled { + bail!("Node at {} is not configured as an entry gateway", address); + } + + // Convert to our internal types + let network_requester: Option = None; // Not needed for LP testing + let ip_packet_router: Option = + ipr_result.map(|ipr| IpPacketRouterDetails { + address: ipr.address, + }); + let authenticator: Option = + authenticator_result.map(|auth| AuthenticatorDetails { + address: auth.address, + }); + #[allow(deprecated)] + let wireguard: Option = + wireguard_result.map(|wg| WireguardDetails { + port: wg.tunnel_port, // Use tunnel_port for deprecated port field + tunnel_port: wg.tunnel_port, + metadata_port: wg.metadata_port, + public_key: wg.public_key, + }); + + // Construct NymNodeData + let node_data = NymNodeData { + last_polled: OffsetDateTimeJsonSchemaWrapper(OffsetDateTime::now_utc()), + host_information: HostInformation { + ip_address: host_info.data.ip_address, + hostname: host_info.data.hostname, + keys: host_info.data.keys.into(), + }, + declared_role: DeclaredRoles { + mixnode: roles.mixnode_enabled, + entry: roles.gateway_enabled, + exit_nr: roles.network_requester_enabled, + exit_ipr: roles.ip_packet_router_enabled, + }, + auxiliary_details: aux_details, + build_information: BinaryBuildInformationOwned { + binary_name: build_info.binary_name, + build_timestamp: build_info.build_timestamp, + build_version: build_info.build_version, + commit_sha: build_info.commit_sha, + commit_timestamp: build_info.commit_timestamp, + commit_branch: build_info.commit_branch, + rustc_version: build_info.rustc_version, + rustc_channel: build_info.rustc_channel, + cargo_triple: build_info.cargo_triple, + cargo_profile: build_info.cargo_profile, + }, + network_requester, + ip_packet_router, + authenticator, + wireguard, + mixnet_websockets: WebSockets { + ws_port: websockets.ws_port, + wss_port: websockets.wss_port, + }, + }; + + // Create NymNodeDescription + let described = NymNodeDescription { + node_id: 0, // We don't have a node_id from direct query + contract_node_type: DescribedNodeType::NymNode, // All new nodes are NymNode type + description: node_data, + }; + + return Ok(DirectoryNode { described }); + } + Ok(_) => { + warn!("Gateway at {} is not healthy", address); + last_error = Some(anyhow!("Gateway is not healthy")); + } + Err(e) => { + warn!("Health check failed for {}: {}", address, e); + last_error = Some(e.into()); + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("Failed to connect to gateway at {}", address))) +} + pub struct NymApiDirectory { // nodes: HashMap, nodes: HashMap, diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 9487b10905f..f293733d2e6 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -4,7 +4,7 @@ use clap::{Parser, Subcommand}; use nym_bin_common::bin_info; use nym_config::defaults::setup_env; -use nym_gateway_probe::nodes::NymApiDirectory; +use nym_gateway_probe::nodes::{NymApiDirectory, query_gateway_by_ip}; use nym_gateway_probe::{CredentialArgs, NetstackArgs, ProbeResult, TestedNode}; use nym_sdk::mixnet::NodeIdentity; use std::path::Path; @@ -37,6 +37,11 @@ struct CliArgs { #[arg(long, short = 'g', alias = "gateway", global = true)] entry_gateway: Option, + /// The address of the gateway to probe directly (bypasses directory lookup) + /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) + #[arg(long, global = true)] + gateway_ip: Option, + /// Identity of the node to test #[arg(long, short, value_parser = validate_node_identity, global = true)] node: Option, @@ -50,6 +55,9 @@ struct CliArgs { #[arg(long, global = true)] only_wireguard: bool, + #[arg(long, global = true)] + only_lp_registration: bool, + /// Disable logging during probe #[arg(long, global = true)] ignore_egress_epoch_role: bool, @@ -76,12 +84,16 @@ const DEFAULT_CONFIG_DIR: &str = "/tmp/nym-gateway-probe/config/"; enum Commands { /// Run the probe locally RunLocal { - /// Provide a mnemonic to get credentials + /// Provide a mnemonic to get credentials (optional when using --use-mock-ecash) #[arg(long)] - mnemonic: String, + mnemonic: Option, #[arg(long)] config_dir: Option, + + /// Use mock ecash credentials for testing (requires gateway with --lp-use-mock-ecash) + #[arg(long)] + use_mock_ecash: bool, }, } @@ -116,18 +128,39 @@ pub(crate) async fn run() -> anyhow::Result { .first() .map(|ep| ep.nyxd_url()) .ok_or(anyhow::anyhow!("missing nyxd url"))?; - let api_url = network - .endpoints - .first() - .and_then(|ep| ep.api_url()) - .ok_or(anyhow::anyhow!("missing nyxd url"))?; - - let directory = NymApiDirectory::new(api_url).await?; - - let entry = if let Some(gateway) = &args.entry_gateway { - NodeIdentity::from_base58_string(gateway)? + // If gateway IP is provided, query it directly without using the directory + let (entry, directory, gateway_node) = if let Some(gateway_ip) = args.gateway_ip { + info!("Using direct IP query mode for gateway: {}", gateway_ip); + let gateway_node = query_gateway_by_ip(gateway_ip).await?; + let identity = gateway_node.identity(); + + // Still create the directory for potential secondary lookups, + // but only if API URL is available + let directory = if let Some(api_url) = network.endpoints.first().and_then(|ep| ep.api_url()) + { + Some(NymApiDirectory::new(api_url).await?) + } else { + None + }; + + (identity, directory, Some(gateway_node)) } else { - directory.random_exit_with_ipr()? + // Original behavior: use directory service + let api_url = network + .endpoints + .first() + .and_then(|ep| ep.api_url()) + .ok_or(anyhow::anyhow!("missing api url"))?; + + let directory = NymApiDirectory::new(api_url).await?; + + let entry = if let Some(gateway) = &args.entry_gateway { + NodeIdentity::from_base58_string(gateway)? + } else { + directory.random_exit_with_ipr()? + }; + + (entry, Some(directory), None) }; let test_point = if let Some(node) = args.node { @@ -136,8 +169,18 @@ pub(crate) async fn run() -> anyhow::Result { TestedNode::SameAsEntry }; - let mut trial = - nym_gateway_probe::Probe::new(entry, test_point, args.netstack_args, args.credential_args); + let mut trial = if let Some(gw_node) = gateway_node { + nym_gateway_probe::Probe::new_with_gateway( + entry, + test_point, + args.netstack_args, + args.credential_args, + gw_node, + ) + } else { + nym_gateway_probe::Probe::new(entry, test_point, args.netstack_args, args.credential_args) + }; + if let Some(awg_args) = args.amnezia_args { trial.with_amnezia(&awg_args); } @@ -146,6 +189,7 @@ pub(crate) async fn run() -> anyhow::Result { Some(Commands::RunLocal { mnemonic, config_dir, + use_mock_ecash, }) => { let config_dir = config_dir .clone() @@ -158,12 +202,14 @@ pub(crate) async fn run() -> anyhow::Result { Box::pin(trial.probe_run_locally( &config_dir, - mnemonic, + mnemonic.as_deref(), directory, nyxd_url, args.ignore_egress_epoch_role, args.only_wireguard, + args.only_lp_registration, args.min_gateway_mixnet_performance, + *use_mock_ecash, )) .await } @@ -173,6 +219,7 @@ pub(crate) async fn run() -> anyhow::Result { nyxd_url, args.ignore_egress_epoch_role, args.only_wireguard, + args.only_lp_registration, args.min_gateway_mixnet_performance, )) .await diff --git a/nym-gateway-probe/src/types.rs b/nym-gateway-probe/src/types.rs index 17f02b40f8a..ec887d61fb2 100644 --- a/nym-gateway-probe/src/types.rs +++ b/nym-gateway-probe/src/types.rs @@ -13,6 +13,7 @@ pub struct ProbeOutcome { pub as_entry: Entry, pub as_exit: Option, pub wg: Option, + pub lp: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -44,6 +45,15 @@ pub struct WgProbeResults { pub download_error_v6: String, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename = "lp")] +pub struct LpProbeResults { + pub can_connect: bool, + pub can_handshake: bool, + pub can_register: bool, + pub error: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] #[allow(clippy::enum_variant_names)] diff --git a/nym-node/nym-node-metrics/src/network.rs b/nym-node/nym-node-metrics/src/network.rs index 74089dd58c0..33fed5474be 100644 --- a/nym-node/nym-node-metrics/src/network.rs +++ b/nym-node/nym-node-metrics/src/network.rs @@ -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, + + 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) + } } diff --git a/nym-node/src/cli/helpers.rs b/nym-node/src/cli/helpers.rs index 08ccef0857b..04666973342 100644 --- a/nym-node/src/cli/helpers.rs +++ b/nym-node/src/cli/helpers.rs @@ -293,6 +293,14 @@ pub(crate) struct WireguardArgs { env = NYMNODE_WG_PRIVATE_NETWORK_PREFIX_ARG )] pub(crate) wireguard_private_network_prefix: Option, + + /// Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. + /// Useful in containerized environments without kernel WireGuard support. + #[clap( + long, + env = NYMNODE_WG_USERSPACE_ARG + )] + pub(crate) wireguard_userspace: Option, } impl WireguardArgs { @@ -321,6 +329,10 @@ impl WireguardArgs { section.private_network_prefix_v4 = private_network_prefix } + if let Some(userspace) = self.wireguard_userspace { + section.use_userspace = userspace + } + section } } @@ -446,6 +458,23 @@ pub(crate) struct EntryGatewayArgs { )] #[zeroize(skip)] pub(crate) upgrade_mode_attester_public_key: Option, + + /// Enable LP (Lewes Protocol) listener for client registration. + /// LP provides an alternative registration protocol with improved security features. + #[clap( + long, + env = NYMNODE_ENABLE_LP_ARG + )] + pub(crate) enable_lp: Option, + + /// Use mock ecash manager for LP testing. + /// WARNING: Only use this for local testing! Never enable in production. + /// When enabled, the LP listener will accept any credential without blockchain verification. + #[clap( + long, + env = NYMNODE_LP_USE_MOCK_ECASH_ARG + )] + pub(crate) lp_use_mock_ecash: Option, } impl EntryGatewayArgs { @@ -479,6 +508,12 @@ impl EntryGatewayArgs { if let Some(upgrade_mode_attester_public_key) = self.upgrade_mode_attester_public_key { section.upgrade_mode.attester_public_key = upgrade_mode_attester_public_key } + if let Some(enable_lp) = self.enable_lp { + section.lp.enabled = enable_lp + } + if let Some(use_mock_ecash) = self.lp_use_mock_ecash { + section.lp.use_mock_ecash = use_mock_ecash + } section } diff --git a/nym-node/src/config/gateway_tasks.rs b/nym-node/src/config/gateway_tasks.rs index ca47c155386..78666fcf2f8 100644 --- a/nym-node/src/config/gateway_tasks.rs +++ b/nym-node/src/config/gateway_tasks.rs @@ -46,6 +46,9 @@ pub struct GatewayTasksConfig { pub upgrade_mode: UpgradeModeWatcher, + #[serde(default)] + pub lp: nym_gateway::node::LpConfig, + #[serde(default)] pub debug: Debug, } @@ -225,6 +228,7 @@ impl GatewayTasksConfig { announce_ws_port: None, announce_wss_port: None, upgrade_mode: UpgradeModeWatcher::new()?, + lp: Default::default(), debug: Default::default(), }) } diff --git a/nym-node/src/config/helpers.rs b/nym-node/src/config/helpers.rs index 9605302aa20..6aafeb0811e 100644 --- a/nym-node/src/config/helpers.rs +++ b/nym-node/src/config/helpers.rs @@ -27,6 +27,7 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config { enabled: config.service_providers.network_requester.debug.enabled, }, config.gateway_tasks.upgrade_mode.clone(), + config.gateway_tasks.lp.clone(), nym_gateway::config::Debug { client_bandwidth_max_flushing_rate: config .gateway_tasks @@ -91,6 +92,8 @@ pub struct GatewayTasksConfig { pub auth_opts: Option, #[allow(dead_code)] pub wg_opts: LocalWireguardOpts, + #[allow(dead_code)] + pub lp: nym_gateway::node::LpConfig, } // that function is rather disgusting, but I hope it's not going to live for too long @@ -212,6 +215,7 @@ pub fn gateway_tasks_config(config: &Config) -> GatewayTasksConfig { announced_metadata_port: config.wireguard.announced_metadata_port, private_network_prefix_v4: config.wireguard.private_network_prefix_v4, private_network_prefix_v6: config.wireguard.private_network_prefix_v6, + use_userspace: config.wireguard.use_userspace, storage_paths: config.wireguard.storage_paths.clone(), }, custom_mixnet_path: None, @@ -223,6 +227,7 @@ pub fn gateway_tasks_config(config: &Config) -> GatewayTasksConfig { ipr_opts: Some(ipr_opts), auth_opts: Some(auth_opts), wg_opts, + lp: config.gateway_tasks.lp.clone(), } } diff --git a/nym-node/src/config/mod.rs b/nym-node/src/config/mod.rs index 08b578760ef..064efc8b6a1 100644 --- a/nym-node/src/config/mod.rs +++ b/nym-node/src/config/mod.rs @@ -958,6 +958,12 @@ pub struct Wireguard { /// The maximum value for IPv6 is 128 pub private_network_prefix_v6: u8, + /// Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. + /// Useful in containerized environments without kernel WireGuard support. + /// default: `false` + #[serde(default)] + pub use_userspace: bool, + /// Paths for wireguard keys, client registries, etc. pub storage_paths: persistence::WireguardPaths, } @@ -973,6 +979,7 @@ impl Wireguard { announced_metadata_port: WG_METADATA_PORT, private_network_prefix_v4: WG_TUN_DEVICE_NETMASK_V4, private_network_prefix_v6: WG_TUN_DEVICE_NETMASK_V6, + use_userspace: false, storage_paths: persistence::WireguardPaths::new(data_dir), } } diff --git a/nym-node/src/config/old_configs/old_config_v10.rs b/nym-node/src/config/old_configs/old_config_v10.rs index e45cca8dd21..4672f4a330b 100644 --- a/nym-node/src/config/old_configs/old_config_v10.rs +++ b/nym-node/src/config/old_configs/old_config_v10.rs @@ -1324,6 +1324,7 @@ pub async fn try_upgrade_config_v10>( announced_metadata_port: WG_METADATA_PORT, private_network_prefix_v4: old_cfg.wireguard.private_network_prefix_v4, private_network_prefix_v6: old_cfg.wireguard.private_network_prefix_v6, + use_userspace: false, storage_paths: WireguardPaths { private_diffie_hellman_key_file: old_cfg .wireguard @@ -1353,6 +1354,7 @@ pub async fn try_upgrade_config_v10>( ) }) .unwrap_or(UpgradeModeWatcher::new_mainnet()), + lp: Default::default(), debug: gateway_tasks::Debug { message_retrieval_limit: old_cfg.gateway_tasks.debug.message_retrieval_limit, maximum_open_connections: old_cfg.gateway_tasks.debug.maximum_open_connections, diff --git a/nym-node/src/env.rs b/nym-node/src/env.rs index 1564d087a43..0f17c7db91b 100644 --- a/nym-node/src/env.rs +++ b/nym-node/src/env.rs @@ -47,6 +47,7 @@ pub mod vars { pub const NYMNODE_WG_BIND_ADDRESS_ARG: &str = "NYMNODE_WG_BIND_ADDRESS"; pub const NYMNODE_WG_ANNOUNCED_PORT_ARG: &str = "NYMNODE_WG_ANNOUNCED_PORT"; pub const NYMNODE_WG_PRIVATE_NETWORK_PREFIX_ARG: &str = "NYMNODE_WG_PRIVATE_NETWORK_PREFIX"; + pub const NYMNODE_WG_USERSPACE_ARG: &str = "NYMNODE_WG_USERSPACE"; // verloc: pub const NYMNODE_VERLOC_BIND_ADDRESS_ARG: &str = "NYMNODE_VERLOC_BIND_ADDRESS"; @@ -65,6 +66,8 @@ pub mod vars { "NYMNODE_UPGRADE_MODE_ATTESTATION_URL"; pub const NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY_ARG: &str = "NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY"; + pub const NYMNODE_ENABLE_LP_ARG: &str = "NYMNODE_ENABLE_LP"; + pub const NYMNODE_LP_USE_MOCK_ECASH_ARG: &str = "NYMNODE_LP_USE_MOCK_ECASH"; // exit gateway: pub const NYMNODE_UPSTREAM_EXIT_POLICY_ARG: &str = "NYMNODE_UPSTREAM_EXIT_POLICY"; diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index 8ba968ca29f..bb6d255d273 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -325,6 +325,7 @@ impl ServiceProvidersData { pub struct WireguardData { inner: WireguardGatewayData, peer_rx: mpsc::Receiver, + use_userspace: bool, } impl WireguardData { @@ -335,7 +336,11 @@ impl WireguardData { &config.storage_paths.x25519_wireguard_storage_paths(), )?), ); - Ok(WireguardData { inner, peer_rx }) + Ok(WireguardData { + inner, + peer_rx, + use_userspace: config.use_userspace, + }) } pub(crate) fn initialise(config: &Wireguard) -> Result<(), ServiceProvidersError> { @@ -357,6 +362,7 @@ impl From for nym_wireguard::WireguardData { nym_wireguard::WireguardData { inner: value.inner, peer_rx: value.peer_rx, + use_userspace: value.use_userspace, } } } @@ -665,6 +671,32 @@ impl NymNode { .await?; self.shutdown_tracker() .try_spawn_named(async move { websocket.run().await }, "EntryWebsocket"); + + // Set WireGuard data early so LP listener can access it + // (LP listener needs wg_peer_controller for dVPN registrations) + if self.config.wireguard.enabled { + let Some(wg_data) = self.wireguard.take() else { + return Err(NymNodeError::WireguardDataUnavailable); + }; + gateway_tasks_builder.set_wireguard_data(wg_data.into()); + } + + // 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"); } @@ -701,13 +733,6 @@ impl NymNode { gateway_tasks_builder.set_authenticator_opts(config.auth_opts); - // that's incredibly nasty, but unfortunately to change it, would require some refactoring... - let Some(wg_data) = self.wireguard.take() else { - return Err(NymNodeError::WireguardDataUnavailable); - }; - - gateway_tasks_builder.set_wireguard_data(wg_data.into()); - let authenticator = gateway_tasks_builder .build_wireguard_authenticator(upgrade_mode_common_state.clone(), topology_provider) .await?; diff --git a/nym-registration-client/Cargo.toml b/nym-registration-client/Cargo.toml index e0e07ff9c7c..18f261dfdb8 100644 --- a/nym-registration-client/Cargo.toml +++ b/nym-registration-client/Cargo.toml @@ -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 @@ -24,7 +27,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" } diff --git a/nym-registration-client/src/builder/config.rs b/nym-registration-client/src/builder/config.rs index f5fac943618..6816c1b1e21 100644 --- a/nym-registration-client/src/builder/config.rs +++ b/nym-registration-client/src/builder/config.rs @@ -15,12 +15,11 @@ use nym_sdk::{ use std::os::fd::RawFd; use std::{path::PathBuf, sync::Arc, time::Duration}; use tokio_util::sync::CancellationToken; -use typed_builder::TypedBuilder; +use crate::config::RegistrationMode; use crate::error::RegistrationClientError; const VPN_AVERAGE_PACKET_DELAY: Duration = Duration::from_millis(15); -const MIXNET_CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone)] pub struct NymNodeWithKeys { @@ -28,15 +27,12 @@ pub struct NymNodeWithKeys { pub keys: Arc, } -#[derive(TypedBuilder)] pub struct BuilderConfig { pub entry_node: NymNodeWithKeys, pub exit_node: NymNodeWithKeys, pub data_path: Option, pub mixnet_client_config: MixnetClientConfig, - #[builder(default = MIXNET_CLIENT_STARTUP_TIMEOUT)] - pub mixnet_client_startup_timeout: Duration, - pub two_hops: bool, + pub mode: RegistrationMode, pub user_agent: UserAgent, pub custom_topology_provider: Box, pub network_env: NymNetworkDetails, @@ -61,11 +57,61 @@ pub struct MixnetClientConfig { } impl BuilderConfig { + /// Creates a new BuilderConfig with all required parameters. + /// + /// However, consider using `BuilderConfig::builder()` instead. + #[allow(clippy::too_many_arguments)] + pub fn new( + entry_node: NymNodeWithKeys, + exit_node: NymNodeWithKeys, + data_path: Option, + mixnet_client_config: MixnetClientConfig, + mode: RegistrationMode, + user_agent: UserAgent, + custom_topology_provider: Box, + network_env: NymNetworkDetails, + cancel_token: CancellationToken, + #[cfg(unix)] connection_fd_callback: Arc, + ) -> Self { + Self { + entry_node, + exit_node, + data_path, + mixnet_client_config, + mode, + user_agent, + custom_topology_provider, + network_env, + cancel_token, + #[cfg(unix)] + connection_fd_callback, + } + } + + /// Creates a builder for BuilderConfig + /// + /// This is the preferred way to construct a BuilderConfig. + /// + /// # Example + /// ```ignore + /// let config = BuilderConfig::builder() + /// .entry_node(entry) + /// .exit_node(exit) + /// .user_agent(agent) + /// .build()?; + /// ``` + pub fn builder() -> BuilderConfigBuilder { + BuilderConfigBuilder::default() + } + 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) + } } } @@ -107,10 +153,9 @@ impl BuilderConfig { ::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 @@ -212,6 +257,176 @@ fn true_to_disabled(val: bool) -> &'static str { if val { "disabled" } else { "enabled" } } +/// Error type for BuilderConfig validation +#[derive(Debug, Clone, thiserror::Error)] +#[allow(clippy::enum_variant_names)] +pub enum BuilderConfigError { + #[error("entry_node is required")] + MissingEntryNode, + #[error("exit_node is required")] + 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")] + MissingTopologyProvider, + #[error("network_env is required")] + MissingNetworkEnv, + #[error("cancel_token is required")] + MissingCancelToken, + #[cfg(unix)] + #[error("connection_fd_callback is required")] + MissingConnectionFdCallback, +} + +/// Builder for `BuilderConfig` +/// +/// 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, + exit_node: Option, + data_path: Option, + mixnet_client_config: Option, + mode: Option, + user_agent: Option, + custom_topology_provider: Option>, + network_env: Option, + cancel_token: Option, + #[cfg(unix)] + connection_fd_callback: Option>, +} + +impl BuilderConfigBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn entry_node(mut self, entry_node: NymNodeWithKeys) -> Self { + self.entry_node = Some(entry_node); + self + } + + pub fn exit_node(mut self, exit_node: NymNodeWithKeys) -> Self { + self.exit_node = Some(exit_node); + self + } + + pub fn data_path(mut self, data_path: Option) -> Self { + self.data_path = data_path; + self + } + + pub fn mixnet_client_config(mut self, mixnet_client_config: MixnetClientConfig) -> Self { + self.mixnet_client_config = Some(mixnet_client_config); + self + } + + /// 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 + } + + pub fn custom_topology_provider( + mut self, + custom_topology_provider: Box, + ) -> Self { + self.custom_topology_provider = Some(custom_topology_provider); + self + } + + pub fn network_env(mut self, network_env: NymNetworkDetails) -> Self { + self.network_env = Some(network_env); + self + } + + pub fn cancel_token(mut self, cancel_token: CancellationToken) -> Self { + self.cancel_token = Some(cancel_token); + self + } + + #[cfg(unix)] + pub fn connection_fd_callback( + mut self, + connection_fd_callback: Arc, + ) -> Self { + self.connection_fd_callback = Some(connection_fd_callback); + self + } + + /// Builds the `BuilderConfig`. + /// + /// Returns an error if any required field is missing. + pub fn build(self) -> Result { + Ok(BuilderConfig { + entry_node: self + .entry_node + .ok_or(BuilderConfigError::MissingEntryNode)?, + exit_node: self.exit_node.ok_or(BuilderConfigError::MissingExitNode)?, + data_path: self.data_path, + mixnet_client_config: self + .mixnet_client_config + .ok_or(BuilderConfigError::MissingMixnetClientConfig)?, + mode: self.mode.ok_or(BuilderConfigError::MissingMode)?, + user_agent: self + .user_agent + .ok_or(BuilderConfigError::MissingUserAgent)?, + custom_topology_provider: self + .custom_topology_provider + .ok_or(BuilderConfigError::MissingTopologyProvider)?, + network_env: self + .network_env + .ok_or(BuilderConfigError::MissingNetworkEnv)?, + cancel_token: self + .cancel_token + .ok_or(BuilderConfigError::MissingCancelToken)?, + #[cfg(unix)] + connection_fd_callback: self + .connection_fd_callback + .ok_or(BuilderConfigError::MissingConnectionFdCallback)?, + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -224,4 +439,54 @@ mod tests { assert_eq!(config.min_mixnode_performance, None); assert_eq!(config.min_gateway_performance, None); } + + #[test] + fn test_builder_config_builder_fails_without_required_fields() { + // Building without any fields should fail with specific error + let result = BuilderConfig::builder().build(); + assert!(result.is_err()); + match result { + Err(BuilderConfigError::MissingEntryNode) => (), // Expected + Err(e) => panic!("Expected MissingEntryNode, got: {}", e), + Ok(_) => panic!("Expected error, got Ok"), + } + } + + #[test] + fn test_builder_config_builder_validates_all_required_fields() { + // Test that each required field is validated + let result = BuilderConfig::builder().build(); + assert!(result.is_err()); + + // Short-circuits at first missing field, so we just verify it's one of the expected errors + #[allow(unreachable_patterns)] // All variants are covered, but keeping catch-all for safety + match result { + Err(BuilderConfigError::MissingEntryNode) + | Err(BuilderConfigError::MissingExitNode) + | Err(BuilderConfigError::MissingMixnetClientConfig) + | Err(BuilderConfigError::MissingUserAgent) + | Err(BuilderConfigError::MissingTopologyProvider) + | Err(BuilderConfigError::MissingNetworkEnv) + | Err(BuilderConfigError::MissingCancelToken) => (), + #[cfg(unix)] + Err(BuilderConfigError::MissingConnectionFdCallback) => (), + Err(e) => panic!("Unexpected error: {}", e), + Ok(_) => panic!("Expected validation error, got Ok"), + } + } + + #[test] + fn test_builder_config_builder_method_chaining() { + // Test that builder methods chain properly and return Self + let builder = BuilderConfig::builder(); + + // Verify the builder returns itself for chaining + let builder = builder.data_path(None); + let builder = builder.data_path(Some("/tmp/test".into())); + let builder = builder.data_path(None); + + // Builder should still fail because required fields are missing + let result = builder.build(); + assert!(result.is_err()); + } } diff --git a/nym-registration-client/src/builder/mod.rs b/nym-registration-client/src/builder/mod.rs index 7993f922d27..b40afc90fca 100644 --- a/nym-registration-client/src/builder/mod.rs +++ b/nym-registration-client/src/builder/mod.rs @@ -12,10 +12,14 @@ use nym_validator_client::{ QueryHttpRpcNyxdClient, nyxd::{Config as NyxdClientConfig, NyxdClient}, }; +use std::time::Duration; use crate::{RegistrationClient, config::RegistrationClientConfig, error::RegistrationClientError}; use config::BuilderConfig; +/// Timeout for mixnet client startup and connection +const MIXNET_CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + pub(crate) mod config; pub struct RegistrationClientBuilder { @@ -32,7 +36,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(); @@ -46,7 +50,7 @@ impl RegistrationClientBuilder { let builder = MixnetClientBuilder::new_with_storage(mixnet_client_storage) .event_tx(EventSender(event_tx)); let mixnet_client = tokio::time::timeout( - self.config.mixnet_client_startup_timeout, + MIXNET_CLIENT_STARTUP_TIMEOUT, self.config.build_and_connect_mixnet_client(builder), ) .await??; @@ -56,7 +60,7 @@ impl RegistrationClientBuilder { } else { let builder = MixnetClientBuilder::new_ephemeral().event_tx(EventSender(event_tx)); let mixnet_client = tokio::time::timeout( - self.config.mixnet_client_startup_timeout, + MIXNET_CLIENT_STARTUP_TIMEOUT, self.config.build_and_connect_mixnet_client(builder), ) .await??; diff --git a/nym-registration-client/src/config.rs b/nym-registration-client/src/config.rs index 71c7e692d8e..8e1ae945121 100644 --- a/nym-registration-client/src/config.rs +++ b/nym-registration-client/src/config.rs @@ -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, } diff --git a/nym-registration-client/src/error.rs b/nym-registration-client/src/error.rs index 822d44dcae6..449d6d46082 100644 --- a/nym-registration-client/src/error.rs +++ b/nym-registration-client/src/error.rs @@ -74,6 +74,25 @@ pub enum RegistrationClientError { #[source] source: Box, }, + + #[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, + }, + + #[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, + }, } impl RegistrationClientError { diff --git a/nym-registration-client/src/lib.rs b/nym-registration-client/src/lib.rs index 4317787c1f3..05d5ceeb7de 100644 --- a/nym-registration-client/src/lib.rs +++ b/nym-registration-client/src/lib.rs @@ -9,13 +9,15 @@ use nym_credentials_interface::TicketType; use nym_ip_packet_client::IprClientConnect; use nym_registration_common::AssignedAddresses; use nym_sdk::mixnet::{EventReceiver, MixnetClient, Recipient}; -use tracing::debug; +use std::sync::Arc; use crate::config::RegistrationClientConfig; +use crate::lp_client::{LpClientError, LpTransport}; mod builder; mod config; mod error; +mod lp_client; mod types; pub use builder::RegistrationClientBuilder; @@ -23,8 +25,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, LpRegistrationClient}; +pub use types::{ + LpRegistrationResult, MixnetRegistrationResult, RegistrationResult, WireguardRegistrationResult, +}; pub struct RegistrationClient { mixnet_client: MixnetClient, @@ -35,49 +41,22 @@ pub struct RegistrationClient { event_rx: EventReceiver, } -// Bundle of an actual error and the underlying mixnet client so it can be shutdown correctly if needed -struct RegistrationError { - mixnet_client: Option, - source: crate::RegistrationClientError, -} - impl RegistrationClient { - async fn register_mix_exit(self) -> Result { + async fn register_mix_exit(self) -> Result { let entry_mixnet_gateway_ip = self.config.entry.node.ip_address; let exit_mixnet_gateway_ip = self.config.exit.node.ip_address; - let Some(ipr_address) = self.config.exit.node.ipr_address else { - return Err(RegistrationError { - mixnet_client: Some(self.mixnet_client), - source: RegistrationClientError::NoIpPacketRouterAddress { - node_id: self.config.exit.node.identity.to_base58_string(), - }, - }); - }; - - let mut ipr_client = - IprClientConnect::new(self.mixnet_client, self.cancel_token.child_token()); - - let interface_addresses = match self - .cancel_token - .run_until_cancelled(ipr_client.connect(ipr_address)) + let ipr_address = self.config.exit.node.ipr_address.ok_or( + RegistrationClientError::NoIpPacketRouterAddress { + node_id: self.config.exit.node.identity.to_base58_string(), + }, + )?; + let mut ipr_client = IprClientConnect::new(self.mixnet_client, self.cancel_token.clone()); + let interface_addresses = ipr_client + .connect(ipr_address) .await - { - Some(Ok(addr)) => addr, - Some(Err(e)) => { - return Err(RegistrationError { - mixnet_client: Some(ipr_client.into_mixnet_client()), - source: RegistrationClientError::ConnectToIpPacketRouter(e), - }); - } - None => { - return Err(RegistrationError { - mixnet_client: Some(ipr_client.into_mixnet_client()), - source: RegistrationClientError::Cancelled, - }); - } - }; + .map_err(RegistrationClientError::ConnectToIpPacketRouter)?; Ok(RegistrationResult::Mixnet(Box::new( MixnetRegistrationResult { @@ -94,24 +73,18 @@ impl RegistrationClient { ))) } - async fn register_wg(self) -> Result { - let Some(entry_auth_address) = self.config.entry.node.authenticator_address else { - return Err(RegistrationError { - mixnet_client: Some(self.mixnet_client), - source: RegistrationClientError::AuthenticationNotPossible { - node_id: self.config.entry.node.identity.to_base58_string(), - }, - }); - }; + async fn register_wg(self) -> Result { + let entry_auth_address = self.config.entry.node.authenticator_address.ok_or( + RegistrationClientError::AuthenticationNotPossible { + node_id: self.config.entry.node.identity.to_base58_string(), + }, + )?; - let Some(exit_auth_address) = self.config.exit.node.authenticator_address else { - return Err(RegistrationError { - mixnet_client: Some(self.mixnet_client), - source: RegistrationClientError::AuthenticationNotPossible { - node_id: self.config.exit.node.identity.to_base58_string(), - }, - }); - }; + let exit_auth_address = self.config.exit.node.authenticator_address.ok_or( + RegistrationClientError::AuthenticationNotPossible { + node_id: self.config.exit.node.identity.to_base58_string(), + }, + )?; let entry_version = self.config.entry.node.version; tracing::debug!("Entry gateway version: {entry_version}"); @@ -120,10 +93,8 @@ impl RegistrationClient { // Start the auth client mixnet listener, which will listen for incoming messages from the // mixnet and rebroadcast them to the auth clients. - // From this point on, we don't need to care about the mixnet client anymore let mixnet_listener = - AuthClientMixnetListener::new(self.mixnet_client, self.cancel_token.child_token()) - .start(); + AuthClientMixnetListener::new(self.mixnet_client, self.cancel_token.clone()).start(); let mut entry_auth_client = AuthenticatorClient::new( mixnet_listener.subscribe(), @@ -150,34 +121,23 @@ impl RegistrationClient { let exit_fut = exit_auth_client .register_wireguard(&*self.bandwidth_controller, TicketType::V1WireguardExit); - let (entry, exit) = Box::pin( - self.cancel_token - .run_until_cancelled(async { tokio::join!(entry_fut, exit_fut) }), - ) - .await - .ok_or(RegistrationError { - mixnet_client: None, - source: RegistrationClientError::Cancelled, - })?; + let (entry, exit) = Box::pin(async { tokio::join!(entry_fut, exit_fut) }).await; - let entry = entry.map_err(|source| RegistrationError { - mixnet_client: None, - source: RegistrationClientError::from_authenticator_error( + let entry = entry.map_err(|source| { + RegistrationClientError::from_authenticator_error( source, self.config.entry.node.identity.to_base58_string(), entry_auth_address, - true, - ), + true, // is entry + ) })?; - - let exit = exit.map_err(|source| RegistrationError { - mixnet_client: None, - source: RegistrationClientError::from_authenticator_error( + let exit = exit.map_err(|source| { + RegistrationClientError::from_authenticator_error( source, self.config.exit.node.identity.to_base58_string(), exit_auth_address, - false, - ), + false, // is exit (not entry) + ) })?; Ok(RegistrationResult::Wireguard(Box::new( @@ -192,24 +152,160 @@ impl RegistrationClient { ))) } - pub async fn register(self) -> Result { - let registration_result = if self.config.two_hops { - self.register_wg().await - } else { - self.register_mix_exit().await + async fn register_lp(self) -> Result { + // 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); + + // Generate fresh Ed25519 keypairs for LP registration + // These are ephemeral and used only for the LP handshake protocol + use nym_crypto::asymmetric::ed25519; + use rand::rngs::OsRng; + let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); + let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); + + // 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 entry_lp_keys = entry_lp_keypair.clone(); + + async move { + let mut client = LpRegistrationClient::new_with_default_psk( + entry_lp_keys, + entry_identity, + 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)) + } }; - // If we failed to register, and we were the owner of the mixnet client, shut it down - match registration_result { - Ok(result) => Ok(result), - Err(error) => { - debug!("Registration failed"); - if let Some(mixnet_client) = error.mixnet_client { - debug!("Shutting down mixnet client"); - mixnet_client.disconnect().await; - } - Err(error.source) + // 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 exit_lp_keys = exit_lp_keypair; + + async move { + let mut client = LpRegistrationClient::new_with_default_psk( + exit_lp_keys, + exit_identity, + 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 { + self.cancel_token + .clone() + .run_until_cancelled(async { + match self.config.mode { + RegistrationMode::Mixnet => self.register_mix_exit().await, + RegistrationMode::Wireguard => self.register_wg().await, + RegistrationMode::Lp => self.register_lp().await, + } + }) + .await + .ok_or(RegistrationClientError::Cancelled)? } } diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs new file mode 100644 index 00000000000..e541a587665 --- /dev/null +++ b/nym-registration-client/src/lp_client/client.rs @@ -0,0 +1,801 @@ +// Copyright 2025 - Nym Technologies SA +// 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::{CredentialSpendingData, TicketType}; +use nym_crypto::asymmetric::{ed25519, x25519}; +use nym_lp::LpPacket; +use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +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, + + /// Client's Ed25519 identity keypair (used for PSQ authentication and X25519 derivation). + local_ed25519_keypair: Arc, + + /// Gateway's Ed25519 public key (from directory/discovery). + gateway_ed25519_public_key: ed25519::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, + + /// 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_ed25519_keypair` - Client's Ed25519 identity keypair (for PSQ auth and X25519 derivation) + /// * `gateway_ed25519_public_key` - Gateway's Ed25519 public key (from directory/discovery) + /// * `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 inside the state machine. + pub fn new( + local_ed25519_keypair: Arc, + gateway_ed25519_public_key: ed25519::PublicKey, + gateway_lp_address: SocketAddr, + client_ip: IpAddr, + config: LpConfig, + ) -> Self { + Self { + tcp_stream: None, + local_ed25519_keypair, + gateway_ed25519_public_key, + gateway_lp_address, + state_machine: None, + client_ip, + config, + } + } + + /// Creates a new LP registration client with default configuration. + /// + /// # Arguments + /// * `local_ed25519_keypair` - Client's Ed25519 identity keypair + /// * `gateway_ed25519_public_key` - Gateway's Ed25519 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 inside the state machine. + /// For custom config, use `new()` directly. + pub fn new_with_default_psk( + local_ed25519_keypair: Arc, + gateway_ed25519_public_key: ed25519::PublicKey, + gateway_lp_address: SocketAddr, + client_ip: IpAddr, + ) -> Self { + Self::new( + local_ed25519_keypair, + gateway_ed25519_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: Derive X25519 keys from Ed25519 for Noise protocol (internal to ClientHello) + // The Ed25519 keys are used for PSQ authentication and also converted to X25519 + let client_x25519_public = self + .local_ed25519_keypair + .public_key() + .to_x25519() + .map_err(|e| { + LpClientError::Crypto(format!("Failed to derive X25519 public key: {}", e)) + })?; + + // Step 2: Generate ClientHelloData with fresh salt and both public keys + let client_hello_data = nym_lp::ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + self.local_ed25519_keypair.public_key().to_bytes(), + ); + let salt = client_hello_data.salt; + + tracing::trace!( + "Generated ClientHello with timestamp: {}", + client_hello_data.extract_timestamp() + ); + + // Step 3: 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 4: Create state machine as initiator with Ed25519 keys + // PSK derivation happens internally in the state machine constructor + let mut state_machine = LpStateMachine::new( + true, // is_initiator + ( + self.local_ed25519_keypair.private_key(), + self.local_ed25519_keypair.public_key(), + ), + &self.gateway_ed25519_public_key, + &salt, + )?; + + // 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?; + + // Check if handshake completed after sending this packet + // (e.g., initiator completes after sending final message) + if state_machine.session()?.is_handshake_complete() { + tracing::info!("LP handshake completed after sending packet"); + break; + } + } + LpAction::HandshakeComplete => { + tracing::info!("LP handshake completed successfully"); + break; + } + LpAction::KKTComplete => { + tracing::info!("KKT exchange completed, starting Noise handshake"); + // After KKT completes, initiator must send first Noise handshake message + let noise_msg = state_machine + .session()? + .prepare_handshake_message() + .ok_or_else(|| { + LpClientError::Transport( + "No handshake message available after KKT".to_string(), + ) + })??; + let noise_packet = state_machine.session()?.next_packet(noise_msg)?; + tracing::trace!("Sending first Noise handshake message"); + Self::send_packet(stream, &noise_packet).await?; + } + 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 { + // 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 + ))), + } + } + + /// Sends LP registration request with a pre-generated credential. + /// This is useful for testing with mock ecash credentials. + /// + /// This implements the LP registration request sending: + /// 1. Uses pre-provided bandwidth credential (skips acquisition) + /// 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_with_credential( + &mut self, + wg_keypair: &x25519::KeyPair, + _gateway_identity: &ed25519::PublicKey, + credential: CredentialSpendingData, + 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!("Using pre-generated credential for registration"); + + // Build registration request with pre-generated credential + 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); + + // 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() + ); + + // 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 + )) + })?; + + // 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 { + // 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 { + // 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 { + // 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 mut rng = rand::thread_rng(); + let keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + let gateway_key = *ed25519::KeyPair::new(&mut rng).public_key(); + 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); + } +} diff --git a/nym-registration-client/src/lp_client/config.rs b/nym-registration-client/src/lp_client/config.rs new file mode 100644 index 00000000000..0d18a0299a5 --- /dev/null +++ b/nym-registration-client/src/lp_client/config.rs @@ -0,0 +1,101 @@ +// Copyright 2025 - Nym Technologies SA +// 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, +} + +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!(config.tcp_nodelay); + assert_eq!(config.tcp_keepalive, None); + } + + #[test] + fn test_config_clone() { + let config = LpConfig::default(); + let cloned = config.clone(); + + assert_eq!(config, cloned); + } +} diff --git a/nym-registration-client/src/lp_client/error.rs b/nym-registration-client/src/lp_client/error.rs new file mode 100644 index 00000000000..20633a6dbc5 --- /dev/null +++ b/nym-registration-client/src/lp_client/error.rs @@ -0,0 +1,62 @@ +// Copyright 2025 - Nym Technologies SA +// 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 }, + + /// Cryptographic operation failed + #[error("Cryptographic error: {0}")] + Crypto(String), +} + +pub type Result = std::result::Result; diff --git a/nym-registration-client/src/lp_client/mod.rs b/nym-registration-client/src/lp_client/mod.rs new file mode 100644 index 00000000000..6a145fdaca1 --- /dev/null +++ b/nym-registration-client/src/lp_client/mod.rs @@ -0,0 +1,41 @@ +// Copyright 2025 - Nym Technologies SA +// 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; diff --git a/nym-registration-client/src/lp_client/transport.rs b/nym-registration-client/src/lp_client/transport.rs new file mode 100644 index 00000000000..51f67e24060 --- /dev/null +++ b/nym-registration-client/src/lp_client/transport.rs @@ -0,0 +1,267 @@ +// Copyright 2025 - Nym Technologies SA +// 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 { + // 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 + /// + /// # 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> { + 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 { + // 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) + } +} diff --git a/nym-registration-client/src/types.rs b/nym-registration-client/src/types.rs index 70c3a4d3a48..ad387d7b5bc 100644 --- a/nym-registration-client/src/types.rs +++ b/nym-registration-client/src/types.rs @@ -9,6 +9,7 @@ use nym_sdk::mixnet::{EventReceiver, MixnetClient}; pub enum RegistrationResult { Mixnet(Box), Wireguard(Box), + Lp(Box), } pub struct MixnetRegistrationResult { @@ -25,3 +26,24 @@ pub struct WireguardRegistrationResult { pub authenticator_listener_handle: AuthClientMixnetListenerHandle, pub bw_controller: Box, } + +/// 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, +} diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 4f910e1a156..014cf185f9f 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4289,6 +4289,7 @@ version = "0.4.0" dependencies = [ "base64 0.22.1", "bs58", + "curve25519-dalek", "ed25519-dalek", "jwt-simple", "nym-pemstore", @@ -4296,6 +4297,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_bytes", + "sha2 0.10.9", "subtle-encoding", "thiserror 2.0.12", "x25519-dalek", diff --git a/tools/internal/testnet-manager/src/manager/node.rs b/tools/internal/testnet-manager/src/manager/node.rs index 8eab0c6499e..1525a95fad4 100644 --- a/tools/internal/testnet-manager/src/manager/node.rs +++ b/tools/internal/testnet-manager/src/manager/node.rs @@ -42,6 +42,7 @@ impl NymNode { host: "127.0.0.1".to_string(), custom_http_port: Some(self.http_port), identity_key: self.identity_key.clone(), + lp_address: None, } }