Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8507c48ca5 | |||
| 2fdf953b24 | |||
| 54898aff5a | |||
| 996fe3fdce | |||
| 81eea63709 | |||
| 637c29adc7 | |||
| 9b567a8483 | |||
| d81fbdd501 | |||
| 5fa1a3fc17 | |||
| 9782ca1452 | |||
| 50fc7a8fbb | |||
| c7cb17d7cf | |||
| a0bb89c6b9 | |||
| cdf8e23af3 | |||
| 7e300654b8 | |||
| e095f6c753 | |||
| b924729992 | |||
| 1cfa79abc9 | |||
| c6180d8b76 | |||
| b340367106 | |||
| 57f3d942b7 | |||
| 0658c5a0c8 | |||
| 9b54a11f4b | |||
| 04300b6c56 | |||
| 1945ad2e4d | |||
| d9b0d5d599 | |||
| 246d061975 | |||
| bd475b5572 | |||
| 7ab5e31b3c | |||
| 76a5d5048f | |||
| 3b891fff1c | |||
| 7c3feaede3 | |||
| 379ec1d61e | |||
| e9c7e931e7 | |||
| f9c40e41f2 | |||
| dd7a43dd15 | |||
| 9eb8cc2de8 | |||
| 7ebacde1fc | |||
| 1386a49a99 | |||
| bb76ff82bd | |||
| b5f1bab87b | |||
| e096ec9c9e | |||
| d09e7a296d | |||
| 4e8411a728 | |||
| 70311da0a6 | |||
| bfd8eb3f2d | |||
| dcf5840b11 | |||
| 9bcae469fc | |||
| 65bd47f342 | |||
| 637459b153 | |||
| 0a6f78a921 | |||
| 539216d585 |
@@ -1,2 +1,5 @@
|
||||
nym-validator-rewarder/.sqlx/** diff=nodiff
|
||||
nym-node-status-api/nym-node-status-api/.sqlx/** diff=nodiff
|
||||
|
||||
# Use bd merge for beads JSONL files
|
||||
.beads/beads.jsonl merge=beads
|
||||
|
||||
+11
-1
@@ -64,4 +64,14 @@ nym-api/redocly/formatted-openapi.json
|
||||
**/settings.sql
|
||||
**/enter_db.sh
|
||||
|
||||
*.profraw
|
||||
*.profraw
|
||||
.beads
|
||||
CLAUDE.md
|
||||
docs
|
||||
.claude
|
||||
.superego
|
||||
|
||||
# Superego (machine-specific paths)
|
||||
.superego/
|
||||
.claude/hooks/superego/
|
||||
.claude/settings.json
|
||||
|
||||
@@ -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 <package-name>
|
||||
|
||||
# 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 <package-name>
|
||||
|
||||
# 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 <package-name>
|
||||
|
||||
# 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 <migration_name>
|
||||
|
||||
# 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
|
||||
Generated
+839
-330
File diff suppressed because it is too large
Load Diff
+17
-4
@@ -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",
|
||||
@@ -153,13 +157,14 @@ 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",
|
||||
"tools/internal/validator-status-check",
|
||||
"tools/nym-cli",
|
||||
"tools/nym-id-cli",
|
||||
"tools/nym-lp-client",
|
||||
"tools/nym-nr-query",
|
||||
"tools/nymvisor",
|
||||
"tools/ts-rs-cli",
|
||||
@@ -168,7 +173,8 @@ members = [
|
||||
"wasm/mix-fetch",
|
||||
"wasm/node-tester",
|
||||
"wasm/zknym-lib",
|
||||
"nym-gateway-probe"
|
||||
"nym-gateway-probe",
|
||||
"integration-tests", "common/nym-lp-transport",
|
||||
]
|
||||
|
||||
default-members = [
|
||||
@@ -186,6 +192,7 @@ default-members = [
|
||||
"service-providers/ip-packet-router",
|
||||
"service-providers/network-requester",
|
||||
"tools/nymvisor",
|
||||
"nym-registration-client"
|
||||
]
|
||||
|
||||
exclude = ["contracts", "nym-wallet", "cpu-cycles"]
|
||||
@@ -207,6 +214,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"
|
||||
@@ -246,6 +254,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" }
|
||||
@@ -286,7 +295,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"
|
||||
@@ -296,6 +307,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"
|
||||
@@ -326,7 +338,7 @@ serde_repr = "0.1"
|
||||
serde_with = "3.9.0"
|
||||
serde_yaml = "0.9.25"
|
||||
serde_plain = "1.0.2"
|
||||
sha2 = "0.10.9"
|
||||
sha2 = "0.10.3"
|
||||
si-scale = "0.2.3"
|
||||
snow = "0.9.6"
|
||||
sphinx-packet = "=0.6.0"
|
||||
@@ -342,6 +354,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"
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use traits::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
|
||||
pub mod acquire;
|
||||
pub mod error;
|
||||
mod event;
|
||||
pub mod mock;
|
||||
mod traits;
|
||||
mod utils;
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![allow(clippy::expect_used)]
|
||||
|
||||
use crate::error::BandwidthControllerError;
|
||||
use crate::{BandwidthTicketProvider, PreparedCredential, PreparedCredentialMetadata};
|
||||
use async_trait::async_trait;
|
||||
use nym_credentials_interface::{CredentialSpendingData, TicketType};
|
||||
use nym_crypto::asymmetric::ed25519::PublicKey;
|
||||
use nym_ecash_time::OffsetDateTime;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockBandwidthController {
|
||||
// TODO: inject proper bls381 keys and just sign credentials
|
||||
//
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
|
||||
impl BandwidthTicketProvider for MockBandwidthController {
|
||||
async fn get_ecash_ticket(
|
||||
&self,
|
||||
_ticket_type: TicketType,
|
||||
_gateway_id: PublicKey,
|
||||
tickets_to_spend: u32,
|
||||
) -> Result<PreparedCredential, BandwidthControllerError> {
|
||||
assert_eq!(tickets_to_spend, 1);
|
||||
|
||||
// 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();
|
||||
|
||||
Ok(PreparedCredential {
|
||||
data: credential,
|
||||
epoch_id: 0,
|
||||
metadata: PreparedCredentialMetadata {
|
||||
ticketbook_id: 0,
|
||||
tickets_withdrawn: 1,
|
||||
used_tickets: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
@@ -170,14 +174,14 @@ impl EcashManager {
|
||||
}
|
||||
|
||||
pub struct MockEcashManager {
|
||||
verfication_key: tokio::sync::RwLock<VerificationKeyAuth>,
|
||||
verification_key: tokio::sync::RwLock<VerificationKeyAuth>,
|
||||
storage: Box<dyn BandwidthGatewayStorage + Send + Sync>,
|
||||
}
|
||||
|
||||
impl MockEcashManager {
|
||||
pub fn new(storage: Box<dyn BandwidthGatewayStorage + Send + Sync>) -> Self {
|
||||
Self {
|
||||
verfication_key: tokio::sync::RwLock::new(
|
||||
verification_key: tokio::sync::RwLock::new(
|
||||
VerificationKeyAuth::from_bytes(&[
|
||||
129, 187, 76, 12, 1, 51, 46, 26, 132, 205, 148, 109, 140, 131, 50, 119, 45,
|
||||
128, 51, 218, 106, 70, 181, 74, 244, 38, 162, 62, 42, 12, 5, 100, 7, 136, 32,
|
||||
@@ -233,7 +237,7 @@ impl traits::EcashManager for MockEcashManager {
|
||||
&self,
|
||||
_epoch_id: EpochId,
|
||||
) -> Result<RwLockReadGuard<'_, VerificationKeyAuth>, EcashTicketError> {
|
||||
Ok(self.verfication_key.read().await)
|
||||
Ok(self.verification_key.read().await)
|
||||
}
|
||||
|
||||
fn storage(&self) -> Box<dyn BandwidthGatewayStorage + Send + Sync> {
|
||||
@@ -249,4 +253,8 @@ impl traits::EcashManager for MockEcashManager {
|
||||
}
|
||||
|
||||
fn async_verify(&self, _ticket: ClientTicket) {}
|
||||
|
||||
fn is_mock(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn EcashManager + Send + Sync>,
|
||||
@@ -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<i64> {
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
use thiserror::Error;
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
@@ -73,7 +74,7 @@ pub struct CredentialSigningData {
|
||||
pub ticketbook_type: TicketType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||
#[derive(Serialize, Deserialize, PartialEq, Clone)]
|
||||
pub struct CredentialSpendingData {
|
||||
pub payment: Payment,
|
||||
|
||||
@@ -86,6 +87,20 @@ pub struct CredentialSpendingData {
|
||||
pub epoch_id: u64,
|
||||
}
|
||||
|
||||
impl Debug for CredentialSpendingData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// we're redacting the payment not since it contains secret,
|
||||
// but because it's producing a lot of noise in the output and
|
||||
// we are not really interested in coordinates of each of the attached curve points
|
||||
f.debug_struct("CredentialSpendingData")
|
||||
.field("payment", &"[REDACTED]")
|
||||
.field("pay_info", &self.pay_info)
|
||||
.field("spend_date", &self.spend_date)
|
||||
.field("epoch_id", &self.epoch_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialSpendingData {
|
||||
pub fn verify(&self, verification_key: &VerificationKeyAuth) -> Result<(), CompactEcashError> {
|
||||
self.payment.spend_verify(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -213,6 +213,37 @@ impl PublicKey {
|
||||
) -> Result<(), SignatureError> {
|
||||
self.0.verify(message.as_ref(), &signature.0)
|
||||
}
|
||||
|
||||
/// Converts this Ed25519 public key to an X25519 public key for ECDH.
|
||||
///
|
||||
/// Uses the standard ed25519→x25519 conversion by converting the Edwards point
|
||||
/// to Montgomery form. This is the same approach as libsodium's
|
||||
/// `crypto_sign_ed25519_pk_to_curve25519`.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(x25519::PublicKey)` - The converted X25519 public key
|
||||
/// * `Err(Ed25519RecoveryError)` - If the conversion fails (e.g., low-order point)
|
||||
pub fn to_x25519(&self) -> Result<crate::asymmetric::x25519::PublicKey, Ed25519RecoveryError> {
|
||||
use curve25519_dalek::edwards::CompressedEdwardsY;
|
||||
|
||||
// Decompress the Ed25519 point
|
||||
let compressed = CompressedEdwardsY((*self).to_bytes());
|
||||
let edwards_point = compressed.decompress().ok_or_else(|| {
|
||||
Ed25519RecoveryError::MalformedBytes(SignatureError::from_source(
|
||||
"Failed to decompress Ed25519 point".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Convert to Montgomery form
|
||||
let montgomery = edwards_point.to_montgomery();
|
||||
|
||||
// Create X25519 public key
|
||||
crate::asymmetric::x25519::PublicKey::from_bytes(montgomery.as_bytes()).map_err(|_| {
|
||||
Ed25519RecoveryError::MalformedBytes(SignatureError::from_source(
|
||||
"Failed to convert to X25519".to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sphinx")]
|
||||
@@ -334,6 +365,30 @@ 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
|
||||
// Both hash and x25519_bytes wrapped in Zeroizing to clear key material
|
||||
let mut hash = zeroize::Zeroizing::new([0u8; 64]);
|
||||
hash.copy_from_slice(&Sha512::digest(self.0));
|
||||
|
||||
// Take first 32 bytes (clamping is done automatically by x25519_dalek::StaticSecret)
|
||||
let mut x25519_bytes = zeroize::Zeroizing::new([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 +572,27 @@ mod tests {
|
||||
|
||||
assert_eq!(sig1.to_vec(), sig2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "rand")]
|
||||
fn test_ed25519_to_x25519_ecdh() {
|
||||
let mut rng = thread_rng();
|
||||
|
||||
// Create two ed25519 keypairs
|
||||
let alice_ed = KeyPair::new(&mut rng);
|
||||
let bob_ed = KeyPair::new(&mut rng);
|
||||
|
||||
// Convert to x25519
|
||||
let alice_x25519_private = alice_ed.private_key().to_x25519();
|
||||
let alice_x25519_public = alice_ed.public_key().to_x25519().unwrap();
|
||||
let bob_x25519_private = bob_ed.private_key().to_x25519();
|
||||
let bob_x25519_public = bob_ed.public_key().to_x25519().unwrap();
|
||||
|
||||
// Perform ECDH both ways
|
||||
let alice_shared = alice_x25519_private.diffie_hellman(&bob_x25519_public);
|
||||
let bob_shared = bob_x25519_private.diffie_hellman(&alice_x25519_public);
|
||||
|
||||
// Both should produce the same shared secret
|
||||
assert_eq!(alice_shared, bob_shared);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Key Derivation Functions using Blake3.
|
||||
|
||||
/// Derives a 32-byte key using Blake3's key derivation mode.
|
||||
///
|
||||
/// Uses Blake3's built-in `derive_key` function with domain separation via context string.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `context` - Context string for domain separation (e.g., "nym-lp-psk-v1")
|
||||
/// * `key_material` - Input key material (shared secret from ECDH, etc.)
|
||||
/// * `salt` - Additional salt for freshness (timestamp + nonce)
|
||||
///
|
||||
/// # Returns
|
||||
/// 32-byte derived key suitable for use as PSK
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let psk = derive_key_blake3("nym-lp-psk-v1", shared_secret.as_bytes(), &salt);
|
||||
/// ```
|
||||
pub fn derive_key_blake3(context: &str, key_material: &[u8], salt: &[u8]) -> [u8; 32] {
|
||||
// Concatenate key_material and salt as input
|
||||
let input = [key_material, salt].concat();
|
||||
|
||||
// Use Blake3's derive_key with context for domain separation
|
||||
// blake3::derive_key returns [u8; 32] directly
|
||||
blake3::derive_key(context, &input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_derivation() {
|
||||
let context = "test-context";
|
||||
let key_material = b"shared_secret_12345";
|
||||
let salt = b"salt_67890";
|
||||
|
||||
let key1 = derive_key_blake3(context, key_material, salt);
|
||||
let key2 = derive_key_blake3(context, key_material, salt);
|
||||
|
||||
assert_eq!(key1, key2, "Same inputs should produce same output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_contexts_produce_different_keys() {
|
||||
let key_material = b"shared_secret";
|
||||
let salt = b"salt";
|
||||
|
||||
let key1 = derive_key_blake3("context1", key_material, salt);
|
||||
let key2 = derive_key_blake3("context2", key_material, salt);
|
||||
|
||||
assert_ne!(
|
||||
key1, key2,
|
||||
"Different contexts should produce different keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_salts_produce_different_keys() {
|
||||
let context = "test-context";
|
||||
let key_material = b"shared_secret";
|
||||
|
||||
let key1 = derive_key_blake3(context, key_material, b"salt1");
|
||||
let key2 = derive_key_blake3(context, key_material, b"salt2");
|
||||
|
||||
assert_ne!(key1, key2, "Different salts should produce different keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_key_material_produces_different_keys() {
|
||||
let context = "test-context";
|
||||
let salt = b"salt";
|
||||
|
||||
let key1 = derive_key_blake3(context, b"secret1", salt);
|
||||
let key2 = derive_key_blake3(context, b"secret2", salt);
|
||||
|
||||
assert_ne!(
|
||||
key1, key2,
|
||||
"Different key material should produce different keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_length() {
|
||||
let key = derive_key_blake3("test", b"key", b"salt");
|
||||
assert_eq!(key.len(), 32, "Output should be exactly 32 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_inputs() {
|
||||
// Should not panic with empty inputs
|
||||
let key = derive_key_blake3("test", b"", b"");
|
||||
assert_eq!(key.len(), 32);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub mod crypto_hash;
|
||||
pub mod hkdf;
|
||||
#[cfg(feature = "hashing")]
|
||||
pub mod hmac;
|
||||
#[cfg(feature = "hashing")]
|
||||
pub mod kdf;
|
||||
#[cfg(all(feature = "asymmetric", feature = "hashing", feature = "stream_cipher"))]
|
||||
pub mod shared_key;
|
||||
pub mod symmetric;
|
||||
|
||||
@@ -133,6 +133,13 @@ impl GatewayStorage {
|
||||
}
|
||||
};
|
||||
|
||||
Self::from_connection_pool(connection_pool, message_retrieval_limit).await
|
||||
}
|
||||
|
||||
pub async fn from_connection_pool(
|
||||
connection_pool: sqlx::sqlite::SqlitePool,
|
||||
message_retrieval_limit: i64,
|
||||
) -> Result<Self, GatewayStorageError> {
|
||||
if let Err(err) = sqlx::migrate!("./migrations").run(&connection_pool).await {
|
||||
error!("Failed to perform migration on the SQLx database: {err}");
|
||||
return Err(err.into());
|
||||
|
||||
@@ -150,6 +150,10 @@ impl OutputParams {
|
||||
pub fn get_output(&self) -> Output {
|
||||
self.output.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn to_response<T: Serialize>(self, data: T) -> FormattedResponse<T> {
|
||||
self.get_output().to_response(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl Output {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[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"] }
|
||||
bytes = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ansi_term = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.11"
|
||||
@@ -0,0 +1,80 @@
|
||||
use bytes::BytesMut;
|
||||
use log::info;
|
||||
use nym_kcp::{packet::KcpPacket, session::KcpSession};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create two KcpSessions, simulating two endpoints
|
||||
let mut local_sess = KcpSession::new(42);
|
||||
let mut remote_sess = KcpSession::new(42);
|
||||
|
||||
// Set an MSS (max segment size) smaller than our data to force fragmentation
|
||||
local_sess.set_mtu(40);
|
||||
remote_sess.set_mtu(40);
|
||||
|
||||
// Some data larger than 30 bytes to demonstrate multi-fragment
|
||||
let big_data = b"The quick brown fox jumps over the lazy dog. This is a test.";
|
||||
|
||||
// --- LOCAL sends data ---
|
||||
info!(
|
||||
"Local: sending data: {:?}",
|
||||
String::from_utf8_lossy(big_data)
|
||||
);
|
||||
local_sess.send(big_data);
|
||||
|
||||
// Update local session's logic at time=0
|
||||
local_sess.update(100);
|
||||
|
||||
// LOCAL fetches outgoing (to be sent across the network)
|
||||
let outgoing_pkts = local_sess.fetch_outgoing();
|
||||
info!("Local: outgoing pkts: {:?}", outgoing_pkts);
|
||||
// Here you'd normally encrypt and send them. We’ll just encode them into a buffer.
|
||||
// Then that buffer is "transferred" to the remote side.
|
||||
let mut wire_buf = BytesMut::new();
|
||||
for pkt in &outgoing_pkts {
|
||||
pkt.encode(&mut wire_buf);
|
||||
}
|
||||
|
||||
// --- REMOTE receives data ---
|
||||
// The remote side "decrypts" (here we just clone) and decodes
|
||||
let mut remote_in = wire_buf.clone();
|
||||
|
||||
// Decode zero or more KcpPackets from remote_in
|
||||
while let Some(decoded_pkt) = KcpPacket::decode(&mut remote_in)? {
|
||||
info!(
|
||||
"Decoded packet, sn: {}, frg: {}",
|
||||
decoded_pkt.sn(),
|
||||
decoded_pkt.frg()
|
||||
);
|
||||
remote_sess.input(&decoded_pkt)?;
|
||||
}
|
||||
|
||||
// Update remote session to process newly received data
|
||||
remote_sess.update(100);
|
||||
|
||||
// The remote session likely generated ACK packets
|
||||
let ack_pkts = remote_sess.fetch_outgoing();
|
||||
|
||||
// --- LOCAL receives ACKs ---
|
||||
// The local side decodes them
|
||||
let mut ack_buf = BytesMut::new();
|
||||
for pkt in &ack_pkts {
|
||||
pkt.encode(&mut ack_buf);
|
||||
}
|
||||
|
||||
while let Some(decoded_pkt) = KcpPacket::decode(&mut ack_buf)? {
|
||||
local_sess.input(&decoded_pkt)?;
|
||||
}
|
||||
|
||||
// Update local again with some arbitrary time, e.g. 50 ms later
|
||||
local_sess.update(100);
|
||||
|
||||
// Just for completeness, local might produce more packets, though typically it's just empty now
|
||||
let _ = local_sess.fetch_outgoing();
|
||||
|
||||
// --- REMOTE reads reassembled data ---
|
||||
|
||||
let incoming = remote_sess.fetch_incoming();
|
||||
info!("Remote: incoming pkts: {:?}", incoming);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead as _, BufReader},
|
||||
};
|
||||
|
||||
use bytes::BytesMut;
|
||||
use log::info;
|
||||
use nym_kcp::{
|
||||
codec::KcpCodec,
|
||||
packet::{KcpCommand, KcpPacket},
|
||||
};
|
||||
use tokio_util::codec::{Decoder as _, Encoder as _};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1) Open a file and read lines
|
||||
let file = File::open("bin/wire_format/packets.txt")?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
// 2) Create our KcpCodec
|
||||
let mut codec = KcpCodec {};
|
||||
|
||||
// We'll use out_buf for encoded data from *all* lines
|
||||
let mut out_buf = BytesMut::new();
|
||||
|
||||
let mut input_lines = vec![];
|
||||
|
||||
// Read lines & encode them all
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
let line = line?;
|
||||
info!("Original line #{}: {}", i + 1, line);
|
||||
|
||||
// Construct a KcpPacket
|
||||
let pkt = KcpPacket::new(
|
||||
42,
|
||||
KcpCommand::Push,
|
||||
0,
|
||||
128,
|
||||
0,
|
||||
i as u32,
|
||||
0,
|
||||
line.as_bytes().to_vec(),
|
||||
);
|
||||
|
||||
input_lines.push(pkt.clone_data());
|
||||
|
||||
// Encode (serialize) the packet into out_buf
|
||||
codec.encode(pkt, &mut out_buf)?;
|
||||
}
|
||||
|
||||
// === Simulate encryption & transmission ===
|
||||
// In reality, you might do `encrypt(&out_buf)` and then
|
||||
// send it over the network. We'll just clone here:
|
||||
let mut received_buf = out_buf.clone();
|
||||
|
||||
// 3) Now decode (deserialize) all packets at once
|
||||
// For demonstration, read them back out
|
||||
let mut count = 0;
|
||||
|
||||
let mut decoded_lines = vec![];
|
||||
|
||||
#[allow(clippy::while_let_loop)]
|
||||
loop {
|
||||
match codec.decode(&mut received_buf)? {
|
||||
Some(decoded_pkt) => {
|
||||
count += 1;
|
||||
// Convert packet data back to a string
|
||||
let decoded_str = String::from_utf8_lossy(decoded_pkt.data());
|
||||
info!("Decoded line #{}: {}", decoded_pkt.sn() + 1, decoded_str);
|
||||
|
||||
decoded_lines.push(decoded_pkt.clone_data());
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
for (i, j) in input_lines.iter().zip(decoded_lines.iter()) {
|
||||
assert_eq!(i, j);
|
||||
}
|
||||
|
||||
info!("Decoded {} lines total.", count);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
packet 1
|
||||
packet 2
|
||||
packet 3
|
||||
packet 4
|
||||
packet 5
|
||||
packet 6
|
||||
packet 7
|
||||
packet 8
|
||||
packet 9
|
||||
packet 10
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::io;
|
||||
|
||||
use bytes::BytesMut;
|
||||
use tokio_util::codec::{Decoder, Encoder};
|
||||
|
||||
use super::packet::KcpPacket;
|
||||
|
||||
/// Our codec for encoding/decoding KCP packets
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KcpCodec;
|
||||
|
||||
impl Decoder for KcpCodec {
|
||||
type Item = KcpPacket;
|
||||
type Error = io::Error;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
// We simply delegate to `KcpPacket::decode`
|
||||
KcpPacket::decode(src).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder<KcpPacket> for KcpCodec {
|
||||
type Error = io::Error;
|
||||
|
||||
fn encode(&mut self, item: KcpPacket, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||
// We just call `item.encode` to append the bytes
|
||||
item.encode(dst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use bytes::BytesMut;
|
||||
use log::{debug, trace};
|
||||
|
||||
use crate::{error::KcpError, packet::KcpPacket, session::KcpSession};
|
||||
|
||||
pub struct KcpDriver {
|
||||
session: KcpSession,
|
||||
buffer: BytesMut,
|
||||
}
|
||||
|
||||
impl KcpDriver {
|
||||
pub fn conv_id(&self) -> Result<u32, KcpError> {
|
||||
Ok(self.session.conv)
|
||||
}
|
||||
|
||||
pub fn send(&mut self, data: &[u8]) {
|
||||
self.session.send(data);
|
||||
}
|
||||
|
||||
pub fn input(&mut self, data: &[u8]) -> Result<Vec<KcpPacket>, KcpError> {
|
||||
self.buffer.extend_from_slice(data);
|
||||
let mut pkts = Vec::new();
|
||||
while let Ok(Some(pkt)) = KcpPacket::decode(&mut self.buffer) {
|
||||
debug!(
|
||||
"Decoded packet, cmd: {}, sn: {}, frg: {}",
|
||||
pkt.command(),
|
||||
pkt.sn(),
|
||||
pkt.frg()
|
||||
);
|
||||
self._input(&pkt)?;
|
||||
pkts.push(pkt);
|
||||
}
|
||||
Ok(pkts)
|
||||
}
|
||||
|
||||
fn _input(&mut self, pkt: &KcpPacket) -> Result<(), KcpError> {
|
||||
self.session.input(pkt)
|
||||
}
|
||||
|
||||
pub fn fetch_outgoing(&mut self) -> Vec<KcpPacket> {
|
||||
trace!(
|
||||
"ts_flush: {}, ts_current: {}",
|
||||
self.session.ts_flush(),
|
||||
self.session.ts_current()
|
||||
);
|
||||
self.session.fetch_outgoing()
|
||||
}
|
||||
|
||||
pub fn update(&mut self, tick: u64) {
|
||||
self.session.update(tick as u32);
|
||||
}
|
||||
|
||||
pub fn new(session: KcpSession) -> Self {
|
||||
KcpDriver {
|
||||
session,
|
||||
buffer: BytesMut::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch any complete messages that have been reassembled from received KCP packets.
|
||||
///
|
||||
/// Returns a vector of complete messages. Messages are only returned once all
|
||||
/// fragments have been received and reassembled.
|
||||
pub fn fetch_incoming(&mut self) -> Vec<BytesMut> {
|
||||
self.session.fetch_incoming()
|
||||
}
|
||||
|
||||
/// Read reassembled data into a buffer.
|
||||
///
|
||||
/// Returns the number of bytes read into the buffer.
|
||||
/// If no complete message is available, returns 0.
|
||||
pub fn recv(&mut self, buf: &mut [u8]) -> usize {
|
||||
self.session.recv(buf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum KcpError {
|
||||
#[error("Invalid KCP command value: {0}")]
|
||||
InvalidCommand(u8),
|
||||
|
||||
#[error("Conversation ID mismatch: expected {expected}, received {received}")]
|
||||
ConvMismatch { expected: u32, received: u32 },
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod codec;
|
||||
pub mod driver;
|
||||
pub mod error;
|
||||
pub mod packet;
|
||||
pub mod session;
|
||||
|
||||
pub const MAX_RTO: u32 = 60000; // Same as used in update_rtt
|
||||
@@ -0,0 +1,224 @@
|
||||
use bytes::{Buf, BufMut, BytesMut};
|
||||
use log::{debug, trace};
|
||||
|
||||
use super::error::KcpError;
|
||||
|
||||
// Nym-KCP uses a modified header format with u16 for frg field (25 bytes total).
|
||||
// Standard KCP uses u8 for frg (24 bytes). This deviation from skywind3000/kcp protocol
|
||||
// supports messages up to ~91MB (65535 fragments × MTU) vs standard 355KB limit.
|
||||
// This is intentional - Nym uses KCP internally for reliability/multiplexing, not interop.
|
||||
pub const KCP_HEADER: usize = 25;
|
||||
|
||||
/// Typed enumeration for KCP commands.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum KcpCommand {
|
||||
Push = 81, // cmd: push data
|
||||
Ack = 82, // cmd: ack
|
||||
Wask = 83, // cmd: window probe (ask)
|
||||
Wins = 84, // cmd: window size (tell)
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KcpCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
KcpCommand::Push => write!(f, "Push"),
|
||||
KcpCommand::Ack => write!(f, "Ack"),
|
||||
KcpCommand::Wask => write!(f, "Window Probe (ask)"),
|
||||
KcpCommand::Wins => write!(f, "Window Size (tell)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for KcpCommand {
|
||||
type Error = KcpError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
81 => Ok(KcpCommand::Push),
|
||||
82 => Ok(KcpCommand::Ack),
|
||||
83 => Ok(KcpCommand::Wask),
|
||||
84 => Ok(KcpCommand::Wins),
|
||||
_ => Err(KcpError::InvalidCommand(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::from_over_into)]
|
||||
impl Into<u8> for KcpCommand {
|
||||
fn into(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// A single KCP packet (on-wire format).
|
||||
/// Note: Nym-KCP uses u16 for frg (fragment count) instead of standard u8.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KcpPacket {
|
||||
conv: u32,
|
||||
cmd: KcpCommand,
|
||||
frg: u16,
|
||||
wnd: u16,
|
||||
ts: u32,
|
||||
sn: u32,
|
||||
una: u32,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
impl KcpPacket {
|
||||
pub fn new(
|
||||
conv: u32,
|
||||
cmd: KcpCommand,
|
||||
frg: u16,
|
||||
wnd: u16,
|
||||
ts: u32,
|
||||
sn: u32,
|
||||
una: u32,
|
||||
data: Vec<u8>,
|
||||
) -> Self {
|
||||
Self {
|
||||
conv,
|
||||
cmd,
|
||||
frg,
|
||||
wnd,
|
||||
ts,
|
||||
sn,
|
||||
una,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&self) -> KcpCommand {
|
||||
self.cmd
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
pub fn clone_data(&self) -> Vec<u8> {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
pub fn conv(&self) -> u32 {
|
||||
self.conv
|
||||
}
|
||||
|
||||
pub fn cmd(&self) -> KcpCommand {
|
||||
self.cmd
|
||||
}
|
||||
|
||||
pub fn frg(&self) -> u16 {
|
||||
self.frg
|
||||
}
|
||||
|
||||
pub fn wnd(&self) -> u16 {
|
||||
self.wnd
|
||||
}
|
||||
|
||||
pub fn ts(&self) -> u32 {
|
||||
self.ts
|
||||
}
|
||||
|
||||
pub fn sn(&self) -> u32 {
|
||||
self.sn
|
||||
}
|
||||
|
||||
pub fn una(&self) -> u32 {
|
||||
self.una
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KcpPacket {
|
||||
fn default() -> Self {
|
||||
// We must pick some default command, e.g. `Push`.
|
||||
// Or omit `Default` if you don't need it.
|
||||
KcpPacket {
|
||||
conv: 0,
|
||||
cmd: KcpCommand::Push,
|
||||
frg: 0,
|
||||
wnd: 0,
|
||||
ts: 0,
|
||||
sn: 0,
|
||||
una: 0,
|
||||
data: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KcpPacket {
|
||||
/// Attempt to decode a `KcpPacket` from `src`.
|
||||
/// Returns Ok(Some(pkt)) if fully available, Ok(None) if not enough data,
|
||||
/// or Err(...) if there's an invalid command or other error.
|
||||
pub fn decode(src: &mut BytesMut) -> Result<Option<Self>, KcpError> {
|
||||
trace!("Decoding buffer with len: {}", src.len());
|
||||
if src.len() < KCP_HEADER {
|
||||
// Not enough for even the header, this is usually fine, more data will arrive
|
||||
debug!("Not enough data for header");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Peek into the header (25 bytes for Nym-KCP)
|
||||
let mut header = &src[..KCP_HEADER];
|
||||
|
||||
let conv = header.get_u32_le();
|
||||
let cmd_byte = header.get_u8();
|
||||
let frg = header.get_u16_le();
|
||||
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_u16_le(self.frg);
|
||||
dst.put_u16_le(self.wnd);
|
||||
dst.put_u32_le(self.ts);
|
||||
dst.put_u32_le(self.sn);
|
||||
dst.put_u32_le(self.una);
|
||||
dst.put_u32_le(self.data.len() as u32);
|
||||
dst.extend_from_slice(&self.data);
|
||||
|
||||
trace!("Encoded packet: {:?}, len: {}", dst, dst.len());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "nym-kkt"
|
||||
version = "0.1.0"
|
||||
authors = ["Georgio Nicolas <georgio@nymtech.net>"]
|
||||
edition = { workspace = true }
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# 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"
|
||||
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
|
||||
@@ -0,0 +1,522 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// fine in benchmarking code
|
||||
#![allow(clippy::expect_used)]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,303 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<Self, KKTError> {
|
||||
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<u8> {
|
||||
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<u8>,
|
||||
) -> Result<Self, KKTError> {
|
||||
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<Self, KKTError> {
|
||||
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) -> Result<Algorithm, KKTError> {
|
||||
match kem {
|
||||
KEM::MlKem768 => Ok(Algorithm::MlKem768),
|
||||
KEM::XWing => Ok(Algorithm::XWingKemDraft06),
|
||||
KEM::X25519 => Ok(Algorithm::X25519),
|
||||
KEM::McEliece => Err(KKTError::KEMMapping {
|
||||
info: "attempted to map McEliece KEM to libcrux_kem",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<Self, KKTError> {
|
||||
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<Self, KKTError> {
|
||||
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<Vec<u8>, KKTError> {
|
||||
let mut header_bytes: Vec<u8> = 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<Self, KKTError> {
|
||||
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
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<R>(
|
||||
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<Vec<u8>, KKTError> {
|
||||
// The empty nonce is fine since we use the key once.
|
||||
let nonce = Nonce::<Aes256GcmSiv>::from_slice(&[]);
|
||||
|
||||
let ciphertext =
|
||||
nym_crypto::symmetric::aead::encrypt::<Aes256GcmSiv>(&key.into(), nonce, message)?;
|
||||
|
||||
key.zeroize();
|
||||
|
||||
Ok(ciphertext)
|
||||
}
|
||||
|
||||
fn decrypt(key: [u8; 32], ciphertext: Vec<u8>) -> Vec<u8> {
|
||||
// The empty nonce is fine since we use the key once.
|
||||
let nonce = Nonce::<Aes256>::from_slice(&[]);
|
||||
|
||||
let ciphertext =
|
||||
nym_crypto::symmetric::aead::encrypt::<Aes256GcmSiv>(&key.into(), nonce, message)?;
|
||||
|
||||
key.zeroize();
|
||||
|
||||
Ok(ciphertext)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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("KEM mapping failure: {}", info)]
|
||||
KEMMapping { info: &'static str },
|
||||
|
||||
#[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<libcrux_kem::Error> 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<libcrux_ecdh::Error> for KKTError {
|
||||
fn from(err: libcrux_ecdh::Error) -> Self {
|
||||
match err {
|
||||
libcrux_ecdh::Error::InvalidPoint => KKTError::KEMError {
|
||||
info: "Invalid Remote Public Key",
|
||||
},
|
||||
_ => KKTError::LibcruxError,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<u8>,
|
||||
session_id: Vec<u8>,
|
||||
body: Vec<u8>,
|
||||
signature: Vec<u8>,
|
||||
}
|
||||
|
||||
// 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<u8> {
|
||||
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<u8>, Vec<u8>, Vec<u8>) =
|
||||
(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()
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<R>(
|
||||
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<u8> {
|
||||
let mut hashed_key: Vec<u8> = 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<u8> {
|
||||
hash_key_bytes(hash_function, hash_length, encapsulation_key)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<R: CryptoRng + RngCore>(
|
||||
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<EncapsulationKey<'a>, 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<KKTFrame, KKTError> {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<R>(
|
||||
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<EncapsulationKey<'a>, 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<u8> = 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<EncapsulationKey<'a>>), 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<u8> = 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<KKTFrame, KKTError> {
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "nym-lp-common"
|
||||
version = "0.1.0"
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::fmt;
|
||||
use std::fmt::Write;
|
||||
|
||||
pub fn format_debug_bytes(bytes: &[u8]) -> Result<String, fmt::Error> {
|
||||
let mut out = String::new();
|
||||
const LINE_LEN: usize = 16;
|
||||
for (i, chunk) in bytes.chunks(LINE_LEN).enumerate() {
|
||||
let line_prefix = format!("[{}:{}]", 1 + i * LINE_LEN, i * LINE_LEN + chunk.len());
|
||||
write!(out, "{line_prefix:12}")?;
|
||||
let mut line = String::new();
|
||||
for b in chunk {
|
||||
line.push_str(format!("{:02x} ", b).as_str());
|
||||
}
|
||||
write!(
|
||||
out,
|
||||
"{line:48} {}",
|
||||
chunk
|
||||
.iter()
|
||||
.map(|&b| b as char)
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '.' })
|
||||
.collect::<String>()
|
||||
)?;
|
||||
|
||||
writeln!(out)?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "nym-lp-transport"
|
||||
version = "0.1.0"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
nym-test-utils = { path = "../test-utils", optional = true }
|
||||
|
||||
[features]
|
||||
io-mocks = ["nym-test-utils"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod traits;
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#[cfg(feature = "io-mocks")]
|
||||
use nym_test_utils::mocks::async_read_write::MockIOStream;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
// only used in internal code (and tests)
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait LpTransport: AsyncRead + AsyncWrite + Sized {
|
||||
async fn connect(endpoint: SocketAddr) -> std::io::Result<Self>;
|
||||
|
||||
fn set_no_delay(&mut self, nodelay: bool) -> std::io::Result<()>;
|
||||
}
|
||||
|
||||
impl LpTransport for TcpStream {
|
||||
async fn connect(endpoint: SocketAddr) -> std::io::Result<Self> {
|
||||
TcpStream::connect(endpoint).await
|
||||
}
|
||||
|
||||
fn set_no_delay(&mut self, nodelay: bool) -> std::io::Result<()> {
|
||||
// Set TCP_NODELAY for low latency
|
||||
self.set_nodelay(nodelay)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "io-mocks")]
|
||||
impl LpTransport for MockIOStream {
|
||||
async fn connect(_endpoint: SocketAddr) -> std::io::Result<Self> {
|
||||
Ok(MockIOStream::default())
|
||||
}
|
||||
|
||||
fn set_no_delay(&mut self, _nodelay: bool) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
[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 }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
zeroize = { workspace = true, features = ["zeroize_derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand_chacha = "0.3"
|
||||
|
||||
|
||||
[[bench]]
|
||||
name = "replay_protection"
|
||||
harness = false
|
||||
@@ -0,0 +1,365 @@
|
||||
# LP Protocol Design
|
||||
|
||||
## Overview
|
||||
|
||||
The Lewes Protocol (LP) provides authenticated, encrypted sessions with replay protection. Key design principles:
|
||||
|
||||
1. **Unified packet structure** - Same format for all packet types
|
||||
2. **Receiver index** - Client-proposed session identifier (replaces computed session_id)
|
||||
3. **Opportunistic encryption** - Header authentication and payload encryption as soon as PSK is available
|
||||
4. **WireGuard-inspired simplicity** - Minimal header, clear security model
|
||||
|
||||
## Packet Structure
|
||||
|
||||
### Unified Format (v2)
|
||||
|
||||
All packets share the same outer structure - cleartext fields are always first:
|
||||
|
||||
```
|
||||
┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐
|
||||
│ receiver_index │ counter │ version │ reserved │ payload │ trailer │
|
||||
│ 4B │ 8B │ 1B │ 3B │ variable │ 16B │
|
||||
└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘
|
||||
│←── 12B outer header ────┤│←── inner (cleartext or encrypted) ──────┤│─ 16B ──┤
|
||||
```
|
||||
|
||||
**Total overhead:** 32 bytes (12B outer + 4B inner prefix + 16B trailer)
|
||||
|
||||
Key properties:
|
||||
- **Outer header** (12 bytes): Always cleartext, used for routing before session lookup
|
||||
- **Inner content**: Cleartext before PSK, encrypted after PSK
|
||||
- **No disambiguation needed**: Format is identical for both modes
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
**Outer Header** (always cleartext, 12 bytes):
|
||||
|
||||
| Field | Size | Description |
|
||||
|-------|------|-------------|
|
||||
| receiver_index | 4 bytes | Session identifier, proposed by client (routing key) |
|
||||
| counter | 8 bytes | Monotonic counter, used as AEAD nonce and for replay protection |
|
||||
|
||||
**Inner Content** (cleartext or encrypted):
|
||||
|
||||
| Field | Size | Description |
|
||||
|-------|------|-------------|
|
||||
| version | 1 byte | Protocol version |
|
||||
| reserved | 3 bytes | Reserved for future use |
|
||||
| payload | variable | Message type (2B) + content |
|
||||
| trailer | 16 bytes | Zeros (no PSK) or AEAD Poly1305 tag (with PSK) |
|
||||
|
||||
### Wire Format
|
||||
|
||||
Length-prefixed over TCP:
|
||||
|
||||
```
|
||||
┌────────────────────┬─────────────────────────────────────────────────────┐
|
||||
│ length (4B BE u32) │ LpPacket │
|
||||
└────────────────────┴─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Message Types
|
||||
|
||||
| Type | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| Busy | 0x0000 | Server congestion signal |
|
||||
| Handshake | 0x0001 | Noise protocol messages |
|
||||
| EncryptedData | 0x0002 | Encrypted application data |
|
||||
| ClientHello | 0x0003 | Initial session setup |
|
||||
| KKTRequest | 0x0004 | KEM key transfer request |
|
||||
| KKTResponse | 0x0005 | KEM key transfer response |
|
||||
| ForwardPacket | 0x0006 | Nested session forwarding |
|
||||
| Collision | 0x0007 | Receiver index collision |
|
||||
| Ack | 0x0008 | Gateway confirms receipt of message |
|
||||
|
||||
### Planned Message Types (not yet implemented)
|
||||
|
||||
| Type | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| SubsessionRequest | 0x0009 | Client requests new subsession |
|
||||
| SubsessionKK1 | 0x000A | KK handshake msg 1 (responder → initiator) |
|
||||
| SubsessionKK2 | 0x000B | KK handshake msg 2 (initiator → responder) |
|
||||
| SubsessionReady | 0x000C | Subsession established confirmation |
|
||||
|
||||
## Receiver Index
|
||||
|
||||
### Assignment
|
||||
|
||||
The client generates a random 4-byte receiver_index and includes it in ClientHello. The gateway uses this as the session lookup key. This replaces the previous approach of computing a deterministic session_id from both parties' keys.
|
||||
|
||||
### Collision Handling
|
||||
|
||||
With 4 bytes (2^32 values), collision probability is negligible:
|
||||
|
||||
| Active Sessions | Collision Probability |
|
||||
|-----------------|----------------------|
|
||||
| 10,000 | ~0.001% |
|
||||
| 100,000 | ~0.1% |
|
||||
|
||||
If collision detected, gateway rejects ClientHello and client retries with new index.
|
||||
|
||||
## Opportunistic Encryption
|
||||
|
||||
### Principle
|
||||
|
||||
As soon as PSK is derived (after processing Noise msg 1 with PSQ), all subsequent packets use outer AEAD encryption:
|
||||
|
||||
- **Header**: Authenticated as associated data (AD)
|
||||
- **Payload**: Encrypted (message type + content)
|
||||
- **Trailer**: AEAD tag
|
||||
|
||||
### Timeline
|
||||
|
||||
| Packet | PSK Available | Header | Payload | Trailer |
|
||||
|--------|---------------|--------|---------|---------|
|
||||
| ClientHello | No | Clear | Clear | Zeros |
|
||||
| Ack | No | Clear | Clear | Zeros |
|
||||
| KKTRequest | No | Clear | Clear | Zeros |
|
||||
| KKTResponse | No | Clear | Clear | Zeros |
|
||||
| Noise msg 1 | No | Clear | Clear | Zeros |
|
||||
| | | **PSK derived** | | |
|
||||
| Noise msg 2 | Yes | Authenticated | Encrypted | Tag |
|
||||
| Noise msg 3 | Yes | Authenticated | Encrypted | Tag |
|
||||
| Data | Yes | Authenticated | Encrypted | Tag |
|
||||
|
||||
### Encryption Scheme
|
||||
|
||||
- **AEAD**: ChaCha20-Poly1305
|
||||
- **Key**: outer_key = KDF(PSK, "lp-outer-aead") - derived from PSK, not PSK itself
|
||||
- **Nonce**: counter (8 bytes, zero-padded to 12 bytes)
|
||||
- **AAD**: receiver_index ‖ counter (12 bytes) - the outer header
|
||||
- **Encrypted**: version ‖ reserved ‖ message_type ‖ content
|
||||
|
||||
Note: PSK is used as-is for Noise (which does internal key derivation). The outer_key derivation avoids key reuse between the two encryption layers.
|
||||
|
||||
### Before PSK
|
||||
|
||||
```
|
||||
┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐
|
||||
│ receiver_index │ counter │ version │ reserved │ payload │ 00...00 │
|
||||
│ │ │ │ │ (plaintext) │ │
|
||||
└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘
|
||||
│←── 12B outer ──────────┤│←────────────── cleartext inner ──────────┤│─zeros──┤
|
||||
```
|
||||
|
||||
### After PSK
|
||||
|
||||
```
|
||||
┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐
|
||||
│ receiver_index │ counter │ version │ reserved │ payload │ tag │
|
||||
│ │ │ (enc) │ (enc) │ (encrypted) │ │
|
||||
└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘
|
||||
│←── 12B outer (AAD) ────┤│←────────── encrypted inner ──────────────┤│─ tag ──┤
|
||||
```
|
||||
|
||||
## Handshake Flow
|
||||
|
||||
Each arrow represents a separate TCP connection (packet-per-connection model).
|
||||
|
||||
```
|
||||
Client Gateway
|
||||
│ │
|
||||
│ [hdr][ClientHello][zeros] │
|
||||
│──────────────────────────────────────►│ store state[receiver_index]
|
||||
│ │
|
||||
│ [hdr][Ack][zeros] │
|
||||
│◄──────────────────────────────────────│ confirm ClientHello
|
||||
│ │
|
||||
│ [hdr][KKTRequest][zeros] │
|
||||
│──────────────────────────────────────►│
|
||||
│ │
|
||||
│ [hdr][KKTResponse][zeros] │
|
||||
│◄──────────────────────────────────────│
|
||||
│ │
|
||||
│ [hdr][Noise1+PSQ][zeros] │
|
||||
│──────────────────────────────────────►│ derive PSK
|
||||
│ │
|
||||
│ [hdr][encrypted Noise2][tag] │ ← authenticated
|
||||
│◄──────────────────────────────────────│
|
||||
│ │
|
||||
│ [hdr][encrypted Noise3][tag] │ ← authenticated
|
||||
│──────────────────────────────────────►│
|
||||
│ │
|
||||
│ ════════ Session Established ═════════│
|
||||
│ │
|
||||
│ [hdr][encrypted Data][tag] │
|
||||
│◄─────────────────────────────────────►│
|
||||
```
|
||||
|
||||
## Data Packet Encryption
|
||||
|
||||
Data packets have two encryption layers:
|
||||
|
||||
```
|
||||
Application Data
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Noise encrypt │ Inner layer (forward secrecy, ratcheting)
|
||||
│ (session keys) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ PSK AEAD │ Outer layer (header auth, payload encryption)
|
||||
│ (pre-shared key) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Wire: [header][encrypted payload][tag]
|
||||
```
|
||||
|
||||
### What Outer AEAD Encrypts
|
||||
|
||||
The outer AEAD encrypts: message_type (2B) + message content
|
||||
|
||||
This hides the message type from observers after PSK is available.
|
||||
|
||||
## Subsessions and Rekeying
|
||||
|
||||
Subsessions enable **forward secrecy** through periodic rekeying and **channel multiplexing** for independent encrypted streams.
|
||||
|
||||
### Design Principles
|
||||
|
||||
| Aspect | Decision | Rationale |
|
||||
|--------|----------|-----------|
|
||||
| Key derivation | Noise KK handshake | Clean crypto, both parties already authenticated |
|
||||
| Initiation channel | Tunneled through parent | Already authenticated, no proof-of-ownership needed |
|
||||
| Hierarchy | Promotion model (chain) | Simpler than tree, natural for rekeying |
|
||||
| Old session after promotion | Read-only until TTL | Drains in-flight packets, provides grace period |
|
||||
|
||||
### Noise KK Pattern
|
||||
|
||||
Subsessions use `Noise_KK_25519_ChaChaPoly_SHA256`:
|
||||
|
||||
- **KK** = Both parties already know each other's static keys
|
||||
- **2 messages** to complete (vs 3 for XKpsk3)
|
||||
- **No PSK needed** - already authenticated via parent session
|
||||
|
||||
### Promotion Model
|
||||
|
||||
When a subsession is created, it becomes the new "master" and the old session becomes read-only:
|
||||
|
||||
```
|
||||
Session A (master) → Session B created → A demoted, B is master
|
||||
A: read-only until TTL
|
||||
```
|
||||
|
||||
This creates a chain (A → B → C) but maintains only one level of nesting conceptually. Each promotion replaces the previous master.
|
||||
|
||||
### Protocol Flow
|
||||
|
||||
```
|
||||
Client Gateway
|
||||
│ │
|
||||
│═══════ Parent Session (A) ════════│ Transport mode
|
||||
│ │
|
||||
│──[SubsessionRequest{idx=B}]──────►│ Encrypted in parent
|
||||
│ │ Gateway creates KK responder
|
||||
│◄──[SubsessionKK1{idx=B, e}]───────│ KK handshake msg 1
|
||||
│──[SubsessionKK2{idx=B, e,ee,se}]─►│ KK handshake msg 2
|
||||
│◄──[SubsessionReady{idx=B}]────────│ Subsession established
|
||||
│ │
|
||||
│ Session A: read-only (receive) │
|
||||
│═══════ Session B (new master) ════│ New Transport mode
|
||||
```
|
||||
|
||||
### Session State Transitions
|
||||
|
||||
```
|
||||
Parent Session (A):
|
||||
Transport → ReadOnlyTransport (on subsession creation)
|
||||
ReadOnlyTransport → (expires via TTL cleanup)
|
||||
|
||||
Subsession (B):
|
||||
(created) → KKHandshaking → Transport (becomes new master)
|
||||
```
|
||||
|
||||
### Read-Only Session Semantics
|
||||
|
||||
After demotion:
|
||||
- **Can receive**: Decrypt and process incoming packets (drain in-flight)
|
||||
- **Cannot send**: Encryption blocked, returns error
|
||||
- **Cleaned up**: Via normal TTL expiration
|
||||
|
||||
### Message Formats
|
||||
|
||||
```rust
|
||||
SubsessionRequestData {
|
||||
new_receiver_index: u32, // Client-proposed index for subsession
|
||||
}
|
||||
|
||||
SubsessionKK1Data {
|
||||
new_receiver_index: u32,
|
||||
kk_message: Vec<u8>, // Noise KK message 1
|
||||
}
|
||||
|
||||
SubsessionKK2Data {
|
||||
new_receiver_index: u32,
|
||||
kk_message: Vec<u8>, // Noise KK message 2
|
||||
}
|
||||
|
||||
SubsessionReadyData {
|
||||
new_receiver_index: u32,
|
||||
}
|
||||
```
|
||||
|
||||
### Counter Independence
|
||||
|
||||
- Each session has independent counters
|
||||
- Subsession starts at counter 0
|
||||
- No counter coordination needed between parent and subsession
|
||||
|
||||
### Failure Handling
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| KK handshake fails | Discard attempt, keep using parent |
|
||||
| Receiver index collision | Retry with new receiver_index |
|
||||
| Parent session not found | Return error, client reconnects |
|
||||
|
||||
### Security Benefits
|
||||
|
||||
1. **Forward secrecy**: Compromise of current keys doesn't expose past traffic
|
||||
2. **Key rotation**: Periodic rekeying limits exposure window
|
||||
3. **Channel isolation**: Independent streams can't cross-decrypt
|
||||
|
||||
## Security Properties
|
||||
|
||||
### Always Visible to Observer
|
||||
|
||||
Only the outer header (12 bytes) is visible after PSK establishment:
|
||||
|
||||
- Receiver index (4 bytes) - opaque, unlinkable to identity
|
||||
- Counter (8 bytes) - reveals packet ordering
|
||||
- Packet size
|
||||
|
||||
Note: Before PSK, version, reserved, and message type are also visible.
|
||||
|
||||
### Protected After PSK
|
||||
|
||||
- Outer header integrity (authenticated via AEAD AAD)
|
||||
- Inner content confidentiality (encrypted):
|
||||
- Protocol version
|
||||
- Reserved field
|
||||
- Message type
|
||||
- Payload
|
||||
- Application data (double encrypted: outer AEAD + inner Noise)
|
||||
|
||||
### Cryptographic Guarantees
|
||||
|
||||
| Property | Mechanism |
|
||||
|----------|-----------|
|
||||
| Confidentiality | ChaCha20 (outer) + Noise ChaCha20 (inner) |
|
||||
| Integrity | Poly1305 (outer) + Noise Poly1305 (inner) |
|
||||
| Replay protection | Counter validation (before decryption) |
|
||||
| Forward secrecy | Noise session keys (inner) + subsession rekeying |
|
||||
| Header authentication | AEAD associated data |
|
||||
| Key rotation | Periodic subsession creation (Noise KK) |
|
||||
|
||||
## References
|
||||
|
||||
- WireGuard Protocol - Inspiration for receiver_index and packet simplicity
|
||||
- Noise Protocol Framework - Inner encryption layer, KK pattern for subsessions
|
||||
- RFC 8439 ChaCha20-Poly1305 - AEAD cipher
|
||||
- Noise Explorer KK - https://noiseexplorer.com/patterns/KK/
|
||||
@@ -0,0 +1,309 @@
|
||||
# Nym Lewes Protocol
|
||||
|
||||
The Lewes Protocol (LP) is a secure network communication protocol implemented in Rust. It provides authenticated, encrypted sessions with replay protection and supports nested session forwarding for privacy-preserving multi-hop connections.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌────────────────┐ ┌───────────────┐
|
||||
│ Transport Layer │◄───►│ LP Session │◄───►│ LP Codec │
|
||||
│ (TCP) │ │ - State machine│ │ - Serialize │
|
||||
└─────────────────┘ │ - Noise crypto │ │ - Deserialize │
|
||||
│ - Replay prot. │ └───────────────┘
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
## Packet Structure
|
||||
|
||||
The protocol uses a length-prefixed packet format over TCP:
|
||||
|
||||
```
|
||||
Wire Format:
|
||||
┌────────────────────┬─────────────────────────────────────────┐
|
||||
│ Length (4B BE u32) │ LpPacket │
|
||||
└────────────────────┴─────────────────────────────────────────┘
|
||||
|
||||
LpPacket:
|
||||
┌──────────────────┬───────────────────┬──────────────────┐
|
||||
│ Header (16B) │ Message │ Trailer (16B) │
|
||||
├──────────────────┼───────────────────┼──────────────────┤
|
||||
│ Version (1B) │ Type (2B LE u16) │ Reserved │
|
||||
│ Reserved (3B) │ Content (var) │ (16 bytes) │
|
||||
│ SessionID (4B LE)│ │ │
|
||||
│ Counter (8B LE) │ │ │
|
||||
└──────────────────┴───────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
- **Header**: Protocol version (1), session identifier, monotonic counter
|
||||
- **Message**: Type discriminant + variable-length content
|
||||
- **Trailer**: Reserved for future use (16 bytes)
|
||||
|
||||
## Message Types
|
||||
|
||||
| Type | Value | Purpose |
|
||||
|------|-------|---------|
|
||||
| `Busy` | 0x0000 | Server congestion signal |
|
||||
| `Handshake` | 0x0001 | Noise protocol handshake messages |
|
||||
| `EncryptedData` | 0x0002 | Encrypted application data |
|
||||
| `ClientHello` | 0x0003 | Initial session negotiation |
|
||||
| `KKTRequest` | 0x0004 | KEM Key Transfer request |
|
||||
| `KKTResponse` | 0x0005 | KEM Key Transfer response |
|
||||
| `ForwardPacket` | 0x0006 | Nested session forwarding |
|
||||
|
||||
## Session Establishment
|
||||
|
||||
### Session ID
|
||||
|
||||
Sessions are identified by a deterministic 32-bit ID computed from both parties' X25519 public keys:
|
||||
|
||||
```
|
||||
session_id = make_lp_id(client_x25519_pub, gateway_x25519_pub)
|
||||
```
|
||||
|
||||
The computation is order-independent, allowing both sides to derive the same ID independently.
|
||||
|
||||
**BOOTSTRAP_SESSION_ID (0)**: A special session ID used only for the initial `ClientHello` packet, since neither side can compute the final ID until both X25519 keys are known.
|
||||
|
||||
### Handshake Flow
|
||||
|
||||
```
|
||||
┌────────┐ ┌─────────┐
|
||||
│ Client │ │ Gateway │
|
||||
└───┬────┘ └────┬────┘
|
||||
│ │
|
||||
│ 1. ClientHello (session_id=0) │
|
||||
│ [client_x25519, client_ed25519, salt]│
|
||||
│───────────────────────────────────────►│
|
||||
│ │ (computes session_id)
|
||||
│ │ (stores state machine)
|
||||
│ │
|
||||
│ 2. KKTRequest (session_id=N) │
|
||||
│ [signed request for KEM key] │
|
||||
│───────────────────────────────────────►│
|
||||
│ │
|
||||
│ 3. KKTResponse │
|
||||
│ [gateway KEM key + signature] │
|
||||
│◄───────────────────────────────────────│
|
||||
│ │
|
||||
│ 4. Noise Handshake Msg 1 │
|
||||
│ [PSQ payload + noise message] │
|
||||
│───────────────────────────────────────►│
|
||||
│ │ (derives PSK from PSQ)
|
||||
│ 5. Noise Handshake Msg 2 │
|
||||
│ [PSK handle + noise message] │
|
||||
│◄───────────────────────────────────────│
|
||||
│ │
|
||||
│ 6. Noise Handshake Msg 3 │
|
||||
│───────────────────────────────────────►│
|
||||
│ │
|
||||
│ ═══════ Session Established ═══════ │
|
||||
│ │
|
||||
│ 7. EncryptedData │
|
||||
│ [encrypted application data] │
|
||||
│◄──────────────────────────────────────►│
|
||||
│ │
|
||||
```
|
||||
|
||||
### ClientHello Data
|
||||
|
||||
```rust
|
||||
struct ClientHelloData {
|
||||
client_lp_public_key: [u8; 32], // X25519 (derived from Ed25519)
|
||||
client_ed25519_public_key: [u8; 32], // For authentication
|
||||
salt: [u8; 32], // timestamp (8B) + nonce (24B)
|
||||
}
|
||||
```
|
||||
|
||||
## Packet-Per-Connection Model
|
||||
|
||||
The gateway processes **exactly one packet per TCP connection**, then closes. State persists between connections via in-memory maps:
|
||||
|
||||
```
|
||||
TCP Connect → Receive Packet → Process → Send Response → TCP Close
|
||||
```
|
||||
|
||||
**State Storage:**
|
||||
- `handshake_states`: Maps `session_id → LpStateMachine` (during handshake)
|
||||
- `session_states`: Maps `session_id → LpSession` (after handshake complete)
|
||||
|
||||
Both maps use TTL-based cleanup to remove stale entries (default: 5 min handshake, 1 hour session).
|
||||
|
||||
### Gateway Packet Routing
|
||||
|
||||
```
|
||||
Packet Received
|
||||
│
|
||||
├─► session_id == 0 (BOOTSTRAP)
|
||||
│ └─► handle_client_hello()
|
||||
│ └─► Create state machine, store in handshake_states
|
||||
│
|
||||
├─► session_id in handshake_states
|
||||
│ └─► handle_handshake_packet()
|
||||
│ └─► Process KKT/Noise, move to session_states when complete
|
||||
│
|
||||
└─► session_id in session_states
|
||||
└─► handle_transport_packet()
|
||||
└─► Decrypt, process registration or forwarding
|
||||
```
|
||||
|
||||
## Session Forwarding
|
||||
|
||||
Forwarding enables a client to establish an independent session with an exit gateway through an entry gateway, providing network-level privacy.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ Client │
|
||||
└────┬─────┘
|
||||
│ Outer LP Session (established, encrypted)
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Entry Gateway │ Sees: Client IP
|
||||
│ │ Doesn't see: Exit destination
|
||||
└────────┬───────┘
|
||||
│ Forwards inner packets (TCP)
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Exit Gateway │ Sees: Entry Gateway IP
|
||||
│ │ Doesn't see: Client IP
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### ForwardPacket Message
|
||||
|
||||
```rust
|
||||
struct ForwardPacketData {
|
||||
target_gateway_identity: [u8; 32], // Exit gateway's Ed25519 key
|
||||
target_lp_address: String, // e.g., "2.2.2.2:41264"
|
||||
inner_packet_bytes: Vec<u8>, // Complete LP packet for exit
|
||||
}
|
||||
```
|
||||
|
||||
### Forwarding Flow
|
||||
|
||||
1. **Client** establishes outer LP session with entry gateway
|
||||
2. **Client** creates `ClientHello` packet for exit gateway
|
||||
3. **Client** wraps inner packet in `ForwardPacketData`:
|
||||
- Sets `target_gateway_identity` to exit's Ed25519 key
|
||||
- Sets `target_lp_address` to exit's LP listener address
|
||||
- Serializes complete LP packet as `inner_packet_bytes`
|
||||
4. **Client** encrypts `ForwardPacketData` using outer session
|
||||
5. **Client** sends as `EncryptedData` to entry gateway
|
||||
|
||||
6. **Entry Gateway** decrypts, sees `ForwardPacketData`
|
||||
7. **Entry Gateway** connects to exit gateway (new TCP)
|
||||
8. **Entry Gateway** sends `inner_packet_bytes` directly
|
||||
9. **Entry Gateway** receives exit's response
|
||||
10. **Entry Gateway** encrypts response using outer session
|
||||
11. **Entry Gateway** sends encrypted response to client
|
||||
|
||||
12. **Client** decrypts response, processes in inner session state
|
||||
|
||||
### NestedLpSession
|
||||
|
||||
The `NestedLpSession` struct manages the inner session from the client's perspective:
|
||||
|
||||
```rust
|
||||
struct NestedLpSession {
|
||||
exit_identity: [u8; 32], // Exit gateway Ed25519
|
||||
exit_address: String, // Exit LP address
|
||||
client_keypair: Arc<ed25519::KeyPair>,
|
||||
exit_public_key: ed25519::PublicKey,
|
||||
state_machine: Option<LpStateMachine>,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```rust
|
||||
// Create nested session targeting exit gateway
|
||||
let nested = NestedLpSession::new(exit_identity, exit_address, keypair, exit_pubkey);
|
||||
|
||||
// Perform handshake through outer session
|
||||
nested.handshake_and_register(&mut outer_client).await?;
|
||||
|
||||
// Inner session now established with exit gateway
|
||||
```
|
||||
|
||||
## State Machine States
|
||||
|
||||
```
|
||||
ReadyToHandshake
|
||||
│
|
||||
▼
|
||||
KKTExchange ◄─── KKTRequest/KKTResponse
|
||||
│
|
||||
▼
|
||||
Handshaking ◄─── Noise messages + PSQ
|
||||
│
|
||||
▼
|
||||
Transport ◄─── EncryptedData
|
||||
│
|
||||
▼
|
||||
Closed
|
||||
```
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Key Types
|
||||
- **Ed25519**: Identity keys, signing
|
||||
- **X25519**: Key exchange (derived from Ed25519 via RFC 7748)
|
||||
|
||||
### Noise Protocol
|
||||
- Pattern: `Noise_XKpsk3_25519_ChaChaPoly_SHA256`
|
||||
- Provides: Forward secrecy, mutual authentication, PSK binding
|
||||
|
||||
### PSK Derivation (PSQ)
|
||||
The Pre-Shared Key is derived via Post-Quantum Secure Key Exchange:
|
||||
1. Client encapsulates using authenticated KEM key from KKT
|
||||
2. Produces 32-byte PSK + ciphertext
|
||||
3. Gateway decapsulates to derive same PSK
|
||||
4. PSK injected into Noise at position 3
|
||||
|
||||
### Replay Protection
|
||||
|
||||
- **Monotonic counter**: Each packet has incrementing 64-bit counter
|
||||
- **Sliding window**: Bitmap tracks received counters (1024 packet window)
|
||||
- **SIMD optimized**: Branchless validation for constant-time operation
|
||||
|
||||
```rust
|
||||
// Validation flow
|
||||
validator.will_accept_branchless(counter) // Check before decrypt
|
||||
validator.mark_did_receive_branchless(counter) // Mark after decrypt
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
### LpSession Fields
|
||||
```rust
|
||||
struct LpSession {
|
||||
id: u32, // Session identifier
|
||||
is_initiator: bool, // Client or gateway role
|
||||
noise_state: NoiseState, // Noise transport state
|
||||
kkt_state: KktState, // KKT exchange progress
|
||||
psq_state: PsqState, // PSQ handshake progress
|
||||
psk_handle: Option<Vec<u8>>,// PSK handle from responder
|
||||
sending_counter: AtomicU64, // Outgoing packet counter
|
||||
receiving_counter: Validator, // Replay protection
|
||||
psk_injected: AtomicBool, // Safety: real PSK injected?
|
||||
}
|
||||
```
|
||||
|
||||
### PSK Safety
|
||||
Sessions initialize with a dummy PSK. The `psk_injected` flag must be `true` before `encrypt_data()` or `decrypt_data()` will operate, preventing accidental use of the insecure dummy.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
common/nym-lp/src/
|
||||
├── lib.rs # Module exports
|
||||
├── message.rs # LpMessage enum, ClientHelloData, ForwardPacketData
|
||||
├── packet.rs # LpPacket, LpHeader, BOOTSTRAP_SESSION_ID
|
||||
├── codec.rs # Serialization/deserialization
|
||||
├── session.rs # LpSession, cryptographic operations
|
||||
├── state_machine.rs # LpStateMachine, state transitions
|
||||
├── psk.rs # PSK derivation utilities
|
||||
└── error.rs # Error types
|
||||
```
|
||||
@@ -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<u64> = (0..size).collect();
|
||||
|
||||
b.iter(|| {
|
||||
let mut validator = validator.clone();
|
||||
for &counter in &counters {
|
||||
let _ = black_box(validator.will_accept_branchless(counter));
|
||||
let _ = black_box(validator.mark_did_receive_branchless(counter));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_out_of_order_counters(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("replay_out_of_order");
|
||||
group.sample_size(1000);
|
||||
|
||||
for size in [100, 1000, 10000] {
|
||||
group.throughput(Throughput::Elements(size as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("out_of_order_counters", size),
|
||||
&size,
|
||||
|b, &size| {
|
||||
let validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Create random counters within a valid window
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(42);
|
||||
let counters: Vec<u64> = (0..size).map(|_| rng.gen_range(0..1024)).collect();
|
||||
|
||||
b.iter(|| {
|
||||
let mut validator = validator.clone();
|
||||
for &counter in &counters {
|
||||
let _ = black_box(validator.will_accept_branchless(counter));
|
||||
let _ = black_box(validator.mark_did_receive_branchless(counter));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_thread_safety(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("replay_thread_safety");
|
||||
group.sample_size(1000);
|
||||
|
||||
for size in [100, 1000, 10000] {
|
||||
group.throughput(Throughput::Elements(size));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("thread_safe_validator", size),
|
||||
&size,
|
||||
|b, &size| {
|
||||
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
|
||||
let counters: Vec<u64> = (0..size).collect();
|
||||
|
||||
b.iter(|| {
|
||||
for &counter in &counters {
|
||||
let result = {
|
||||
let guard = validator.lock();
|
||||
black_box(guard.will_accept_branchless(counter))
|
||||
};
|
||||
|
||||
if result.is_ok() {
|
||||
let mut guard = validator.lock();
|
||||
let _ = black_box(guard.mark_did_receive_branchless(counter));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_window_sliding(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("replay_window_sliding");
|
||||
group.sample_size(100);
|
||||
|
||||
for window_size in [128, 512, 1024] {
|
||||
group.throughput(Throughput::Elements(window_size));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("window_sliding", window_size),
|
||||
&window_size,
|
||||
|b, &window_size| {
|
||||
b.iter(|| {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// First fill the window with sequential packets
|
||||
for i in 0..window_size {
|
||||
let _ = black_box(validator.mark_did_receive_branchless(i));
|
||||
}
|
||||
|
||||
// Then jump ahead to force window sliding
|
||||
let _ = black_box(validator.mark_did_receive_branchless(window_size * 3));
|
||||
|
||||
// Try some packets in the new window
|
||||
for i in (window_size * 2 + 1)..(window_size * 3) {
|
||||
let _ = black_box(validator.will_accept_branchless(i));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark operations that would benefit from SIMD optimization
|
||||
fn bench_core_operations(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("replay_core_operations");
|
||||
group.sample_size(1000);
|
||||
|
||||
// Create validators with different states
|
||||
let empty_validator = ReceivingKeyCounterValidator::default();
|
||||
let mut half_full_validator = ReceivingKeyCounterValidator::default();
|
||||
let mut full_validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Fill validators with different patterns
|
||||
for i in 0..512 {
|
||||
half_full_validator.mark_did_receive_branchless(i).unwrap();
|
||||
}
|
||||
|
||||
for i in 0..1024 {
|
||||
full_validator.mark_did_receive_branchless(i).unwrap();
|
||||
}
|
||||
|
||||
// Benchmark clearing operations
|
||||
group.bench_function("clear_empty_window", |b| {
|
||||
b.iter(|| {
|
||||
let mut validator = empty_validator.clone();
|
||||
// Force window sliding that will clear bitmap
|
||||
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
|
||||
black_box(());
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("clear_half_full_window", |b| {
|
||||
b.iter(|| {
|
||||
let mut validator = half_full_validator.clone();
|
||||
// Force window sliding that will clear bitmap
|
||||
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
|
||||
black_box(());
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("clear_full_window", |b| {
|
||||
b.iter(|| {
|
||||
let mut validator = full_validator.clone();
|
||||
// Force window sliding that will clear bitmap
|
||||
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
|
||||
black_box(());
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark thread safety with different thread counts
|
||||
fn bench_concurrency_scaling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("replay_concurrency_scaling");
|
||||
group.sample_size(50);
|
||||
|
||||
for thread_count in [1, 2, 4, 8] {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("mutex_threads", thread_count),
|
||||
&thread_count,
|
||||
|b, &thread_count| {
|
||||
b.iter(|| {
|
||||
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for t in 0..thread_count {
|
||||
let validator_clone = Arc::clone(&validator);
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut success_count = 0;
|
||||
for i in 0..100 {
|
||||
let counter = t * 1000 + i;
|
||||
let mut guard = validator_clone.lock();
|
||||
if guard.mark_did_receive_branchless(counter as u64).is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
success_count
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
for handle in handles {
|
||||
total += handle.join().unwrap();
|
||||
}
|
||||
|
||||
black_box(total)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
replay_benches,
|
||||
bench_sequential_counters,
|
||||
bench_out_of_order_counters,
|
||||
bench_thread_safety,
|
||||
bench_window_sliding,
|
||||
bench_core_operations,
|
||||
bench_concurrency_scaling
|
||||
);
|
||||
criterion_main!(replay_benches);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Configuration for LP protocol.
|
||||
//!
|
||||
//! LP security stack = KKT (key fetch) → PSQ (PQ PSK) → Noise (transport).
|
||||
//! KEM algorithm selection affects only PSQ layer. Noise always uses X25519 DH.
|
||||
//! Migration to PQ KEMs (MlKem768, XWing) requires only config change.
|
||||
|
||||
use nym_kkt::ciphersuite::KEM;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default PSK time-to-live (1 hour, matches psk.rs implementation).
|
||||
pub const DEFAULT_PSK_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Configuration for LP protocol.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LpConfig {
|
||||
/// KEM algorithm for PSQ key encapsulation.
|
||||
/// X25519 = classical (testing), MlKem768 = PQ, XWing = hybrid.
|
||||
#[serde(with = "kem_serde")]
|
||||
pub kem_algorithm: KEM,
|
||||
|
||||
/// PSK time-to-live in seconds.
|
||||
pub psk_ttl_secs: u64,
|
||||
|
||||
/// Enable KKT for authenticated key distribution.
|
||||
pub enable_kkt: bool,
|
||||
}
|
||||
|
||||
impl Default for LpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kem_algorithm: KEM::X25519,
|
||||
psk_ttl_secs: DEFAULT_PSK_TTL_SECS,
|
||||
enable_kkt: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LpConfig {
|
||||
/// Returns PSK TTL as Duration.
|
||||
pub fn psk_ttl(&self) -> Duration {
|
||||
Duration::from_secs(self.psk_ttl_secs)
|
||||
}
|
||||
}
|
||||
|
||||
mod kem_serde {
|
||||
use nym_kkt::ciphersuite::KEM;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub fn serialize<S>(kem: &KEM, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match kem {
|
||||
KEM::X25519 => "X25519",
|
||||
KEM::MlKem768 => "MlKem768",
|
||||
KEM::XWing => "XWing",
|
||||
KEM::McEliece => "McEliece",
|
||||
}
|
||||
.serialize(serializer)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<KEM, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
match s.as_str() {
|
||||
"X25519" => Ok(KEM::X25519),
|
||||
"MlKem768" => Ok(KEM::MlKem768),
|
||||
"XWing" => Ok(KEM::XWing),
|
||||
"McEliece" => Ok(KEM::McEliece),
|
||||
_ => Err(serde::de::Error::custom(format!("Unknown KEM: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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(u32),
|
||||
|
||||
#[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),
|
||||
|
||||
/// Outer AEAD authentication tag verification failed.
|
||||
#[error("AEAD authentication tag verification failed")]
|
||||
AeadTagMismatch,
|
||||
}
|
||||
@@ -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<Self, LpError> {
|
||||
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<Self, LpError> {
|
||||
let bytes: [u8; 32] = bs58::decode(s).into_vec()?.try_into().unwrap();
|
||||
Ok(PublicKey(SphinxPublicKey::from(bytes)))
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self, LpError> {
|
||||
Ok(PublicKey(SphinxPublicKey::from(*bytes)))
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PublicKey {
|
||||
fn default() -> Self {
|
||||
let private_key = PrivateKey::default();
|
||||
private_key.public_key()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Keypair {
|
||||
private_key: PrivateKey,
|
||||
public_key: PublicKey,
|
||||
}
|
||||
|
||||
impl Default for Keypair {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Keypair {
|
||||
pub fn new() -> Self {
|
||||
let private_key = PrivateKey::default();
|
||||
let public_key = private_key.public_key();
|
||||
Self {
|
||||
private_key,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn 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<KeypairReadable> for Keypair {
|
||||
fn from(keypair: KeypairReadable) -> Self {
|
||||
Self {
|
||||
private_key: PrivateKey::from_base58_string(&keypair.private).unwrap(),
|
||||
public_key: PublicKey::from_base58_string(&keypair.public).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Keypair> for KeypairReadable {
|
||||
fn from(keypair: &Keypair) -> Self {
|
||||
Self {
|
||||
private: keypair.private_key.to_base58_string(),
|
||||
public: keypair.public_key.to_base58_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FromStr for PrivateKey {
|
||||
type Err = LpError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
PrivateKey::from_base58_string(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PrivateKey {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_base58_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PublicKey {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_base58_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, serde::Deserialize, Clone, ToSchema, Debug)]
|
||||
pub struct KeypairReadable {
|
||||
private: String,
|
||||
public: String,
|
||||
}
|
||||
|
||||
impl KeypairReadable {
|
||||
pub fn private_key(&self) -> Result<PrivateKey, LpError> {
|
||||
PrivateKey::from_base58_string(&self.private)
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> Result<PublicKey, LpError> {
|
||||
PublicKey::from_base58_string(&self.public)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<EncapsulationKey<'a>, 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<KKTResponseData, LpError> {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
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;
|
||||
|
||||
pub use config::LpConfig;
|
||||
pub use error::LpError;
|
||||
pub use message::{ClientHelloData, LpMessage};
|
||||
pub use packet::{BOOTSTRAP_RECEIVER_IDX, LpPacket, OuterHeader};
|
||||
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 serialisation;
|
||||
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;
|
||||
use nym_crypto::asymmetric::ed25519;
|
||||
|
||||
// X25519 keypairs for Noise protocol
|
||||
let keypair_1 = Keypair::default();
|
||||
let keypair_2 = Keypair::default();
|
||||
|
||||
// Use a fixed receiver_index for deterministic tests
|
||||
let receiver_index: u32 = 12345;
|
||||
|
||||
// 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(
|
||||
receiver_index,
|
||||
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(
|
||||
receiver_index,
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::message::LpMessage;
|
||||
use crate::packet::{LpHeader, LpPacket, TRAILER_LEN};
|
||||
use crate::session_manager::SessionManager;
|
||||
use crate::{LpError, 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,
|
||||
receiver_idx: 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, None).unwrap();
|
||||
|
||||
// Parse packet
|
||||
let parsed_packet1 = parse_lp_packet(&buf1, None).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,
|
||||
receiver_idx: 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, None).unwrap();
|
||||
|
||||
// Parse packet
|
||||
let parsed_packet2 = parse_lp_packet(&buf2, None).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,
|
||||
receiver_idx: 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, None).unwrap();
|
||||
|
||||
// Parse packet
|
||||
let parsed_packet3 = parse_lp_packet(&buf3, None).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);
|
||||
|
||||
// Use fixed receiver_index for deterministic test
|
||||
let receiver_index: u32 = 54321;
|
||||
|
||||
// Test salt
|
||||
let salt = [46u8; 32];
|
||||
|
||||
// Create a session via manager
|
||||
let _ = local_manager
|
||||
.create_session_state_machine(
|
||||
receiver_index,
|
||||
(
|
||||
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(
|
||||
receiver_index,
|
||||
(
|
||||
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,
|
||||
receiver_idx: receiver_index,
|
||||
counter: 0,
|
||||
},
|
||||
message: LpMessage::Busy,
|
||||
trailer: [0u8; TRAILER_LEN],
|
||||
};
|
||||
|
||||
// Serialize
|
||||
let mut buf1 = BytesMut::new();
|
||||
serialize_lp_packet(&packet1, &mut buf1, None).unwrap();
|
||||
|
||||
// Parse
|
||||
let parsed_packet1 = parse_lp_packet(&buf1, None).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(receiver_index, parsed_packet1.header.counter)
|
||||
.expect("Packet 1 check failed");
|
||||
// Mark received
|
||||
local_manager
|
||||
.receiving_counter_mark(receiver_index, 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,
|
||||
receiver_idx: receiver_index,
|
||||
counter: 1,
|
||||
},
|
||||
message: LpMessage::Busy,
|
||||
trailer: [0u8; TRAILER_LEN],
|
||||
};
|
||||
|
||||
// Serialize
|
||||
let mut buf2 = BytesMut::new();
|
||||
serialize_lp_packet(&packet2, &mut buf2, None).unwrap();
|
||||
|
||||
// Parse
|
||||
let parsed_packet2 = parse_lp_packet(&buf2, None).unwrap();
|
||||
|
||||
// Perform replay check
|
||||
local_manager
|
||||
.receiving_counter_quick_check(receiver_index, parsed_packet2.header.counter)
|
||||
.expect("Packet 2 check failed");
|
||||
// Mark received
|
||||
local_manager
|
||||
.receiving_counter_mark(receiver_index, 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,
|
||||
receiver_idx: receiver_index,
|
||||
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, None).unwrap();
|
||||
|
||||
// Parse
|
||||
let parsed_packet3 = parse_lp_packet(&buf3, None).unwrap();
|
||||
|
||||
// Perform replay check (should fail)
|
||||
let replay_result = local_manager
|
||||
.receiving_counter_quick_check(receiver_index, parsed_packet3.header.counter);
|
||||
assert!(replay_result.is_err());
|
||||
match replay_result.unwrap_err() {
|
||||
LpError::Replay(e) => {
|
||||
assert!(matches!(e, crate::replay::ReplayError::DuplicateCounter));
|
||||
}
|
||||
e => panic!("Expected replay error for packet 3, got {:?}", e),
|
||||
}
|
||||
// Do not mark received
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::serialisation::{BincodeOptions, lp_bincode_serializer};
|
||||
use crate::{BOOTSTRAP_RECEIVER_IDX, LpError};
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use num_enum::{IntoPrimitive, TryFromPrimitive};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
/// Data structure for the ClientHello message
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientHelloData {
|
||||
/// Client-proposed receiver index for session identification (4 bytes)
|
||||
/// Auto-generated randomly by the client
|
||||
pub receiver_index: u32,
|
||||
/// 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 {
|
||||
pub const LEN: usize = 100;
|
||||
|
||||
fn generate_receiver_index() -> u32 {
|
||||
loop {
|
||||
let candidate = rand::random();
|
||||
if candidate != BOOTSTRAP_RECEIVER_IDX {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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],
|
||||
timestamp: u64,
|
||||
) -> Self {
|
||||
// Generate salt: timestamp + nonce
|
||||
let mut salt = [0u8; 32];
|
||||
|
||||
// First 8 bytes: current timestamp as u64 little-endian
|
||||
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 {
|
||||
receiver_index: Self::generate_receiver_index(), // Auto-generate random receiver index
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(Self::LEN);
|
||||
out.put_u32_le(self.receiver_index);
|
||||
out.put_slice(&self.client_lp_public_key);
|
||||
out.put_slice(&self.client_ed25519_public_key);
|
||||
out.put_slice(&self.salt);
|
||||
out
|
||||
}
|
||||
pub fn decode(b: &[u8]) -> Result<Self, LpError> {
|
||||
if b.len() != Self::LEN {
|
||||
return Err(LpError::DeserializationError(format!(
|
||||
"Expected {} bytes to deserialise ClientHelloData. got {}",
|
||||
Self::LEN,
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// SAFETY: we checked for valid byte lengths
|
||||
#[allow(clippy::unwrap_used)]
|
||||
Ok(ClientHelloData {
|
||||
receiver_index: u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
|
||||
client_lp_public_key: b[4..36].try_into().unwrap(),
|
||||
client_ed25519_public_key: b[36..68].try_into().unwrap(),
|
||||
salt: b[68..].try_into().unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, IntoPrimitive, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum MessageType {
|
||||
Busy = 0x0000,
|
||||
Handshake = 0x0001,
|
||||
EncryptedData = 0x0002,
|
||||
ClientHello = 0x0003,
|
||||
KKTRequest = 0x0004,
|
||||
KKTResponse = 0x0005,
|
||||
ForwardPacket = 0x0006,
|
||||
/// Receiver index collision - client should retry with new index
|
||||
Collision = 0x0007,
|
||||
/// Acknowledgment - gateway confirms receipt of message
|
||||
Ack = 0x0008,
|
||||
/// Subsession request - client initiates subsession creation
|
||||
SubsessionRequest = 0x0009,
|
||||
/// Subsession KK1 - first message of Noise KK handshake
|
||||
SubsessionKK1 = 0x000A,
|
||||
/// Subsession KK2 - second message of Noise KK handshake
|
||||
SubsessionKK2 = 0x000B,
|
||||
/// Subsession ready - subsession established confirmation
|
||||
SubsessionReady = 0x000C,
|
||||
/// Subsession abort - race winner tells loser to become responder
|
||||
SubsessionAbort = 0x000D,
|
||||
}
|
||||
|
||||
impl MessageType {
|
||||
pub(crate) fn from_u32(value: u32) -> Option<Self> {
|
||||
MessageType::try_from(value).ok()
|
||||
}
|
||||
|
||||
pub fn to_u32(&self) -> u32 {
|
||||
u32::from(*self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HandshakeData(pub Vec<u8>);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedDataPayload(pub Vec<u8>);
|
||||
|
||||
/// KKT request frame data (serialized KKTFrame bytes)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KKTRequestData(pub Vec<u8>);
|
||||
|
||||
/// KKT response frame data (serialized KKTFrame bytes)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KKTResponseData(pub Vec<u8>);
|
||||
|
||||
/// Packet forwarding request with embedded inner LP packet
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ForwardPacketData {
|
||||
/// Target gateway's Ed25519 identity (32 bytes)
|
||||
pub target_gateway_identity: [u8; 32],
|
||||
|
||||
/// Target gateway's LP address (IP:port string)
|
||||
pub target_lp_address: String,
|
||||
|
||||
/// Complete inner LP packet bytes (serialized LpPacket)
|
||||
/// This is the CLIENT→EXIT gateway packet, encrypted for exit
|
||||
pub inner_packet_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Subsession KK1 message - first message of Noise KK handshake
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SubsessionKK1Data {
|
||||
/// Noise KK first message payload (ephemeral key + encrypted static)
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Subsession KK2 message - second message of Noise KK handshake
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SubsessionKK2Data {
|
||||
/// Noise KK second message payload (ephemeral key + encrypted response)
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Subsession ready confirmation with new session index
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SubsessionReadyData {
|
||||
/// New subsession's receiver index for routing
|
||||
pub receiver_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LpMessage {
|
||||
Busy,
|
||||
Handshake(HandshakeData),
|
||||
EncryptedData(EncryptedDataPayload),
|
||||
ClientHello(ClientHelloData),
|
||||
KKTRequest(KKTRequestData),
|
||||
KKTResponse(KKTResponseData),
|
||||
ForwardPacket(ForwardPacketData),
|
||||
/// Receiver index collision - client should retry with new receiver_index
|
||||
Collision,
|
||||
/// Acknowledgment - gateway confirms receipt of message
|
||||
Ack,
|
||||
/// Subsession request - client initiates subsession creation (empty, signal only)
|
||||
SubsessionRequest,
|
||||
/// Subsession KK1 - first message of Noise KK handshake
|
||||
SubsessionKK1(SubsessionKK1Data),
|
||||
/// Subsession KK2 - second message of Noise KK handshake
|
||||
SubsessionKK2(SubsessionKK2Data),
|
||||
/// Subsession ready - subsession established confirmation
|
||||
SubsessionReady(SubsessionReadyData),
|
||||
/// Subsession abort - race winner tells loser to become responder (empty, signal only)
|
||||
SubsessionAbort,
|
||||
}
|
||||
|
||||
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"),
|
||||
LpMessage::ForwardPacket(_) => write!(f, "ForwardPacket"),
|
||||
LpMessage::Collision => write!(f, "Collision"),
|
||||
LpMessage::Ack => write!(f, "Ack"),
|
||||
LpMessage::SubsessionRequest => write!(f, "SubsessionRequest"),
|
||||
LpMessage::SubsessionKK1(_) => write!(f, "SubsessionKK1"),
|
||||
LpMessage::SubsessionKK2(_) => write!(f, "SubsessionKK2"),
|
||||
LpMessage::SubsessionReady(_) => write!(f, "SubsessionReady"),
|
||||
LpMessage::SubsessionAbort => write!(f, "SubsessionAbort"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(_) => &[], // Structured data, serialized in encode_content
|
||||
LpMessage::KKTRequest(payload) => payload.0.as_slice(),
|
||||
LpMessage::KKTResponse(payload) => payload.0.as_slice(),
|
||||
LpMessage::ForwardPacket(_) => &[], // Structured data, serialized in encode_content
|
||||
LpMessage::Collision => &[],
|
||||
LpMessage::Ack => &[],
|
||||
LpMessage::SubsessionRequest => &[],
|
||||
LpMessage::SubsessionKK1(_) => &[], // Structured data, serialized in encode_content
|
||||
LpMessage::SubsessionKK2(_) => &[], // Structured data, serialized in encode_content
|
||||
LpMessage::SubsessionReady(_) => &[], // Structured data, serialized in encode_content
|
||||
LpMessage::SubsessionAbort => &[],
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
LpMessage::ForwardPacket(_) => false, // Always has data
|
||||
LpMessage::Collision => true,
|
||||
LpMessage::Ack => true,
|
||||
LpMessage::SubsessionRequest => true, // Empty signal
|
||||
LpMessage::SubsessionKK1(_) => false, // Always has payload
|
||||
LpMessage::SubsessionKK2(_) => false, // Always has payload
|
||||
LpMessage::SubsessionReady(_) => false, // Always has receiver_index
|
||||
LpMessage::SubsessionAbort => true, // Empty signal
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
LpMessage::Busy => 0,
|
||||
LpMessage::Handshake(payload) => payload.0.len(),
|
||||
LpMessage::EncryptedData(payload) => payload.0.len(),
|
||||
// 4 bytes receiver_index + 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt
|
||||
LpMessage::ClientHello(_) => ClientHelloData::LEN,
|
||||
LpMessage::KKTRequest(payload) => payload.0.len(),
|
||||
LpMessage::KKTResponse(payload) => payload.0.len(),
|
||||
LpMessage::ForwardPacket(data) => {
|
||||
32 + data.target_lp_address.len() + data.inner_packet_bytes.len() + 10
|
||||
}
|
||||
LpMessage::Collision => 0,
|
||||
LpMessage::Ack => 0,
|
||||
LpMessage::SubsessionRequest => 0,
|
||||
// Variable length: bincode overhead (~8 bytes for Vec length) + payload
|
||||
LpMessage::SubsessionKK1(data) => 8 + data.payload.len(),
|
||||
LpMessage::SubsessionKK2(data) => 8 + data.payload.len(),
|
||||
// 4 bytes u32 + bincode overhead (~4 bytes)
|
||||
LpMessage::SubsessionReady(_) => 8,
|
||||
LpMessage::SubsessionAbort => 0,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
LpMessage::ForwardPacket(_) => MessageType::ForwardPacket,
|
||||
LpMessage::Collision => MessageType::Collision,
|
||||
LpMessage::Ack => MessageType::Ack,
|
||||
LpMessage::SubsessionRequest => MessageType::SubsessionRequest,
|
||||
LpMessage::SubsessionKK1(_) => MessageType::SubsessionKK1,
|
||||
LpMessage::SubsessionKK2(_) => MessageType::SubsessionKK2,
|
||||
LpMessage::SubsessionReady(_) => MessageType::SubsessionReady,
|
||||
LpMessage::SubsessionAbort => MessageType::SubsessionAbort,
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
dst.put_slice(&data.encode());
|
||||
}
|
||||
LpMessage::KKTRequest(payload) => {
|
||||
dst.put_slice(&payload.0);
|
||||
}
|
||||
LpMessage::KKTResponse(payload) => {
|
||||
dst.put_slice(&payload.0);
|
||||
}
|
||||
LpMessage::ForwardPacket(data) => {
|
||||
let serialized = lp_bincode_serializer()
|
||||
.serialize(data)
|
||||
.expect("Failed to serialize ForwardPacketData");
|
||||
dst.put_slice(&serialized);
|
||||
}
|
||||
LpMessage::Collision => { /* No content */ }
|
||||
LpMessage::Ack => { /* No content */ }
|
||||
LpMessage::SubsessionRequest => { /* No content - signal only */ }
|
||||
LpMessage::SubsessionKK1(data) => {
|
||||
let serialized = lp_bincode_serializer()
|
||||
.serialize(data)
|
||||
.expect("Failed to serialize SubsessionKK1Data");
|
||||
dst.put_slice(&serialized);
|
||||
}
|
||||
LpMessage::SubsessionKK2(data) => {
|
||||
let serialized = lp_bincode_serializer()
|
||||
.serialize(data)
|
||||
.expect("Failed to serialize SubsessionKK2Data");
|
||||
dst.put_slice(&serialized);
|
||||
}
|
||||
LpMessage::SubsessionReady(data) => {
|
||||
let serialized = lp_bincode_serializer()
|
||||
.serialize(data)
|
||||
.expect("Failed to serialize SubsessionReadyData");
|
||||
dst.put_slice(&serialized);
|
||||
}
|
||||
LpMessage::SubsessionAbort => { /* No content - signal only */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
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,
|
||||
receiver_idx: 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 timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("System time before UNIX epoch")
|
||||
.as_secs();
|
||||
let client_key = [1u8; 32];
|
||||
let client_ed25519_key = [2u8; 32];
|
||||
let hello1 =
|
||||
ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
|
||||
let hello2 =
|
||||
ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
|
||||
|
||||
// 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 timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("System time before UNIX epoch")
|
||||
.as_secs();
|
||||
let client_key = [2u8; 32];
|
||||
let client_ed25519_key = [3u8; 32];
|
||||
let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
|
||||
|
||||
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 timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("System time before UNIX epoch")
|
||||
.as_secs();
|
||||
let client_key = [3u8; 32];
|
||||
let client_ed25519_key = [4u8; 32];
|
||||
let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
|
||||
|
||||
// First 8 bytes should be non-zero timestamp
|
||||
let timestamp_bytes = &hello.salt[..8];
|
||||
assert_ne!(timestamp_bytes, &[0u8; 8]);
|
||||
|
||||
// Salt should be 32 bytes total
|
||||
assert_eq!(hello.salt.len(), 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Sans-IO Noise protocol state machine, adapted from noise-psq.
|
||||
|
||||
use snow::{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),
|
||||
|
||||
#[error("session is read-only after demotion")]
|
||||
SessionReadOnly,
|
||||
}
|
||||
|
||||
impl From<snow::Error> for NoiseError {
|
||||
fn from(err: snow::Error) -> Self {
|
||||
match err {
|
||||
snow::Error::Decrypt => NoiseError::DecryptionError,
|
||||
err => NoiseError::ProtocolError(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Protocol State and Structs ---
|
||||
|
||||
/// Represents the possible states of the Noise protocol machine.
|
||||
#[derive(Debug)]
|
||||
pub enum NoiseProtocolState {
|
||||
/// The protocol is currently performing the handshake.
|
||||
/// Contains the Snow handshake state.
|
||||
Handshaking(Box<snow::HandshakeState>),
|
||||
|
||||
/// The handshake is complete, and the protocol is in transport mode.
|
||||
/// Contains the Snow transport state.
|
||||
Transport(TransportState),
|
||||
|
||||
/// The protocol has encountered an unrecoverable error.
|
||||
/// Stores the error description.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The core sans-io Noise protocol state machine.
|
||||
#[derive(Debug)]
|
||||
pub struct NoiseProtocol {
|
||||
state: NoiseProtocolState,
|
||||
// We might need buffers for incoming/outgoing data later if we add internal buffering
|
||||
// read_buffer: Vec<u8>,
|
||||
// write_buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Represents the outcome of processing received bytes via `read_message`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ReadResult {
|
||||
/// A handshake or transport message was successfully processed, but yielded no application data
|
||||
/// and did not complete the handshake.
|
||||
NoOp,
|
||||
/// A complete application data message was decrypted.
|
||||
DecryptedData(Vec<u8>),
|
||||
/// The handshake successfully completed during this read operation.
|
||||
HandshakeComplete,
|
||||
// NOTE: NeedMoreBytes variant removed as read_message expects full frames.
|
||||
}
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
impl NoiseProtocol {
|
||||
/// Creates a new `NoiseProtocol` instance in the Handshaking state.
|
||||
///
|
||||
/// Takes an initialized `snow::HandshakeState` (e.g., from `snow::Builder`).
|
||||
pub fn new(initial_state: snow::HandshakeState) -> Self {
|
||||
NoiseProtocol {
|
||||
state: NoiseProtocolState::Handshaking(Box::new(initial_state)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a single, complete incoming Noise message frame.
|
||||
///
|
||||
/// Assumes the caller handles buffering and framing to provide one full message.
|
||||
/// Returns the result of processing the message.
|
||||
pub fn read_message(&mut self, input: &[u8]) -> Result<ReadResult, NoiseError> {
|
||||
// Allocate a buffer large enough for the maximum possible Noise message size.
|
||||
// TODO: Consider reusing a buffer for efficiency.
|
||||
let mut buffer = vec![0u8; 65535]; // Max Noise message size
|
||||
|
||||
match &mut self.state {
|
||||
NoiseProtocolState::Handshaking(handshake_state) => {
|
||||
match handshake_state.read_message(input, &mut buffer) {
|
||||
Ok(_) => {
|
||||
if handshake_state.is_handshake_finished() {
|
||||
// Transition to Transport state.
|
||||
let current_state = std::mem::replace(
|
||||
&mut self.state,
|
||||
// Temporary placeholder needed for mem::replace
|
||||
NoiseProtocolState::Failed(
|
||||
NoiseError::IncorrectStateError.to_string(),
|
||||
),
|
||||
);
|
||||
if let NoiseProtocolState::Handshaking(state_to_convert) = current_state
|
||||
{
|
||||
match state_to_convert.into_transport_mode() {
|
||||
Ok(transport_state) => {
|
||||
self.state = NoiseProtocolState::Transport(transport_state);
|
||||
Ok(ReadResult::HandshakeComplete)
|
||||
}
|
||||
Err(e) => {
|
||||
let err = NoiseError::from(e);
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Should be unreachable
|
||||
let err = NoiseError::IncorrectStateError;
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Err(err)
|
||||
}
|
||||
} else {
|
||||
// Handshake continues
|
||||
Ok(ReadResult::NoOp)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err = NoiseError::from(e);
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
NoiseProtocolState::Transport(transport_state) => {
|
||||
match transport_state.read_message(input, &mut buffer) {
|
||||
Ok(len) => Ok(ReadResult::DecryptedData(buffer[..len].to_vec())),
|
||||
Err(e) => {
|
||||
let err = NoiseError::from(e);
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
NoiseProtocolState::Failed(_) => Err(NoiseError::IncorrectStateError),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if there are pending handshake messages to send.
|
||||
///
|
||||
/// If in Handshaking state and it's our turn, generates the message.
|
||||
/// Transitions state to Transport if the handshake completes after this message.
|
||||
/// Returns `None` if not in Handshaking state or not our turn.
|
||||
pub fn get_bytes_to_send(&mut self) -> Option<Result<Vec<u8>, NoiseError>> {
|
||||
match &mut self.state {
|
||||
NoiseProtocolState::Handshaking(handshake_state) => {
|
||||
if handshake_state.is_my_turn() {
|
||||
let mut buffer = vec![0u8; 65535];
|
||||
match handshake_state.write_message(&[], &mut buffer) {
|
||||
// Empty payload for handshake msg
|
||||
Ok(len) => {
|
||||
if handshake_state.is_handshake_finished() {
|
||||
// Transition to Transport state.
|
||||
let current_state = std::mem::replace(
|
||||
&mut self.state,
|
||||
NoiseProtocolState::Failed(
|
||||
NoiseError::IncorrectStateError.to_string(),
|
||||
),
|
||||
);
|
||||
if let NoiseProtocolState::Handshaking(state_to_convert) =
|
||||
current_state
|
||||
{
|
||||
match state_to_convert.into_transport_mode() {
|
||||
Ok(transport_state) => {
|
||||
self.state =
|
||||
NoiseProtocolState::Transport(transport_state);
|
||||
Some(Ok(buffer[..len].to_vec())) // Return final handshake msg
|
||||
}
|
||||
Err(e) => {
|
||||
let err = NoiseError::from(e);
|
||||
self.state =
|
||||
NoiseProtocolState::Failed(err.to_string());
|
||||
Some(Err(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Should be unreachable
|
||||
let err = NoiseError::IncorrectStateError;
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Some(Err(err))
|
||||
}
|
||||
} else {
|
||||
// Handshake continues
|
||||
Some(Ok(buffer[..len].to_vec()))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err = NoiseError::from(e);
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Some(Err(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not our turn
|
||||
None
|
||||
}
|
||||
}
|
||||
NoiseProtocolState::Transport(_) | NoiseProtocolState::Failed(_) => {
|
||||
// No handshake messages to send in these states
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts an application data payload for sending during the Transport phase.
|
||||
///
|
||||
/// Returns the ciphertext (payload + 16-byte tag).
|
||||
/// Errors if not in Transport state or encryption fails.
|
||||
pub fn write_message(&mut self, payload: &[u8]) -> Result<Vec<u8>, NoiseError> {
|
||||
match &mut self.state {
|
||||
NoiseProtocolState::Transport(transport_state) => {
|
||||
let mut buffer = vec![0u8; payload.len() + 16]; // Payload + tag
|
||||
match transport_state.write_message(payload, &mut buffer) {
|
||||
Ok(len) => Ok(buffer[..len].to_vec()),
|
||||
Err(e) => {
|
||||
let err = NoiseError::from(e);
|
||||
self.state = NoiseProtocolState::Failed(err.to_string());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
NoiseProtocolState::Handshaking(_) | NoiseProtocolState::Failed(_) => {
|
||||
Err(NoiseError::IncorrectStateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the protocol is in the transport phase (handshake complete).
|
||||
pub fn is_transport(&self) -> bool {
|
||||
matches!(self.state, NoiseProtocolState::Transport(_))
|
||||
}
|
||||
|
||||
/// Returns true if the protocol has failed.
|
||||
pub fn is_failed(&self) -> bool {
|
||||
matches!(self.state, NoiseProtocolState::Failed(_))
|
||||
}
|
||||
|
||||
/// Check if the handshake has finished and the protocol is in transport mode.
|
||||
pub fn is_handshake_finished(&self) -> bool {
|
||||
matches!(self.state, NoiseProtocolState::Transport(_))
|
||||
}
|
||||
|
||||
/// 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<NoiseProtocol, NoiseError> {
|
||||
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
|
||||
let psk_index = 3;
|
||||
let noise_params: NoiseParams = pattern_name.parse().unwrap();
|
||||
|
||||
let builder = snow::Builder::new(noise_params.clone());
|
||||
// Using dummy remote key as it's not needed for state creation itself
|
||||
// In a real scenario, the key would depend on initiator/responder role
|
||||
let handshake_state = builder
|
||||
.local_private_key(local_private_key)
|
||||
.remote_public_key(remote_public_key) // Use own public as dummy remote
|
||||
.psk(psk_index, psk)
|
||||
.build_initiator()?;
|
||||
Ok(NoiseProtocol::new(handshake_state))
|
||||
}
|
||||
|
||||
pub fn create_noise_state_responder(
|
||||
local_private_key: &[u8],
|
||||
remote_public_key: &[u8],
|
||||
psk: &[u8],
|
||||
) -> Result<NoiseProtocol, NoiseError> {
|
||||
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
|
||||
let psk_index = 3;
|
||||
let noise_params: NoiseParams = pattern_name.parse().unwrap();
|
||||
|
||||
let builder = snow::Builder::new(noise_params.clone());
|
||||
// Using dummy remote key as it's not needed for state creation itself
|
||||
// In a real scenario, the key would depend on initiator/responder role
|
||||
let handshake_state = builder
|
||||
.local_private_key(local_private_key)
|
||||
.remote_public_key(remote_public_key) // Use own public as dummy remote
|
||||
.psk(psk_index, psk)
|
||||
.build_responder()?;
|
||||
Ok(NoiseProtocol::new(handshake_state))
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<u8> {
|
||||
let mut bytes = BytesMut::new();
|
||||
self.encode(&mut bytes);
|
||||
bytes.freeze().to_vec()
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self, dst: &mut BytesMut) {
|
||||
self.header.encode(dst);
|
||||
|
||||
dst.put_slice(&(self.message.typ() as u16).to_le_bytes());
|
||||
self.message.encode_content(dst);
|
||||
|
||||
dst.put_slice(&self.trailer)
|
||||
}
|
||||
|
||||
/// Validate packet counter against a replay protection validator
|
||||
///
|
||||
/// This performs a quick check to see if the packet counter is valid before
|
||||
/// any expensive processing is done.
|
||||
pub fn validate_counter(
|
||||
&self,
|
||||
validator: &Arc<Mutex<ReceivingKeyCounterValidator>>,
|
||||
) -> Result<(), LpError> {
|
||||
let guard = validator.lock();
|
||||
guard.will_accept_branchless(self.header.counter)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark packet as received in the replay protection validator
|
||||
///
|
||||
/// This should be called after a packet has been successfully processed.
|
||||
pub fn mark_received(
|
||||
&self,
|
||||
validator: &Arc<Mutex<ReceivingKeyCounterValidator>>,
|
||||
) -> Result<(), LpError> {
|
||||
let mut guard = validator.lock();
|
||||
guard.mark_did_receive_branchless(self.header.counter)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Session ID used for ClientHello bootstrap packets before session is established.
|
||||
///
|
||||
/// When a client first connects, it sends a ClientHello packet with receiver_idx=0
|
||||
/// because neither side can compute the deterministic session ID yet (requires
|
||||
/// both parties' X25519 keys). After ClientHello is processed, both sides derive
|
||||
/// the same session ID from their keys, and all subsequent packets use that ID.
|
||||
pub const BOOTSTRAP_RECEIVER_IDX: u32 = 0;
|
||||
|
||||
/// Outer header (12 bytes) - always cleartext, used for routing.
|
||||
///
|
||||
/// This is the first 12 bytes of every LP packet, containing only the fields
|
||||
/// needed for session lookup (receiver_idx) and replay protection (counter).
|
||||
/// For encrypted packets, this is the AAD (additional authenticated data).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OuterHeader {
|
||||
pub receiver_idx: u32,
|
||||
pub counter: u64,
|
||||
}
|
||||
|
||||
impl OuterHeader {
|
||||
pub const SIZE: usize = 12; // receiver_idx(4) + counter(8)
|
||||
|
||||
pub fn new(receiver_idx: u32, counter: u64) -> Self {
|
||||
Self {
|
||||
receiver_idx,
|
||||
counter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(src: &[u8]) -> Result<Self, LpError> {
|
||||
if src.len() < Self::SIZE {
|
||||
return Err(LpError::InsufficientBufferSize);
|
||||
}
|
||||
Ok(Self {
|
||||
receiver_idx: u32::from_le_bytes(src[0..4].try_into().unwrap()),
|
||||
counter: u64::from_le_bytes(src[4..12].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> [u8; Self::SIZE] {
|
||||
let mut buf = [0u8; Self::SIZE];
|
||||
buf[0..4].copy_from_slice(&self.receiver_idx.to_le_bytes());
|
||||
buf[4..12].copy_from_slice(&self.counter.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode directly into a BytesMut buffer
|
||||
pub fn encode_into(&self, dst: &mut BytesMut) {
|
||||
dst.put_slice(&self.receiver_idx.to_le_bytes());
|
||||
dst.put_slice(&self.counter.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal LP header representation containing all logical header fields.
|
||||
///
|
||||
/// **Note**: This struct represents the LOGICAL header, not the wire format.
|
||||
/// On the wire, packets use the unified format where:
|
||||
/// - `OuterHeader` (receiver_idx + counter) always comes first (12 bytes, cleartext)
|
||||
/// - Inner content (version + reserved + payload) follows (cleartext or encrypted)
|
||||
///
|
||||
/// The `LpHeader::encode()` method outputs the old logical format for debug purposes only.
|
||||
/// Use `serialize_lp_packet()` in codec.rs for actual wire serialization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LpHeader {
|
||||
pub protocol_version: u8,
|
||||
pub reserved: u16,
|
||||
pub receiver_idx: u32,
|
||||
pub counter: u64,
|
||||
}
|
||||
|
||||
impl LpHeader {
|
||||
pub const SIZE: usize = 16;
|
||||
}
|
||||
|
||||
impl LpHeader {
|
||||
pub fn new(receiver_idx: u32, counter: u64) -> Self {
|
||||
Self {
|
||||
protocol_version: 1,
|
||||
reserved: 0,
|
||||
receiver_idx,
|
||||
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.receiver_idx.to_le_bytes());
|
||||
|
||||
// counter
|
||||
dst.put_slice(&self.counter.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn parse(src: &[u8]) -> Result<Self, LpError> {
|
||||
if src.len() < Self::SIZE {
|
||||
return Err(LpError::InsufficientBufferSize);
|
||||
}
|
||||
|
||||
let protocol_version = src[0];
|
||||
// Skip reserved bytes [1..4]
|
||||
|
||||
let mut receiver_idx_bytes = [0u8; 4];
|
||||
receiver_idx_bytes.copy_from_slice(&src[4..8]);
|
||||
let receiver_idx = u32::from_le_bytes(receiver_idx_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,
|
||||
receiver_idx,
|
||||
counter,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the counter value from the header
|
||||
pub fn counter(&self) -> u64 {
|
||||
self.counter
|
||||
}
|
||||
|
||||
/// Get the sender index from the header
|
||||
pub fn receiver_idx(&self) -> u32 {
|
||||
self.receiver_idx
|
||||
}
|
||||
}
|
||||
|
||||
// subsequent data: MessageType || Data
|
||||
@@ -0,0 +1,789 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! PSK (Pre-Shared Key) derivation for LP sessions using Blake3 KDF.
|
||||
//!
|
||||
//! This module implements identity-bound PSK derivation where both client and gateway
|
||||
//! derive the same PSK from their LP keypairs.
|
||||
//!
|
||||
//! PSQ is embedded in Noise (not separate protocol) because:
|
||||
//! 1. Single round-trip: PSQ ciphertext piggybacks on Noise handshake messages
|
||||
//! 2. PSK binding: Noise XKpsk3 pattern authenticates both ECDH and PSQ-derived PSK
|
||||
//! 3. Simpler state machine: No separate PSQ negotiation phase needed
|
||||
//! 4. Atomic security: Session establishment either succeeds fully or fails completely
|
||||
//!
|
||||
//! 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";
|
||||
|
||||
/// Context string for subsession PSK derivation.
|
||||
const SUBSESSION_PSK_CONTEXT: &str = "lp-subsession-psk-v1";
|
||||
|
||||
/// Result from PSQ initiator message creation.
|
||||
///
|
||||
/// Contains all outputs needed for session establishment:
|
||||
/// - `psk`: Final derived PSK for Noise handshake (ECDH || K_pq || salt → Blake3)
|
||||
/// - `payload`: Serialized PSQ message to send to responder
|
||||
/// - `pq_shared_secret`: Raw K_pq from KEM encapsulation (for subsession derivation)
|
||||
#[derive(Debug)]
|
||||
pub struct PsqInitiatorResult {
|
||||
/// Final PSK for Noise XKpsk3 handshake
|
||||
pub psk: [u8; 32],
|
||||
/// Serialized PSQ payload to embed in handshake message
|
||||
pub payload: Vec<u8>,
|
||||
/// Raw PQ shared secret (K_pq) before KDF combination.
|
||||
/// Used for deriving subsession PSKs to preserve PQ protection.
|
||||
pub pq_shared_secret: [u8; 32],
|
||||
}
|
||||
|
||||
/// Result from PSQ responder message processing.
|
||||
///
|
||||
/// Contains all outputs needed for session establishment:
|
||||
/// - `psk`: Final derived PSK for Noise handshake (matches initiator's)
|
||||
/// - `psk_handle`: Encrypted PSK handle (ctxt_B) to send back to initiator
|
||||
/// - `pq_shared_secret`: Raw K_pq from KEM decapsulation (for subsession derivation)
|
||||
#[derive(Debug)]
|
||||
pub struct PsqResponderResult {
|
||||
/// Final PSK for Noise XKpsk3 handshake
|
||||
pub psk: [u8; 32],
|
||||
/// Encrypted PSK handle (ctxt_B) from PSQ responder message
|
||||
pub psk_handle: Vec<u8>,
|
||||
/// Raw PQ shared secret (K_pq) before KDF combination.
|
||||
/// Used for deriving subsession PSKs to preserve PQ protection.
|
||||
pub pq_shared_secret: [u8; 32],
|
||||
}
|
||||
|
||||
/// 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<u8>), 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
|
||||
// KEM algorithm migration path:
|
||||
// - X25519: Current default for testing/compatibility (no HNDL resistance)
|
||||
// - MlKem768: Future production default (NIST PQ Level 3, HNDL resistant)
|
||||
// - XWing: Maximum security option (hybrid X25519 + ML-KEM)
|
||||
// Migration: Update LpConfig.kem_algorithm, no protocol changes needed.
|
||||
// KKT protocol adapts automatically to different KEM key sizes.
|
||||
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::<PsqX25519>::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
|
||||
/// `PsqInitiatorResult` containing PSK, payload, and raw PQ shared secret
|
||||
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<PsqInitiatorResult, 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 = <Ed25519 as Authenticator>::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::<Ed25519, PsqX25519>(
|
||||
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) - this is K_pq
|
||||
let psq_psk = state.unregistered_psk();
|
||||
|
||||
// pq_shared_secret is the raw K_pq from KEM encapsulation.
|
||||
// Store it for subsession derivation before it's combined with ECDH.
|
||||
let pq_shared_secret: [u8; 32] = *psq_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(PsqInitiatorResult {
|
||||
psk: final_psk,
|
||||
payload: msg_bytes,
|
||||
pq_shared_secret,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `PsqResponderResult` containing PSK, PSK handle, and raw PQ shared secret
|
||||
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<PsqResponderResult, 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::<PsqX25519>::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 = <Ed25519 as Authenticator>::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::<Ed25519, PsqX25519>(
|
||||
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 - this is K_pq
|
||||
let psq_psk = registered_psk.psk;
|
||||
|
||||
// pq_shared_secret is the raw K_pq from KEM decapsulation.
|
||||
// Store it for subsession derivation before it's combined with ECDH.
|
||||
let pq_shared_secret: [u8; 32] = psq_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(PsqResponderResult {
|
||||
psk: final_psk,
|
||||
psk_handle: responder_msg_bytes,
|
||||
pq_shared_secret,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive subsession PSK from parent's PQ shared secret.
|
||||
///
|
||||
/// Uses Blake3 KDF with domain separation to derive unique PSK for each subsession.
|
||||
/// This preserves PQ protection: subsession keys inherit quantum resistance from
|
||||
/// parent's KEM shared secret (K_pq).
|
||||
///
|
||||
/// # Security Model
|
||||
///
|
||||
/// Subsessions use Noise KKpsk0 pattern where:
|
||||
/// - Both parties already know each other's static X25519 keys (from parent session)
|
||||
/// - PSK provides PQ protection by deriving from parent's K_pq
|
||||
/// - Each subsession gets unique PSK via index parameter (prevents key reuse)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pq_shared_secret` - Parent session's K_pq (32 bytes from KEM)
|
||||
/// * `subsession_index` - Monotonic index for this subsession (prevents reuse)
|
||||
///
|
||||
/// # Returns
|
||||
/// 32-byte PSK for Noise KKpsk0 handshake
|
||||
pub fn derive_subsession_psk(pq_shared_secret: &[u8; 32], subsession_index: u64) -> [u8; 32] {
|
||||
nym_crypto::kdf::derive_key_blake3(
|
||||
SUBSESSION_PSK_CONTEXT,
|
||||
pq_shared_secret,
|
||||
&subsession_index.to_le_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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Error types for replay protection.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during replay protection validation.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ReplayError {
|
||||
/// The counter value is invalid (e.g., too far in the future)
|
||||
#[error("Invalid counter value")]
|
||||
InvalidCounter,
|
||||
|
||||
/// The packet has already been received (replay attack)
|
||||
#[error("Duplicate counter value")]
|
||||
DuplicateCounter,
|
||||
|
||||
/// The packet is outside the replay window
|
||||
#[error("Packet outside replay window")]
|
||||
OutOfWindow,
|
||||
}
|
||||
|
||||
/// Result type for replay protection operations
|
||||
pub type ReplayResult<T> = Result<T, ReplayError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::LpError;
|
||||
|
||||
#[test]
|
||||
fn test_replay_error_variants() {
|
||||
let invalid = ReplayError::InvalidCounter;
|
||||
let duplicate = ReplayError::DuplicateCounter;
|
||||
let out_of_window = ReplayError::OutOfWindow;
|
||||
|
||||
assert_eq!(invalid.to_string(), "Invalid counter value");
|
||||
assert_eq!(duplicate.to_string(), "Duplicate counter value");
|
||||
assert_eq!(out_of_window.to_string(), "Packet outside replay window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_error_conversion() {
|
||||
let replay_error = ReplayError::InvalidCounter;
|
||||
let lp_error: LpError = replay_error.into();
|
||||
|
||||
match lp_error {
|
||||
LpError::Replay(e) => {
|
||||
assert!(matches!(e, ReplayError::InvalidCounter));
|
||||
}
|
||||
_ => panic!("Expected Replay variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_result() {
|
||||
let ok_result: ReplayResult<()> = Ok(());
|
||||
let err = ReplayError::InvalidCounter;
|
||||
|
||||
assert!(ok_result.is_ok());
|
||||
assert!(matches!(err, ReplayError::InvalidCounter));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Replay protection module for the Lewes Protocol.
|
||||
//!
|
||||
//! This module implements BoringTun-style replay protection to prevent
|
||||
//! replay attacks and ensure packet ordering. It uses a bitmap-based
|
||||
//! approach to track received packets and validate their sequence.
|
||||
|
||||
pub mod error;
|
||||
pub mod simd;
|
||||
pub mod validator;
|
||||
|
||||
pub use error::ReplayError;
|
||||
pub use validator::ReceivingKeyCounterValidator;
|
||||
@@ -0,0 +1,281 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! ARM NEON implementation of bitmap operations.
|
||||
|
||||
use super::BitmapOps;
|
||||
|
||||
#[cfg(target_feature = "neon")]
|
||||
use std::arch::aarch64::{vceqq_u64, vdupq_n_u64, vgetq_lane_u64, vld1q_u64, vst1q_u64};
|
||||
|
||||
/// ARM NEON bitmap operations implementation
|
||||
pub struct ArmBitmapOps;
|
||||
|
||||
impl BitmapOps for ArmBitmapOps {
|
||||
#[inline(always)]
|
||||
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
|
||||
debug_assert!(start_idx + num_words <= bitmap.len());
|
||||
|
||||
#[cfg(target_feature = "neon")]
|
||||
unsafe {
|
||||
// Process 2 words at a time with NEON
|
||||
// Safety:
|
||||
// - vdupq_n_u64 is safe to call with any u64 value
|
||||
let zero_vec = vdupq_n_u64(0);
|
||||
let mut idx = start_idx;
|
||||
let end_idx = start_idx + num_words;
|
||||
|
||||
// Process aligned blocks of 2 words
|
||||
while idx + 2 <= end_idx {
|
||||
// Safety:
|
||||
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
|
||||
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
|
||||
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
|
||||
vst1q_u64(bitmap[idx..].as_mut_ptr(), zero_vec);
|
||||
idx += 2;
|
||||
}
|
||||
|
||||
// Handle remaining words (0 or 1)
|
||||
while idx < end_idx {
|
||||
bitmap[idx] = 0;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_feature = "neon"))]
|
||||
{
|
||||
// Fallback to scalar implementation
|
||||
for i in start_idx..(start_idx + num_words) {
|
||||
bitmap[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
|
||||
debug_assert!(start_idx + num_words <= bitmap.len());
|
||||
|
||||
#[cfg(target_feature = "neon")]
|
||||
unsafe {
|
||||
// Process 2 words at a time with NEON
|
||||
// Safety:
|
||||
// - vdupq_n_u64 is safe to call with any u64 value
|
||||
let zero_vec = vdupq_n_u64(0);
|
||||
let mut idx = start_idx;
|
||||
let end_idx = start_idx + num_words;
|
||||
|
||||
// Process aligned blocks of 2 words
|
||||
while idx + 2 <= end_idx {
|
||||
// Safety:
|
||||
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
|
||||
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
|
||||
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
|
||||
let data_vec = vld1q_u64(bitmap[idx..].as_ptr());
|
||||
|
||||
// Safety:
|
||||
// - vceqq_u64 is safe when given valid vector values from vld1q_u64 and vdupq_n_u64
|
||||
// - vgetq_lane_u64 is safe with valid indices (0 and 1) for a 2-lane vector
|
||||
let cmp_result = vceqq_u64(data_vec, zero_vec);
|
||||
let mask1 = vgetq_lane_u64(cmp_result, 0);
|
||||
let mask2 = vgetq_lane_u64(cmp_result, 1);
|
||||
|
||||
if (mask1 & mask2) != u64::MAX {
|
||||
return false;
|
||||
}
|
||||
|
||||
idx += 2;
|
||||
}
|
||||
|
||||
// Handle remaining words (0 or 1)
|
||||
while idx < end_idx {
|
||||
if bitmap[idx] != 0 {
|
||||
return false;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_feature = "neon"))]
|
||||
{
|
||||
// Fallback to scalar implementation
|
||||
bitmap[start_idx..(start_idx + num_words)]
|
||||
.iter()
|
||||
.all(|&w| w == 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = bit_idx % 64;
|
||||
bitmap[word_idx] |= 1u64 << bit_pos;
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = bit_idx % 64;
|
||||
bitmap[word_idx] &= !(1u64 << bit_pos);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = bit_idx % 64;
|
||||
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// We also implement optimized versions for specific operations that could
|
||||
/// benefit from NEON but don't fit the general trait pattern
|
||||
///
|
||||
/// Atomic operations for the bitmap
|
||||
pub mod atomic {
|
||||
#[cfg(target_feature = "neon")]
|
||||
use std::arch::aarch64::{vdupq_n_u64, vld1q_u64, vorrq_u64, vst1q_u64};
|
||||
|
||||
/// Check and set bit, returning the previous state
|
||||
/// This function is not actually atomic! It's just a non-atomic optimization
|
||||
/// For actual atomic operations, the caller must provide proper synchronization
|
||||
#[inline(always)]
|
||||
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = bit_idx % 64;
|
||||
let mask = 1u64 << bit_pos;
|
||||
|
||||
// Get old value
|
||||
let old_word = bitmap[word_idx];
|
||||
|
||||
// Set bit regardless of current state
|
||||
bitmap[word_idx] |= mask;
|
||||
|
||||
// Return true if bit was already set (duplicate)
|
||||
(old_word & mask) != 0
|
||||
}
|
||||
|
||||
/// Set a range of bits efficiently using NEON
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// This function is unsafe because it:
|
||||
/// - Uses SIMD intrinsics that require the NEON CPU feature to be available
|
||||
/// - Accesses bitmap memory through raw pointers
|
||||
/// - Does not perform bounds checking beyond what's required for SIMD operations
|
||||
///
|
||||
/// Caller must ensure:
|
||||
/// - The NEON feature is available on the current CPU
|
||||
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
|
||||
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
|
||||
/// - No other thread is concurrently modifying the same memory
|
||||
#[inline(always)]
|
||||
#[cfg(target_feature = "neon")]
|
||||
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
|
||||
// Process whole words where possible
|
||||
let start_word = (start_bit / 64) as usize;
|
||||
let end_word = (end_bit / 64) as usize;
|
||||
|
||||
if start_word == end_word {
|
||||
// Special case: all bits in the same word
|
||||
let start_mask = u64::MAX << (start_bit % 64);
|
||||
let end_mask = u64::MAX >> (63 - (end_bit % 64));
|
||||
bitmap[start_word] |= start_mask & end_mask;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle partial words at the beginning and end
|
||||
if !start_bit.is_multiple_of(64) {
|
||||
let start_mask = u64::MAX << (start_bit % 64);
|
||||
bitmap[start_word] |= start_mask;
|
||||
}
|
||||
|
||||
if !(end_bit + 1).is_multiple_of(64) {
|
||||
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.is_multiple_of(64) {
|
||||
start_word
|
||||
} else {
|
||||
start_word + 1
|
||||
};
|
||||
let last_full_word = if (end_bit + 1).is_multiple_of(64) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! SIMD optimizations for the replay protection bitmap operations.
|
||||
//!
|
||||
//! This module provides architecture-specific SIMD implementations with a common interface.
|
||||
|
||||
// Re-export the appropriate implementation
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
mod x86;
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub use self::x86::*;
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
mod arm;
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
pub use self::arm::*;
|
||||
|
||||
// Fallback scalar implementation for all other architectures
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
mod scalar;
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
pub use self::scalar::*;
|
||||
|
||||
/// Trait defining SIMD operations for bitmap manipulation
|
||||
pub trait BitmapOps {
|
||||
/// Clear a range of words in the bitmap
|
||||
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize);
|
||||
|
||||
/// Check if a range of words in the bitmap is all zeros
|
||||
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool;
|
||||
|
||||
/// Set a specific bit in the bitmap
|
||||
fn set_bit(bitmap: &mut [u64], bit_idx: u64);
|
||||
|
||||
/// Clear a specific bit in the bitmap
|
||||
fn clear_bit(bitmap: &mut [u64], bit_idx: u64);
|
||||
|
||||
/// Check if a specific bit is set in the bitmap
|
||||
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool;
|
||||
}
|
||||
|
||||
/// Get the optimal number of words to process in a SIMD operation
|
||||
/// for the current architecture
|
||||
#[inline(always)]
|
||||
pub fn optimal_simd_width() -> usize {
|
||||
// This value is specialized for each architecture in their respective modules
|
||||
OPTIMAL_SIMD_WIDTH
|
||||
}
|
||||
|
||||
/// Constant indicating the optimal SIMD processing width in number of u64 words
|
||||
/// for the current architecture
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[cfg(target_feature = "avx2")]
|
||||
pub const OPTIMAL_SIMD_WIDTH: usize = 4; // 256 bits = 4 u64 words
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
|
||||
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[cfg(target_feature = "neon")]
|
||||
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
|
||||
|
||||
// Fallback for non-SIMD platforms or when features aren't available
|
||||
#[cfg(not(any(
|
||||
all(target_arch = "x86_64", target_feature = "avx2"),
|
||||
all(target_arch = "x86_64", target_feature = "sse2"),
|
||||
all(target_arch = "aarch64", target_feature = "neon")
|
||||
)))]
|
||||
pub const OPTIMAL_SIMD_WIDTH: usize = 1; // Scalar fallback
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Scalar (non-SIMD) implementation of bitmap operations.
|
||||
//! Used as a fallback when SIMD instructions are unavailable.
|
||||
|
||||
use super::BitmapOps;
|
||||
|
||||
/// Scalar (non-SIMD) bitmap operations implementation
|
||||
pub struct ScalarBitmapOps;
|
||||
|
||||
impl BitmapOps for ScalarBitmapOps {
|
||||
#[inline(always)]
|
||||
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
|
||||
for i in start_idx..(start_idx + num_words) {
|
||||
bitmap[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
|
||||
for i in start_idx..(start_idx + num_words) {
|
||||
if bitmap[i] != 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = (bit_idx % 64) as u64;
|
||||
bitmap[word_idx] |= 1u64 << bit_pos;
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = (bit_idx % 64) as u64;
|
||||
bitmap[word_idx] &= !(1u64 << bit_pos);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = (bit_idx % 64) as u64;
|
||||
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Scalar implementations of other bitmap utilities
|
||||
pub mod atomic {
|
||||
/// Check and set bit, returning the previous state
|
||||
/// This function is not actually atomic! It's just a normal operation
|
||||
#[inline(always)]
|
||||
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
|
||||
let word_idx = (bit_idx / 64) as usize;
|
||||
let bit_pos = (bit_idx % 64) as u64;
|
||||
let mask = 1u64 << bit_pos;
|
||||
|
||||
// Get old value
|
||||
let old_word = bitmap[word_idx];
|
||||
|
||||
// Set bit regardless of current state
|
||||
bitmap[word_idx] |= mask;
|
||||
|
||||
// Return true if bit was already set (duplicate)
|
||||
(old_word & mask) != 0
|
||||
}
|
||||
|
||||
/// Set a range of bits efficiently
|
||||
#[inline(always)]
|
||||
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
|
||||
// Process whole words where possible
|
||||
let start_word = (start_bit / 64) as usize;
|
||||
let end_word = (end_bit / 64) as usize;
|
||||
|
||||
if start_word == end_word {
|
||||
// Special case: all bits in the same word
|
||||
let start_mask = u64::MAX << (start_bit % 64);
|
||||
let end_mask = u64::MAX >> (63 - (end_bit % 64));
|
||||
bitmap[start_word] |= start_mask & end_mask;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle partial words at the beginning and end
|
||||
if start_bit % 64 != 0 {
|
||||
let start_mask = u64::MAX << (start_bit % 64);
|
||||
bitmap[start_word] |= start_mask;
|
||||
}
|
||||
|
||||
if (end_bit + 1) % 64 != 0 {
|
||||
let end_mask = u64::MAX >> (63 - (end_bit % 64));
|
||||
bitmap[end_word] |= end_mask;
|
||||
}
|
||||
|
||||
// Handle complete words in the middle
|
||||
let first_full_word = if start_bit % 64 == 0 {
|
||||
start_word
|
||||
} else {
|
||||
start_word + 1
|
||||
};
|
||||
let last_full_word = if (end_bit + 1) % 64 == 0 {
|
||||
end_word
|
||||
} else {
|
||||
end_word - 1
|
||||
};
|
||||
|
||||
for word_idx in first_full_word..=last_full_word {
|
||||
bitmap[word_idx] = u64::MAX;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! x86/x86_64 SIMD implementation of bitmap operations.
|
||||
//! Provides optimized implementations using SSE2 and AVX2 intrinsics.
|
||||
|
||||
use super::BitmapOps;
|
||||
|
||||
// Track execution counts for debugging
|
||||
#[cfg(target_feature = "avx2")]
|
||||
static mut AVX2_CLEAR_COUNT: usize = 0;
|
||||
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
|
||||
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_loadu_si128, _mm_or_si128, _mm_set1_epi64x, _mm_setzero_si128, _mm_storeu_si128,
|
||||
};
|
||||
|
||||
#[cfg(all(target_feature = "sse2", not(target_feature = "sse4.1")))]
|
||||
use std::arch::x86_64::{_mm_cmpeq_epi64, _mm_movemask_epi8};
|
||||
|
||||
/// x86/x86_64 SIMD bitmap operations implementation
|
||||
pub struct X86BitmapOps;
|
||||
|
||||
impl BitmapOps for X86BitmapOps {
|
||||
#[allow(unreachable_code)]
|
||||
#[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
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in start_idx..(start_idx + num_words) {
|
||||
bitmap[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
#[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 std::arch::x86_64::_mm_testz_si128(data_vec, data_vec) == 0 {
|
||||
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 std::arch::x86_64::_mm_testz_si128(data_vec, data_vec) == 0 {
|
||||
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;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
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.is_multiple_of(64) {
|
||||
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 = unsafe { _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.is_multiple_of(64) {
|
||||
let start_mask = u64::MAX << (start_bit % 64);
|
||||
bitmap[start_word] |= start_mask;
|
||||
}
|
||||
|
||||
if !(end_bit + 1).is_multiple_of(64) {
|
||||
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.is_multiple_of(64) {
|
||||
start_word
|
||||
} else {
|
||||
start_word + 1
|
||||
};
|
||||
let last_full_word = if (end_bit + 1).is_multiple_of(64) {
|
||||
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 = unsafe { _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 = unsafe { _mm_loadu_si128(bitmap[i..].as_ptr() as *const __m128i) };
|
||||
let result = unsafe { _mm_or_si128(current, ones) };
|
||||
unsafe { _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.is_multiple_of(64) {
|
||||
start_word
|
||||
} else {
|
||||
start_word + 1
|
||||
};
|
||||
let last_full_word = if (end_bit + 1) % 64 == 0 {
|
||||
end_word
|
||||
} else {
|
||||
end_word - 1
|
||||
};
|
||||
|
||||
for i in first_full_word..=last_full_word {
|
||||
bitmap[i] = u64::MAX;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Replay protection validator implementation.
|
||||
//!
|
||||
//! This module implements the core replay protection logic using a bitmap-based
|
||||
//! approach to track received packets and validate their sequence.
|
||||
|
||||
use crate::replay::error::{ReplayError, ReplayResult};
|
||||
use crate::replay::simd::{self, BitmapOps};
|
||||
|
||||
// Determine the appropriate SIMD implementation at compile time
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[cfg(target_feature = "neon")]
|
||||
use crate::replay::simd::ArmBitmapOps as SimdImpl;
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[cfg(target_feature = "avx2")]
|
||||
use crate::replay::simd::X86BitmapOps as SimdImpl;
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
|
||||
use crate::replay::simd::X86BitmapOps as SimdImpl;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(target_arch = "x86_64", target_feature = "avx2"),
|
||||
all(target_arch = "x86_64", target_feature = "sse2"),
|
||||
all(target_arch = "aarch64", target_feature = "neon")
|
||||
)))]
|
||||
use crate::replay::simd::ScalarBitmapOps as SimdImpl;
|
||||
|
||||
/// Size of a word in the bitmap (64 bits)
|
||||
const WORD_SIZE: usize = 64;
|
||||
|
||||
/// Number of words in the bitmap (allows reordering of 64*16 = 1024 packets)
|
||||
const N_WORDS: usize = 16;
|
||||
|
||||
/// Total number of bits in the bitmap
|
||||
const N_BITS: usize = WORD_SIZE * N_WORDS;
|
||||
|
||||
/// Validator for receiving key counters to prevent replay attacks.
|
||||
///
|
||||
/// This structure maintains a bitmap of received packets and validates
|
||||
/// incoming packet counters to ensure they are not replayed.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReceivingKeyCounterValidator {
|
||||
/// Next expected counter value
|
||||
next: u64,
|
||||
|
||||
/// Total number of received packets
|
||||
receive_cnt: u64,
|
||||
|
||||
/// Bitmap for tracking received packets
|
||||
bitmap: [u64; N_WORDS],
|
||||
}
|
||||
|
||||
impl ReceivingKeyCounterValidator {
|
||||
/// Creates a new validator with the given initial counter value.
|
||||
pub fn new(initial_counter: u64) -> Self {
|
||||
Self {
|
||||
next: initial_counter,
|
||||
receive_cnt: 0,
|
||||
bitmap: [0; N_WORDS],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a bit in the bitmap to mark a counter as received.
|
||||
#[inline(always)]
|
||||
fn set_bit(&mut self, idx: u64) {
|
||||
SimdImpl::set_bit(&mut self.bitmap, idx % (N_BITS as u64));
|
||||
}
|
||||
|
||||
/// Clears a bit in the bitmap.
|
||||
#[inline(always)]
|
||||
fn clear_bit(&mut self, idx: u64) {
|
||||
SimdImpl::clear_bit(&mut self.bitmap, idx % (N_BITS as u64));
|
||||
}
|
||||
|
||||
/// Clears the word that contains the given index.
|
||||
#[inline(always)]
|
||||
#[allow(dead_code)]
|
||||
fn clear_word(&mut self, idx: u64) {
|
||||
let bit_idx = idx % (N_BITS as u64);
|
||||
let word = (bit_idx / (WORD_SIZE as u64)) as usize;
|
||||
SimdImpl::clear_words(&mut self.bitmap, word, 1);
|
||||
}
|
||||
|
||||
/// Returns true if the bit is set, false otherwise.
|
||||
#[inline(always)]
|
||||
fn check_bit_branchless(&self, idx: u64) -> bool {
|
||||
SimdImpl::check_bit(&self.bitmap, idx % (N_BITS as u64))
|
||||
}
|
||||
|
||||
/// Performs a quick check to determine if a counter will be accepted.
|
||||
///
|
||||
/// This is a fast check that can be done before more expensive operations.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(())` if the counter is acceptable
|
||||
/// - `Err(ReplayError::InvalidCounter)` if the counter is invalid (too far back)
|
||||
/// - `Err(ReplayError::DuplicateCounter)` if the counter has already been received
|
||||
#[inline(always)]
|
||||
pub fn will_accept_branchless(&self, counter: u64) -> ReplayResult<()> {
|
||||
// Calculate conditions
|
||||
let is_growing = counter >= self.next;
|
||||
|
||||
// Handle potential overflow when adding N_BITS to counter
|
||||
let too_far_back = if counter > u64::MAX - (N_BITS as u64) {
|
||||
// If adding N_BITS would overflow, it can't be too far back
|
||||
false
|
||||
} else {
|
||||
counter + (N_BITS as u64) < self.next
|
||||
};
|
||||
|
||||
let duplicate = self.check_bit_branchless(counter);
|
||||
|
||||
if is_growing {
|
||||
Ok(())
|
||||
} else if too_far_back {
|
||||
Err(ReplayError::OutOfWindow)
|
||||
} else if duplicate {
|
||||
Err(ReplayError::DuplicateCounter)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.is_multiple_of(WORD_SIZE as u64) {
|
||||
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.is_multiple_of(WORD_SIZE as u64) && 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.is_multiple_of(simd_width as u64 * WORD_SIZE as u64)
|
||||
{
|
||||
// Use SIMD to clear multiple words at once if any need clearing
|
||||
let needs_clearing =
|
||||
!SimdImpl::is_range_zero(&self.bitmap, current_word, simd_width);
|
||||
if needs_clearing {
|
||||
SimdImpl::clear_words(&mut self.bitmap, current_word, simd_width);
|
||||
}
|
||||
|
||||
// Skip the words we just processed
|
||||
let words_to_skip = simd_width as u64 * WORD_SIZE as u64;
|
||||
if words_to_skip > u64::MAX - i {
|
||||
i = u64::MAX;
|
||||
break;
|
||||
}
|
||||
i += words_to_skip;
|
||||
} else {
|
||||
// Process single word
|
||||
if self.bitmap[current_word] != 0 {
|
||||
self.bitmap[current_word] = 0;
|
||||
}
|
||||
|
||||
// Check for potential overflow before incrementing
|
||||
if i > u64::MAX - (WORD_SIZE as u64) {
|
||||
i = u64::MAX;
|
||||
break;
|
||||
}
|
||||
i += WORD_SIZE as u64;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-alignment clearing (bit by bit for remaining bits)
|
||||
if i < counter {
|
||||
let final_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
|
||||
let is_final_word_empty = self.bitmap[final_word] == 0;
|
||||
|
||||
// Skip clearing if word is already empty
|
||||
if !is_final_word_empty {
|
||||
while i < counter {
|
||||
self.clear_bit(i);
|
||||
|
||||
// Prevent overflow on increment
|
||||
if i == u64::MAX {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_replay_counter_basic() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Check initial state
|
||||
assert_eq!(validator.next, 0);
|
||||
assert_eq!(validator.receive_cnt, 0);
|
||||
|
||||
// Test sequential counters
|
||||
assert!(validator.mark_did_receive_branchless(0).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(0).is_err());
|
||||
assert!(validator.mark_did_receive_branchless(1).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_counter_out_of_order() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Process some sequential packets
|
||||
assert!(validator.mark_did_receive_branchless(0).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(1).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(2).is_ok());
|
||||
|
||||
// Out-of-order packet that hasn't been seen yet
|
||||
assert!(validator.mark_did_receive_branchless(1).is_err()); // Already seen
|
||||
assert!(validator.mark_did_receive_branchless(10).is_ok()); // New packet, ahead of next
|
||||
|
||||
// Next should now be 11
|
||||
assert_eq!(validator.next, 11);
|
||||
|
||||
// Can still accept packets in the valid window
|
||||
assert!(validator.will_accept_branchless(9).is_ok());
|
||||
assert!(validator.will_accept_branchless(8).is_ok());
|
||||
|
||||
// But duplicates are rejected
|
||||
assert!(validator.will_accept_branchless(10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_counter_full() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Process a bunch of sequential packets
|
||||
for i in 0..64 {
|
||||
assert!(validator.mark_did_receive_branchless(i).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(i).is_err());
|
||||
}
|
||||
|
||||
// Test out of order within window
|
||||
assert!(validator.mark_did_receive_branchless(15).is_err()); // Already seen
|
||||
assert!(validator.mark_did_receive_branchless(63).is_err()); // Already seen
|
||||
|
||||
// Test for packets within bitmap range
|
||||
for i in 64..(N_BITS as u64) + 128 {
|
||||
assert!(validator.mark_did_receive_branchless(i).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(i).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_counter_window_sliding() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Jump far ahead to force window sliding
|
||||
let far_ahead = (N_BITS as u64) * 3;
|
||||
assert!(validator.mark_did_receive_branchless(far_ahead).is_ok());
|
||||
|
||||
// Everything too far back should be rejected
|
||||
for i in 0..=(N_BITS as u64) * 2 {
|
||||
assert!(matches!(
|
||||
validator.will_accept_branchless(i),
|
||||
Err(ReplayError::OutOfWindow)
|
||||
));
|
||||
assert!(validator.mark_did_receive_branchless(i).is_err());
|
||||
}
|
||||
|
||||
// Values in window but less than far_ahead should be accepted
|
||||
for i in (N_BITS as u64) * 2 + 1..far_ahead {
|
||||
assert!(validator.will_accept_branchless(i).is_ok());
|
||||
}
|
||||
|
||||
// The far_ahead value itself should be rejected now (duplicate)
|
||||
assert!(matches!(
|
||||
validator.will_accept_branchless(far_ahead),
|
||||
Err(ReplayError::DuplicateCounter)
|
||||
));
|
||||
|
||||
// Test receiving packets in reverse order within window
|
||||
for i in ((N_BITS as u64) * 2 + 1..far_ahead).rev() {
|
||||
assert!(validator.mark_did_receive_branchless(i).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(i).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_out_of_order_tracking() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Jump ahead
|
||||
assert!(validator.mark_did_receive_branchless(1000).is_ok());
|
||||
|
||||
// Test some more additions
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 70).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 71).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 72).is_ok());
|
||||
assert!(
|
||||
validator
|
||||
.mark_did_receive_branchless(1000 + 72 + 125)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 63).is_ok());
|
||||
|
||||
// Check duplicates
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 70).is_err());
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 71).is_err());
|
||||
assert!(validator.mark_did_receive_branchless(1000 + 72).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_counter_stats() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Initial state
|
||||
let (next, count) = validator.current_packet_cnt();
|
||||
assert_eq!(next, 0);
|
||||
assert_eq!(count, 0);
|
||||
|
||||
// After receiving some packets
|
||||
assert!(validator.mark_did_receive_branchless(0).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(1).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(2).is_ok());
|
||||
|
||||
let (next, count) = validator.current_packet_cnt();
|
||||
assert_eq!(next, 3);
|
||||
assert_eq!(count, 3);
|
||||
|
||||
// After an out of order packet
|
||||
assert!(validator.mark_did_receive_branchless(10).is_ok());
|
||||
|
||||
let (next, count) = validator.current_packet_cnt();
|
||||
assert_eq!(next, 11);
|
||||
assert_eq!(count, 4);
|
||||
|
||||
// After a packet from the past (within window)
|
||||
assert!(validator.mark_did_receive_branchless(5).is_ok());
|
||||
|
||||
let (next, count) = validator.current_packet_cnt();
|
||||
assert_eq!(next, 11); // Next doesn't change
|
||||
assert_eq!(count, 5); // Count increases
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_boundary_edge_cases() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// First process a sequence of packets
|
||||
for i in 0..100 {
|
||||
assert!(validator.mark_did_receive_branchless(i).is_ok());
|
||||
}
|
||||
|
||||
// The window should now span from 100 to 100+N_BITS
|
||||
|
||||
// Test packet near the upper edge of the window
|
||||
let upper_edge = 100 + (N_BITS as u64) - 1;
|
||||
assert!(validator.will_accept_branchless(upper_edge).is_ok());
|
||||
assert!(validator.mark_did_receive_branchless(upper_edge).is_ok());
|
||||
|
||||
// Test packet just outside the upper edge (should be accepted)
|
||||
let just_outside_upper = 100 + (N_BITS as u64);
|
||||
assert!(validator.will_accept_branchless(just_outside_upper).is_ok());
|
||||
|
||||
// Test packet near the lower edge of the window
|
||||
let lower_edge = 100 + 1; // +1 because we've already processed 100
|
||||
assert!(validator.will_accept_branchless(lower_edge).is_ok());
|
||||
|
||||
// Test packet just outside the lower edge (should be rejected)
|
||||
if upper_edge >= (N_BITS as u64) * 2 {
|
||||
// Only test this if we're far enough along to have a lower bound
|
||||
let just_outside_lower = 100 - (N_BITS as u64);
|
||||
assert!(matches!(
|
||||
validator.will_accept_branchless(just_outside_lower),
|
||||
Err(ReplayError::OutOfWindow)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_window_shifts() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// First jump - process packet far ahead
|
||||
let first_jump = 2000;
|
||||
assert!(validator.mark_did_receive_branchless(first_jump).is_ok());
|
||||
|
||||
// Verify next counter is updated
|
||||
let (next, _) = validator.current_packet_cnt();
|
||||
assert_eq!(next, first_jump + 1);
|
||||
|
||||
// Second large jump, even further ahead
|
||||
let second_jump = first_jump + 5000;
|
||||
assert!(validator.mark_did_receive_branchless(second_jump).is_ok());
|
||||
|
||||
// Verify next counter is updated again
|
||||
let (next, _) = validator.current_packet_cnt();
|
||||
assert_eq!(next, second_jump + 1);
|
||||
|
||||
// Test packets within the new window
|
||||
let mid_window = second_jump - 500;
|
||||
assert!(validator.will_accept_branchless(mid_window).is_ok());
|
||||
|
||||
// Test packets outside the new window
|
||||
let outside_window = first_jump + 100;
|
||||
assert!(matches!(
|
||||
validator.will_accept_branchless(outside_window),
|
||||
Err(ReplayError::OutOfWindow)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interleaved_packets_at_boundaries() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Jump ahead to establish a large window
|
||||
let jump = 2000;
|
||||
assert!(validator.mark_did_receive_branchless(jump).is_ok());
|
||||
|
||||
// Process a sequence at the upper boundary
|
||||
for i in 0..10 {
|
||||
let upper_packet = jump + 100 + i;
|
||||
assert!(validator.mark_did_receive_branchless(upper_packet).is_ok());
|
||||
}
|
||||
|
||||
// Process a sequence at the lower boundary
|
||||
for i in 0..10 {
|
||||
let lower_packet = jump - (N_BITS as u64) + 100 + i;
|
||||
// These might fail if they're outside the window, that's ok
|
||||
let _ = validator.mark_did_receive_branchless(lower_packet);
|
||||
}
|
||||
|
||||
// Process alternating packets at both ends
|
||||
for i in 0..5 {
|
||||
let upper = jump + 200 + i;
|
||||
let lower = jump - (N_BITS as u64) + 200 + i;
|
||||
|
||||
assert!(validator.will_accept_branchless(upper).is_ok());
|
||||
let lower_result = validator.will_accept_branchless(lower);
|
||||
|
||||
// Lower might be accepted or rejected, depending on exactly where the window is
|
||||
if lower_result.is_ok() {
|
||||
assert!(validator.mark_did_receive_branchless(lower).is_ok());
|
||||
}
|
||||
|
||||
assert!(validator.mark_did_receive_branchless(upper).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_window_size_with_full_bitmap() {
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Fill the entire bitmap with non-sequential packets
|
||||
// This tests both window size and bitmap capacity
|
||||
|
||||
// Generate a random but reproducible pattern
|
||||
let mut positions = Vec::new();
|
||||
for i in 0..N_BITS {
|
||||
positions.push((i * 7) % N_BITS);
|
||||
}
|
||||
|
||||
// Mark packets in this pattern
|
||||
for pos in &positions {
|
||||
assert!(validator.mark_did_receive_branchless(*pos as u64).is_ok());
|
||||
}
|
||||
|
||||
// Try to mark them again (should all fail as duplicates)
|
||||
for pos in &positions {
|
||||
assert!(matches!(
|
||||
validator.mark_did_receive_branchless(*pos as u64),
|
||||
Err(ReplayError::DuplicateCounter)
|
||||
));
|
||||
}
|
||||
|
||||
// Force window to slide
|
||||
let far_ahead = (N_BITS as u64) * 2;
|
||||
assert!(validator.mark_did_receive_branchless(far_ahead).is_ok());
|
||||
|
||||
// Old packets should now be outside the window
|
||||
for pos in &positions {
|
||||
if *pos as u64 + (N_BITS as u64) < far_ahead {
|
||||
assert!(matches!(
|
||||
validator.will_accept_branchless(*pos as u64),
|
||||
Err(ReplayError::OutOfWindow)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn test_concurrent_access() {
|
||||
let validator = Arc::new(std::sync::Mutex::new(
|
||||
ReceivingKeyCounterValidator::default(),
|
||||
));
|
||||
let num_threads = 8;
|
||||
let operations_per_thread = 1000;
|
||||
let barrier = Arc::new(Barrier::new(num_threads));
|
||||
|
||||
// Create thread handles
|
||||
let mut handles = vec![];
|
||||
|
||||
for thread_id in 0..num_threads {
|
||||
let validator_clone = Arc::clone(&validator);
|
||||
let barrier_clone = Arc::clone(&barrier);
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
// Wait for all threads to be ready
|
||||
barrier_clone.wait();
|
||||
|
||||
let mut successes = 0;
|
||||
let mut duplicates = 0;
|
||||
let mut out_of_window = 0;
|
||||
|
||||
for i in 0..operations_per_thread {
|
||||
// Generate a somewhat random but reproducible counter value
|
||||
// Different threads will sometimes try to insert the same value
|
||||
let counter = (i * 7 + thread_id * 13) as u64;
|
||||
|
||||
let mut guard = validator_clone.lock().unwrap();
|
||||
match guard.mark_did_receive_branchless(counter) {
|
||||
Ok(()) => successes += 1,
|
||||
Err(ReplayError::DuplicateCounter) => duplicates += 1,
|
||||
Err(ReplayError::OutOfWindow) => out_of_window += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(successes, duplicates, out_of_window)
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Collect results
|
||||
let mut total_successes = 0;
|
||||
let mut total_duplicates = 0;
|
||||
let mut total_out_of_window = 0;
|
||||
|
||||
for handle in handles {
|
||||
let (successes, duplicates, out_of_window) = handle.join().unwrap();
|
||||
total_successes += successes;
|
||||
total_duplicates += duplicates;
|
||||
total_out_of_window += out_of_window;
|
||||
}
|
||||
|
||||
// Verify that all operations were accounted for
|
||||
assert_eq!(
|
||||
total_successes + total_duplicates + total_out_of_window,
|
||||
num_threads * operations_per_thread
|
||||
);
|
||||
|
||||
// Verify that some operations were successful and some were duplicates
|
||||
assert!(total_successes > 0);
|
||||
assert!(total_duplicates > 0);
|
||||
|
||||
// Check final state of the validator
|
||||
let final_state = validator.lock().unwrap();
|
||||
let (_next, receive_cnt) = final_state.current_packet_cnt();
|
||||
|
||||
// Verify that the received count matches our successful operations
|
||||
assert_eq!(receive_cnt, total_successes as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_usage() {
|
||||
use std::mem::{size_of, size_of_val};
|
||||
|
||||
// Test small validator
|
||||
let validator_default = ReceivingKeyCounterValidator::default();
|
||||
let size_default = size_of_val(&validator_default);
|
||||
|
||||
// Expected size calculation
|
||||
let expected_size = size_of::<u64>() * 2 + // next + receive_cnt
|
||||
size_of::<u64>() * N_WORDS; // bitmap
|
||||
|
||||
assert_eq!(size_default, expected_size);
|
||||
println!("Default validator size: {} bytes", size_default);
|
||||
|
||||
// Memory efficiency calculation (bits tracked per byte of memory)
|
||||
let bits_per_byte = N_BITS as f64 / size_default as f64;
|
||||
println!(
|
||||
"Memory efficiency: {:.2} bits tracked per byte of memory",
|
||||
bits_per_byte
|
||||
);
|
||||
|
||||
// Verify minimum memory needed for different window sizes
|
||||
for window_size in [64usize, 128, 256, 512, 1024, 2048] {
|
||||
let words_needed = window_size.div_ceil(WORD_SIZE);
|
||||
let memory_needed = size_of::<u64>() * 2 + size_of::<u64>() * words_needed;
|
||||
println!(
|
||||
"Window size {}: {} bytes minimum",
|
||||
window_size, memory_needed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
target_feature = "sse2",
|
||||
target_feature = "avx2",
|
||||
target_feature = "neon"
|
||||
))]
|
||||
fn test_simd_operations() {
|
||||
// This test verifies that SIMD-optimized operations would produce
|
||||
// the same results as the scalar implementation
|
||||
|
||||
// Create a validator with a known state
|
||||
let mut validator = ReceivingKeyCounterValidator::default();
|
||||
|
||||
// Fill bitmap with a pattern
|
||||
for i in 0..64 {
|
||||
validator.set_bit(i);
|
||||
}
|
||||
|
||||
// Create a copy for comparison
|
||||
let _original_bitmap = validator.bitmap;
|
||||
|
||||
// Simulate SIMD clear (4 words at a time)
|
||||
#[cfg(target_feature = "avx2")]
|
||||
{
|
||||
use std::arch::x86_64::{_mm256_setzero_si256, _mm256_storeu_si256};
|
||||
|
||||
// Clear words 0-3 using AVX2
|
||||
unsafe {
|
||||
let zero_vec = _mm256_setzero_si256();
|
||||
_mm256_storeu_si256(validator.bitmap.as_mut_ptr() as *mut _, zero_vec);
|
||||
}
|
||||
|
||||
// Verify first 4 words are cleared
|
||||
assert_eq!(validator.bitmap[0], 0);
|
||||
assert_eq!(validator.bitmap[1], 0);
|
||||
assert_eq!(validator.bitmap[2], 0);
|
||||
assert_eq!(validator.bitmap[3], 0);
|
||||
|
||||
// Verify other words are unchanged
|
||||
for i in 4..N_WORDS {
|
||||
assert_eq!(validator.bitmap[i], _original_bitmap[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_feature = "sse2")]
|
||||
{
|
||||
use std::arch::x86_64::{_mm_setzero_si128, _mm_storeu_si128};
|
||||
|
||||
// Reset validator
|
||||
validator.bitmap = _original_bitmap;
|
||||
|
||||
// Clear words 0-1 using SSE2
|
||||
unsafe {
|
||||
let zero_vec = _mm_setzero_si128();
|
||||
_mm_storeu_si128(validator.bitmap.as_mut_ptr() as *mut _, zero_vec);
|
||||
}
|
||||
|
||||
// Verify first 2 words are cleared
|
||||
assert_eq!(validator.bitmap[0], 0);
|
||||
assert_eq!(validator.bitmap[1], 0);
|
||||
|
||||
// Verify other words are unchanged
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use bincode::Options;
|
||||
|
||||
pub use bincode::Error as BincodeError;
|
||||
pub use bincode::Options as BincodeOptions;
|
||||
|
||||
/// Create explicit bincode options for consistent serialization across versions.
|
||||
///
|
||||
/// Using explicit options future-proofs against bincode 1.x/2.x default changes.
|
||||
pub fn lp_bincode_serializer() -> impl BincodeOptions {
|
||||
bincode::DefaultOptions::new()
|
||||
.with_big_endian()
|
||||
.with_varint_encoding()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Session management for the Lewes Protocol.
|
||||
//!
|
||||
//! This module implements session lifecycle management functionality, handling
|
||||
//! creation, retrieval, and storage of sessions.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use 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<u32, LpStateMachine>,
|
||||
}
|
||||
|
||||
impl Default for SessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Creates a new session manager with empty session storage.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state_machines: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_input(&self, lp_id: u32, input: LpInput) -> Result<Option<LpAction>, LpError> {
|
||||
self.with_state_machine_mut(lp_id, |sm| sm.process_input(input).transpose())?
|
||||
}
|
||||
|
||||
pub fn add(&self, session: LpSession) -> Result<(), LpError> {
|
||||
let sm = LpStateMachine {
|
||||
state: LpState::ReadyToHandshake {
|
||||
session: Box::new(session),
|
||||
},
|
||||
};
|
||||
self.state_machines.insert(sm.id()?, sm);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handshaking(&self, lp_id: u32) -> Result<bool, LpError> {
|
||||
Ok(self.get_state(lp_id)? == LpStateBare::Handshaking)
|
||||
}
|
||||
|
||||
pub fn should_initiate_handshake(&self, lp_id: u32) -> Result<bool, LpError> {
|
||||
Ok(self.ready_to_handshake(lp_id)? || self.closed(lp_id)?)
|
||||
}
|
||||
|
||||
pub fn ready_to_handshake(&self, lp_id: u32) -> Result<bool, LpError> {
|
||||
Ok(self.get_state(lp_id)? == LpStateBare::ReadyToHandshake)
|
||||
}
|
||||
|
||||
pub fn closed(&self, lp_id: u32) -> Result<bool, LpError> {
|
||||
Ok(self.get_state(lp_id)? == LpStateBare::Closed)
|
||||
}
|
||||
|
||||
pub fn transport(&self, lp_id: u32) -> Result<bool, LpError> {
|
||||
Ok(self.get_state(lp_id)? == LpStateBare::Transport)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_state_machine_id(&self, lp_id: u32) -> Result<u32, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| sm.id())?
|
||||
}
|
||||
|
||||
pub fn get_state(&self, lp_id: u32) -> Result<LpStateBare, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| Ok(sm.bare_state()))?
|
||||
}
|
||||
|
||||
pub fn receiving_counter_quick_check(&self, lp_id: u32, counter: u64) -> Result<(), LpError> {
|
||||
self.with_state_machine(lp_id, |sm| {
|
||||
sm.session()?.receiving_counter_quick_check(counter)
|
||||
})?
|
||||
}
|
||||
|
||||
pub fn receiving_counter_mark(&self, lp_id: u32, counter: u64) -> Result<(), LpError> {
|
||||
self.with_state_machine(lp_id, |sm| sm.session()?.receiving_counter_mark(counter))?
|
||||
}
|
||||
|
||||
pub fn start_handshake(&self, lp_id: u32) -> Option<Result<LpMessage, LpError>> {
|
||||
self.prepare_handshake_message(lp_id)
|
||||
}
|
||||
|
||||
pub fn prepare_handshake_message(&self, lp_id: u32) -> Option<Result<LpMessage, LpError>> {
|
||||
self.with_state_machine(lp_id, |sm| sm.session().ok()?.prepare_handshake_message())
|
||||
.ok()?
|
||||
}
|
||||
|
||||
pub fn is_handshake_complete(&self, lp_id: u32) -> Result<bool, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.is_handshake_complete()))?
|
||||
}
|
||||
|
||||
pub fn next_counter(&self, lp_id: u32) -> Result<u64, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.next_counter()))?
|
||||
}
|
||||
|
||||
pub fn decrypt_data(&self, lp_id: u32, message: &LpMessage) -> Result<Vec<u8>, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| {
|
||||
sm.session()?
|
||||
.decrypt_data(message)
|
||||
.map_err(LpError::NoiseError)
|
||||
})?
|
||||
}
|
||||
|
||||
pub fn encrypt_data(&self, lp_id: u32, message: &[u8]) -> Result<LpMessage, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| {
|
||||
sm.session()?
|
||||
.encrypt_data(message)
|
||||
.map_err(LpError::NoiseError)
|
||||
})?
|
||||
}
|
||||
|
||||
pub fn current_packet_cnt(&self, lp_id: u32) -> Result<(u64, u64), LpError> {
|
||||
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.current_packet_cnt()))?
|
||||
}
|
||||
|
||||
pub fn process_handshake_message(
|
||||
&self,
|
||||
lp_id: u32,
|
||||
message: &LpMessage,
|
||||
) -> Result<ReadResult, LpError> {
|
||||
self.with_state_machine(lp_id, |sm| sm.session()?.process_handshake_message(message))?
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.state_machines.len()
|
||||
}
|
||||
|
||||
pub fn state_machine_exists(&self, lp_id: u32) -> bool {
|
||||
self.state_machines.contains_key(&lp_id)
|
||||
}
|
||||
|
||||
pub fn with_state_machine<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
|
||||
where
|
||||
F: FnOnce(&LpStateMachine) -> R,
|
||||
{
|
||||
if let Some(sm) = self.state_machines.get(&lp_id) {
|
||||
Ok(f(&sm))
|
||||
} else {
|
||||
Err(LpError::StateMachineNotFound { lp_id })
|
||||
}
|
||||
// self.state_machines.get(&lp_id).map(|sm_ref| f(&*sm_ref)) // Lock held only during closure execution
|
||||
}
|
||||
|
||||
// For mutable access (like running process_input)
|
||||
pub fn with_state_machine_mut<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
|
||||
where
|
||||
F: FnOnce(&mut LpStateMachine) -> R, // Closure takes mutable ref
|
||||
{
|
||||
if let Some(mut sm) = self.state_machines.get_mut(&lp_id) {
|
||||
Ok(f(&mut sm))
|
||||
} else {
|
||||
Err(LpError::StateMachineNotFound { lp_id })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session_state_machine(
|
||||
&self,
|
||||
receiver_index: u32,
|
||||
local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey),
|
||||
remote_ed25519_key: &ed25519::PublicKey,
|
||||
is_initiator: bool,
|
||||
salt: &[u8; 32],
|
||||
) -> Result<u32, LpError> {
|
||||
let sm = LpStateMachine::new(
|
||||
receiver_index,
|
||||
is_initiator,
|
||||
local_ed25519_keypair,
|
||||
remote_ed25519_key,
|
||||
salt,
|
||||
)?;
|
||||
|
||||
self.state_machines.insert(receiver_index, sm);
|
||||
Ok(receiver_index)
|
||||
}
|
||||
|
||||
/// 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 receiver_index: u32 = 1001;
|
||||
|
||||
let sm_1_id = manager
|
||||
.create_session_state_machine(
|
||||
receiver_index,
|
||||
(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 receiver_index: u32 = 2002;
|
||||
|
||||
let sm_1_id = manager
|
||||
.create_session_state_machine(
|
||||
receiver_index,
|
||||
(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(
|
||||
3001,
|
||||
(
|
||||
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(
|
||||
3002,
|
||||
(
|
||||
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(
|
||||
3003,
|
||||
(
|
||||
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 receiver_index: u32 = 4004;
|
||||
|
||||
let sm = manager.create_session_state_machine(
|
||||
receiver_index,
|
||||
(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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ repository = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
bytes = { workspace = true }
|
||||
cfg-if = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["codec"] }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -21,3 +22,7 @@ nym-sphinx-acknowledgements = { path = "../acknowledgements" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
[features]
|
||||
# When enabled, mix nodes skip ack extraction and forwarding
|
||||
no-mix-acks = []
|
||||
|
||||
@@ -14,7 +14,7 @@ use nym_sphinx_types::{
|
||||
};
|
||||
use std::fmt::Display;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, info, trace};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MixProcessingResultData {
|
||||
@@ -364,21 +364,33 @@ fn split_into_ack_and_message(
|
||||
| PacketSize::ExtendedPacket32
|
||||
| PacketSize::OutfoxRegularPacket => {
|
||||
trace!("received a normal packet!");
|
||||
let (ack_data, message) = split_hop_data_into_ack_and_message(data, packet_type)?;
|
||||
let (ack_first_hop, ack_packet) =
|
||||
match SurbAck::try_recover_first_hop_packet(&ack_data, packet_type) {
|
||||
Ok((first_hop, packet)) => (first_hop, packet),
|
||||
Err(err) => {
|
||||
info!("Failed to recover first hop from ack data: {err}");
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
let forward_ack = MixPacket::new(ack_first_hop, ack_packet, packet_type, key_rotation);
|
||||
Ok((Some(forward_ack), message))
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "no-mix-acks")] {
|
||||
let _ = packet_type;
|
||||
let _ = key_rotation;
|
||||
|
||||
// AIDEV-NOTE: When no-mix-acks is enabled, skip ack extraction entirely.
|
||||
// The full payload (including ack portion) is returned as the message.
|
||||
Ok((None, data))
|
||||
} else {
|
||||
let (ack_data, message) = split_hop_data_into_ack_and_message(data, packet_type)?;
|
||||
let (ack_first_hop, ack_packet) =
|
||||
match SurbAck::try_recover_first_hop_packet(&ack_data, packet_type) {
|
||||
Ok((first_hop, packet)) => (first_hop, packet),
|
||||
Err(err) => {
|
||||
tracing::info!("Failed to recover first hop from ack data: {err}");
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
let forward_ack = MixPacket::new(ack_first_hop, ack_packet, packet_type, key_rotation);
|
||||
Ok((Some(forward_ack), message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn split_hop_data_into_ack_and_message(
|
||||
mut extracted_data: Vec<u8>,
|
||||
packet_type: PacketType,
|
||||
|
||||
@@ -12,10 +12,16 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tokio-util.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
nym-authenticator-requests = { path = "../authenticator-requests" }
|
||||
nym-credentials-interface = { path = "../credentials-interface" }
|
||||
nym-crypto = { path = "../crypto" }
|
||||
nym-ip-packet-requests = { path = "../ip-packet-requests" }
|
||||
nym-sphinx = { path = "../nymsphinx" }
|
||||
nym-wireguard-types = { path = "../wireguard-types" }
|
||||
|
||||
[dev-dependencies]
|
||||
bincode.workspace = true
|
||||
time.workspace = true
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
mod lp_messages;
|
||||
|
||||
pub use lp_messages::{
|
||||
LpGatewayData, LpRegistrationRequest, LpRegistrationResponse, RegistrationMode,
|
||||
};
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
use nym_authenticator_requests::AuthenticatorVersion;
|
||||
use nym_crypto::asymmetric::x25519::{PublicKey, serde_helpers::bs58_x25519_pubkey};
|
||||
use nym_ip_packet_requests::IpPair;
|
||||
use nym_sphinx::addressing::{NodeIdentity, Recipient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct NymNode {
|
||||
@@ -15,6 +21,7 @@ pub struct NymNode {
|
||||
pub ip_address: IpAddr,
|
||||
pub ipr_address: Option<Recipient>,
|
||||
pub authenticator_address: Option<Recipient>,
|
||||
pub lp_address: Option<SocketAddr>,
|
||||
pub version: AuthenticatorVersion,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! LP (Lewes Protocol) registration message types shared between client and gateway.
|
||||
|
||||
use nym_credentials_interface::{CredentialSpendingData, TicketType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::GatewayData;
|
||||
|
||||
/// Registration request sent by client after LP handshake
|
||||
/// Aligned with existing authenticator registration flow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LpRegistrationRequest {
|
||||
/// Client's WireGuard public key (for dVPN mode)
|
||||
pub wg_public_key: nym_wireguard_types::PeerPublicKey,
|
||||
|
||||
/// Bandwidth credential for payment
|
||||
pub credential: CredentialSpendingData,
|
||||
|
||||
/// Ticket type for bandwidth allocation
|
||||
pub ticket_type: TicketType,
|
||||
|
||||
/// Registration mode
|
||||
pub mode: RegistrationMode,
|
||||
|
||||
/// Client's IP address (for tracking/metrics)
|
||||
pub client_ip: IpAddr,
|
||||
|
||||
/// Unix timestamp for replay protection
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum RegistrationMode {
|
||||
/// dVPN mode - register as WireGuard peer (most common)
|
||||
Dvpn,
|
||||
|
||||
/// Mixnet mode - register for mixnet routing via IPR
|
||||
///
|
||||
/// Client provides identity and encryption keys for nym address derivation.
|
||||
/// Gateway stores client in ActiveClientsStore for SURB reply delivery.
|
||||
Mixnet {
|
||||
/// Client's ed25519 public key (identity)
|
||||
///
|
||||
/// Used to derive DestinationAddressBytes for ActiveClientsStore lookup.
|
||||
/// Must match the key used in LP handshake for authentication.
|
||||
client_ed25519_pubkey: [u8; 32],
|
||||
|
||||
/// Client's x25519 public key (encryption)
|
||||
///
|
||||
/// Used for SURB reply encryption. Combined with ed25519 identity
|
||||
/// and gateway identity to form the full nym Recipient address.
|
||||
client_x25519_pubkey: [u8; 32],
|
||||
},
|
||||
}
|
||||
|
||||
/// Gateway data for mixnet mode registration
|
||||
///
|
||||
/// Contains the gateway's identity and sphinx key needed for the client
|
||||
/// to construct its full nym Recipient address.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LpGatewayData {
|
||||
/// Gateway's ed25519 identity public key
|
||||
///
|
||||
/// Forms part of the client's nym Recipient address.
|
||||
pub gateway_identity: [u8; 32],
|
||||
|
||||
/// Gateway's x25519 sphinx public key
|
||||
///
|
||||
/// Used by the client for Sphinx packet construction.
|
||||
pub gateway_sphinx_key: [u8; 32],
|
||||
}
|
||||
|
||||
/// Registration response from gateway
|
||||
/// Contains GatewayData for compatibility with existing client code
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LpRegistrationResponse {
|
||||
/// Whether registration succeeded
|
||||
pub success: bool,
|
||||
|
||||
/// Error message if registration failed
|
||||
pub error: Option<String>,
|
||||
|
||||
/// Gateway configuration data for dVPN mode (WireGuard)
|
||||
/// This matches what WireguardRegistrationResult expects
|
||||
pub gateway_data: Option<GatewayData>,
|
||||
|
||||
/// Gateway data for mixnet mode
|
||||
///
|
||||
/// Contains gateway identity and sphinx key needed for nym address construction.
|
||||
/// Only populated for Mixnet mode registrations.
|
||||
pub lp_gateway_data: Option<LpGatewayData>,
|
||||
|
||||
/// Allocated bandwidth in bytes
|
||||
pub allocated_bandwidth: i64,
|
||||
}
|
||||
|
||||
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 (for dVPN mode)
|
||||
pub fn success(allocated_bandwidth: i64, gateway_data: GatewayData) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
error: None,
|
||||
gateway_data: Some(gateway_data),
|
||||
lp_gateway_data: None,
|
||||
allocated_bandwidth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a success response for mixnet mode with LpGatewayData
|
||||
pub fn success_mixnet(allocated_bandwidth: i64, lp_gateway_data: LpGatewayData) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
error: None,
|
||||
gateway_data: None,
|
||||
lp_gateway_data: Some(lp_gateway_data),
|
||||
allocated_bandwidth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error response
|
||||
pub fn error(error: String) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error: Some(error),
|
||||
gateway_data: None,
|
||||
lp_gateway_data: None,
|
||||
allocated_bandwidth: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 allocated_bandwidth = 1_000_000_000;
|
||||
|
||||
let response = LpRegistrationResponse::success(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);
|
||||
|
||||
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 error_msg = String::from("Insufficient bandwidth");
|
||||
|
||||
let response = LpRegistrationResponse::error(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);
|
||||
}
|
||||
// ==================== 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_ed25519_pubkey = [99u8; 32];
|
||||
let client_x25519_pubkey = [88u8; 32];
|
||||
let mode = RegistrationMode::Mixnet {
|
||||
client_ed25519_pubkey,
|
||||
client_x25519_pubkey,
|
||||
};
|
||||
|
||||
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_ed25519_pubkey: ed25519,
|
||||
client_x25519_pubkey: x25519,
|
||||
} => {
|
||||
assert_eq!(ed25519, client_ed25519_pubkey);
|
||||
assert_eq!(x25519, client_x25519_pubkey);
|
||||
}
|
||||
_ => panic!("Expected Mixnet mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lp_registration_response_success_mixnet() {
|
||||
let lp_gateway_data = LpGatewayData {
|
||||
gateway_identity: [1u8; 32],
|
||||
gateway_sphinx_key: [2u8; 32],
|
||||
};
|
||||
let allocated_bandwidth = 500_000_000;
|
||||
|
||||
let response = LpRegistrationResponse::success_mixnet(allocated_bandwidth, lp_gateway_data);
|
||||
|
||||
assert!(response.success);
|
||||
assert!(response.error.is_none());
|
||||
assert!(response.gateway_data.is_none());
|
||||
assert!(response.lp_gateway_data.is_some());
|
||||
assert_eq!(response.allocated_bandwidth, allocated_bandwidth);
|
||||
|
||||
let gw_data = response
|
||||
.lp_gateway_data
|
||||
.expect("LpGatewayData should be present");
|
||||
assert_eq!(gw_data.gateway_identity, [1u8; 32]);
|
||||
assert_eq!(gw_data.gateway_sphinx_key, [2u8; 32]);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ anyhow = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
rand_chacha = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "time", "rt"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
nym-bin-common = { path = "../bin-common", features = ["tracing"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -8,6 +8,12 @@ use std::future::Future;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::error::Elapsed;
|
||||
|
||||
use nym_bin_common::logging::tracing_subscriber::EnvFilter;
|
||||
use nym_bin_common::logging::tracing_subscriber::layer::SubscriberExt;
|
||||
use nym_bin_common::logging::tracing_subscriber::util::SubscriberInitExt;
|
||||
use nym_bin_common::logging::{default_tracing_fmt_layer, tracing_subscriber};
|
||||
pub use rand_chacha::rand_core::{CryptoRng, RngCore};
|
||||
|
||||
pub fn leak<T>(val: T) -> &'static mut T {
|
||||
Box::leak(Box::new(val))
|
||||
}
|
||||
@@ -31,3 +37,16 @@ pub fn seeded_rng(seed: [u8; 32]) -> ChaCha20Rng {
|
||||
pub fn u64_seeded_rng(seed: u64) -> ChaCha20Rng {
|
||||
ChaCha20Rng::seed_from_u64(seed)
|
||||
}
|
||||
|
||||
// test logger to use during debugging
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub fn setup_test_logger() {
|
||||
tracing_subscriber::registry()
|
||||
.with(default_tracing_fmt_layer(std::io::stderr))
|
||||
.with(
|
||||
EnvFilter::new("trace"),
|
||||
// .add_directive("nym_sdk::client_pool=info".parse().unwrap())
|
||||
// .add_directive("nym_sdk::tcp_proxy_client=debug".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,32 @@
|
||||
|
||||
use crate::mocks::shared::InnerWrapper;
|
||||
use futures::ready;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tracing::trace;
|
||||
|
||||
const INIT_ID: &str = "initialiser";
|
||||
const RECV_ID: &str = "recipient";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Side {
|
||||
Initialiser,
|
||||
Recipient,
|
||||
}
|
||||
|
||||
impl Display for Side {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Side::Initialiser => INIT_ID.fmt(f),
|
||||
Side::Recipient => RECV_ID.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sending buffer of the first stream is the receiving buffer of the second stream
|
||||
// and vice versa
|
||||
@@ -17,8 +39,13 @@ pub fn mock_io_streams() -> (MockIOStream, MockIOStream) {
|
||||
(ch1, ch2)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockIOStream {
|
||||
// identifier to use for logging purposes
|
||||
id: Arc<AtomicU8>,
|
||||
|
||||
// side of the stream to use for logging purposes
|
||||
side: Side,
|
||||
|
||||
// messages to send
|
||||
tx: InnerWrapper<Vec<u8>>,
|
||||
|
||||
@@ -26,14 +53,41 @@ pub struct MockIOStream {
|
||||
rx: InnerWrapper<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockIOStream {
|
||||
fn make_connection(&self) -> Self {
|
||||
impl Default for MockIOStream {
|
||||
fn default() -> Self {
|
||||
MockIOStream {
|
||||
id: Arc::new(AtomicU8::new(0)),
|
||||
side: Side::Initialiser,
|
||||
tx: Default::default(),
|
||||
rx: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockIOStream {
|
||||
#[allow(clippy::panic)]
|
||||
fn make_connection(&self) -> Self {
|
||||
if self.side != Side::Initialiser {
|
||||
panic!("attempted to make invalid connection")
|
||||
}
|
||||
MockIOStream {
|
||||
id: self.id.clone(),
|
||||
side: Side::Recipient,
|
||||
tx: self.rx.cloned_buffer(),
|
||||
rx: self.tx.cloned_buffer(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_id(&self, id: u8) {
|
||||
self.id.store(id, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// the prefix `try_` is due to the fact that if the mock is cloned at an invalid state,
|
||||
// `assert!` will fail causing panic (which is fine in **test** code)
|
||||
pub fn try_get_remote_handle(&self) -> Self {
|
||||
self.make_connection()
|
||||
}
|
||||
|
||||
// unwrap in test code is fine
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub fn unchecked_tx_data(&self) -> Vec<u8> {
|
||||
@@ -45,6 +99,25 @@ impl MockIOStream {
|
||||
pub fn unchecked_rx_data(&self) -> Vec<u8> {
|
||||
self.rx.buffer.try_lock().unwrap().content.clone()
|
||||
}
|
||||
|
||||
fn log_read(&self, bytes: usize) {
|
||||
let id = self.id.load(Ordering::Relaxed);
|
||||
if id == 0 {
|
||||
trace!("[{}] read {bytes} bytes from mock stream", self.side)
|
||||
} else {
|
||||
trace!("[{}-{id}] read {bytes} bytes from mock stream", self.side)
|
||||
}
|
||||
}
|
||||
|
||||
fn log_write(&self, bytes: usize) {
|
||||
let id = self.id.load(Ordering::Relaxed);
|
||||
|
||||
if id == 0 {
|
||||
trace!("[{}] wrote {bytes} bytes to mock stream", self.side)
|
||||
} else {
|
||||
trace!("[{}-{id}] wrote {bytes} bytes to mock stream", self.side)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for MockIOStream {
|
||||
@@ -55,11 +128,13 @@ impl AsyncRead for MockIOStream {
|
||||
) -> Poll<io::Result<()>> {
|
||||
ready!(Pin::new(&mut self.rx).poll_guard_ready(cx));
|
||||
|
||||
let unfilled = buf.remaining();
|
||||
|
||||
// SAFETY: guard is ready
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let guard = self.rx.guard().unwrap();
|
||||
|
||||
let data = guard.take_content();
|
||||
let data = guard.take_at_most(unfilled);
|
||||
if data.is_empty() {
|
||||
// nothing to retrieve - store the waiter so that the sender could trigger it
|
||||
guard.waker = Some(cx.waker().clone());
|
||||
@@ -69,6 +144,7 @@ impl AsyncRead for MockIOStream {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
self.log_read(data.len());
|
||||
// if let Some(waker) = guard.waker.take() {
|
||||
// waker.wake();
|
||||
// }
|
||||
@@ -105,6 +181,8 @@ impl AsyncWrite for MockIOStream {
|
||||
// return Poll::Pending;
|
||||
// }
|
||||
|
||||
self.log_write(buf.len());
|
||||
|
||||
Poll::Ready(Ok(len))
|
||||
}
|
||||
|
||||
|
||||
@@ -106,4 +106,47 @@ impl<T> ContentWrapper<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentWrapper<Vec<u8>> {
|
||||
pub fn take_at_most(&mut self, count: usize) -> Vec<u8> {
|
||||
if self.content.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.content.len() <= count {
|
||||
return self.take_content();
|
||||
}
|
||||
let remaining = self.content.split_off(count);
|
||||
mem::replace(&mut self.content, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> LockState<T> {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn take_at_most() {
|
||||
let mut empty: ContentWrapper<Vec<u8>> = ContentWrapper::default();
|
||||
|
||||
let mut non_empty: ContentWrapper<Vec<u8>> = ContentWrapper {
|
||||
content: vec![1, 2, 3, 4, 5],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(empty.take_at_most(0), Vec::<u8>::new());
|
||||
assert_eq!(empty.take_at_most(1), Vec::<u8>::new());
|
||||
assert_eq!(empty.take_at_most(42), Vec::<u8>::new());
|
||||
|
||||
assert_eq!(non_empty.take_at_most(0), Vec::<u8>::new());
|
||||
assert_eq!(non_empty.take_at_most(1), vec![1]);
|
||||
assert_eq!(non_empty.take_at_most(3), vec![2, 3, 4]);
|
||||
assert_eq!(non_empty.take_at_most(42), vec![5]);
|
||||
|
||||
let mut non_empty: ContentWrapper<Vec<u8>> = ContentWrapper {
|
||||
content: vec![1, 2, 3, 4, 5],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(non_empty.take_at_most(100), vec![1, 2, 3, 4, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,8 @@ base64 = { workspace = true }
|
||||
defguard_wireguard_rs = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
ip_network = { workspace = true }
|
||||
ipnetwork = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util"] }
|
||||
tokio-stream = { workspace = true }
|
||||
@@ -25,6 +27,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" }
|
||||
|
||||
@@ -20,6 +20,9 @@ pub enum Error {
|
||||
|
||||
#[error("{0}")]
|
||||
SystemTime(#[from] std::time::SystemTimeError),
|
||||
|
||||
#[error("IP pool error: {0}")]
|
||||
IpPool(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<RwLock<HashMap<IpPair, AllocationState>>>,
|
||||
}
|
||||
|
||||
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<Self, IpPoolError> {
|
||||
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<Ipv4Addr> = ipv4_net
|
||||
.iter()
|
||||
.filter_map(|ip| {
|
||||
if let IpAddr::V4(v4) = ip {
|
||||
Some(v4)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ipv6_addrs: Vec<Ipv6Addr> = 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<IpPair, IpPoolError> {
|
||||
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),
|
||||
}
|
||||
+58
-12
@@ -8,24 +8,28 @@
|
||||
|
||||
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;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use nym_credential_verification::ecash::EcashManager;
|
||||
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 nym_wireguard_types::Config as WireguardConfig;
|
||||
pub use peer_controller::{PeerControlRequest, PeerRegistrationData};
|
||||
|
||||
pub const CONTROL_CHANNEL_SIZE: usize = 256;
|
||||
|
||||
@@ -125,13 +129,16 @@ impl Drop for WgApiWrapper {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WireguardGatewayData {
|
||||
config: Config,
|
||||
config: WireguardConfig,
|
||||
keypair: Arc<KeyPair>,
|
||||
peer_tx: Sender<PeerControlRequest>,
|
||||
}
|
||||
|
||||
impl WireguardGatewayData {
|
||||
pub fn new(config: Config, keypair: Arc<KeyPair>) -> (Self, Receiver<PeerControlRequest>) {
|
||||
pub fn new(
|
||||
config: WireguardConfig,
|
||||
keypair: Arc<KeyPair>,
|
||||
) -> (Self, Receiver<PeerControlRequest>) {
|
||||
let (peer_tx, peer_rx) = mpsc::channel(CONTROL_CHANNEL_SIZE);
|
||||
(
|
||||
WireguardGatewayData {
|
||||
@@ -143,7 +150,7 @@ impl WireguardGatewayData {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn config(&self) -> Config {
|
||||
pub fn config(&self) -> WireguardConfig {
|
||||
self.config
|
||||
}
|
||||
|
||||
@@ -159,29 +166,34 @@ impl WireguardGatewayData {
|
||||
pub struct WireguardData {
|
||||
pub inner: WireguardGatewayData,
|
||||
pub peer_rx: Receiver<PeerControlRequest>,
|
||||
pub use_userspace: bool,
|
||||
}
|
||||
|
||||
/// Start wireguard device
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn start_wireguard(
|
||||
ecash_manager: Arc<EcashManager>,
|
||||
ecash_manager: Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>,
|
||||
metrics: nym_node_metrics::NymNodeMetrics,
|
||||
peers: Vec<Peer>,
|
||||
upgrade_mode_status: nym_credential_verification::upgrade_mode::UpgradeModeStatus,
|
||||
shutdown_token: nym_task::ShutdownToken,
|
||||
wireguard_data: WireguardData,
|
||||
use_userspace: bool,
|
||||
) -> Result<std::sync::Arc<WgApiWrapper>, Box<dyn std::error::Error + Send + Sync + 'static>> {
|
||||
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,12 @@ 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)
|
||||
.inspect_err(|e| tracing::error!("Failed to configure WireGuard interface: {:?}", e))?;
|
||||
|
||||
info!("Adding IPv6 address to interface...");
|
||||
std::process::Command::new("ip")
|
||||
.args([
|
||||
"-6",
|
||||
@@ -226,7 +243,8 @@ pub async fn start_wireguard(
|
||||
"dev",
|
||||
(&ifname),
|
||||
])
|
||||
.output()?;
|
||||
.output()
|
||||
.inspect_err(|e| tracing::error!("Failed to add IPv6 address: {:?}", 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 +265,37 @@ 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(_)))
|
||||
&& 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,
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// fine in test/mock code
|
||||
#![allow(clippy::panic)]
|
||||
#![allow(clippy::expect_used)]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use crate::PeerControlRequest;
|
||||
use futures::channel::oneshot;
|
||||
use nym_crypto::asymmetric::x25519;
|
||||
use std::any::Any;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fmt::Debug;
|
||||
use std::net::IpAddr;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
|
||||
pub use defguard_wireguard_rs::key::Key;
|
||||
|
||||
pub fn mock_peer_controller(
|
||||
request_rx: Receiver<PeerControlRequest>,
|
||||
) -> (MockPeerController, MockPeerControllerState) {
|
||||
let state = MockPeerControllerState::default();
|
||||
|
||||
(
|
||||
MockPeerController {
|
||||
state: state.clone(),
|
||||
request_rx,
|
||||
},
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
// we need `PartialOrd` for being able to store registered responses in the map
|
||||
// (even though it's not technically the "correct" implementation, for the purposes
|
||||
// of tests/mocks it's sufficient)
|
||||
#[derive(Hash, PartialEq, Clone, Debug, Eq)]
|
||||
pub struct KeyWrapper(Key);
|
||||
|
||||
impl PartialOrd for KeyWrapper {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.0.as_slice().partial_cmp(other.0.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for KeyWrapper {
|
||||
type Target = Key;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Key> for KeyWrapper {
|
||||
fn from(k: Key) -> Self {
|
||||
KeyWrapper(k)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Key> for KeyWrapper {
|
||||
fn from(k: &Key) -> Self {
|
||||
KeyWrapper(k.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hash, PartialOrd, PartialEq, Clone, Debug, Eq)]
|
||||
pub enum PeerControlRequestType {
|
||||
AddPeer { public_key: KeyWrapper },
|
||||
RegisterPeer { public_key: KeyWrapper },
|
||||
RemovePeer { key: KeyWrapper },
|
||||
QueryPeer { key: KeyWrapper },
|
||||
GetClientBandwidthByKey { key: KeyWrapper },
|
||||
GetClientBandwidthByIp { ip: IpAddr },
|
||||
GetVerifierByKey { key: KeyWrapper },
|
||||
GetVerifierByIp { ip: IpAddr },
|
||||
}
|
||||
|
||||
impl PeerControlRequestType {
|
||||
pub fn peer_key(&self) -> Option<KeyWrapper> {
|
||||
match self {
|
||||
PeerControlRequestType::AddPeer { public_key } => Some(public_key.clone()),
|
||||
PeerControlRequestType::RegisterPeer { public_key } => Some(public_key.clone()),
|
||||
PeerControlRequestType::RemovePeer { key } => Some(key.clone()),
|
||||
PeerControlRequestType::QueryPeer { key } => Some(key.clone()),
|
||||
PeerControlRequestType::GetClientBandwidthByKey { key } => Some(key.clone()),
|
||||
PeerControlRequestType::GetClientBandwidthByIp { .. } => None,
|
||||
PeerControlRequestType::GetVerifierByKey { key } => Some(key.clone()),
|
||||
PeerControlRequestType::GetVerifierByIp { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peer_key_unchecked(&self) -> KeyWrapper {
|
||||
self.peer_key().expect("this request does not use peer key")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PeerControlRequest> for PeerControlRequestType {
|
||||
fn from(req: &PeerControlRequest) -> Self {
|
||||
match req {
|
||||
PeerControlRequest::AddPeer { peer, .. } => PeerControlRequestType::AddPeer {
|
||||
public_key: (&peer.public_key).into(),
|
||||
},
|
||||
PeerControlRequest::RegisterPeer {
|
||||
registration_data, ..
|
||||
} => PeerControlRequestType::RegisterPeer {
|
||||
public_key: (®istration_data.public_key).into(),
|
||||
},
|
||||
PeerControlRequest::RemovePeer { key, .. } => {
|
||||
PeerControlRequestType::RemovePeer { key: key.into() }
|
||||
}
|
||||
PeerControlRequest::QueryPeer { key, .. } => {
|
||||
PeerControlRequestType::QueryPeer { key: key.into() }
|
||||
}
|
||||
PeerControlRequest::GetClientBandwidthByKey { key, .. } => {
|
||||
PeerControlRequestType::GetClientBandwidthByKey { key: key.into() }
|
||||
}
|
||||
PeerControlRequest::GetClientBandwidthByIp { ip, .. } => {
|
||||
PeerControlRequestType::GetClientBandwidthByIp { ip: *ip }
|
||||
}
|
||||
PeerControlRequest::GetVerifierByKey { key, .. } => {
|
||||
PeerControlRequestType::GetVerifierByKey { key: key.into() }
|
||||
}
|
||||
PeerControlRequest::GetVerifierByIp { ip, .. } => {
|
||||
PeerControlRequestType::GetVerifierByIp { ip: *ip }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RegisteredResponse {
|
||||
// need an additional flag to trigger internal state updates for checking test invariants
|
||||
pub success: bool,
|
||||
pub content: Box<dyn Any + Send + Sync + 'static>,
|
||||
}
|
||||
|
||||
impl<T, E> From<Result<T, E>> for RegisteredResponse
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
fn from(r: Result<T, E>) -> Self {
|
||||
let success = r.is_ok();
|
||||
|
||||
RegisteredResponse {
|
||||
success,
|
||||
content: Box::new(r),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// all responses are registered as a queue for particular type
|
||||
// (this is because the actual type can't be cloned as the `Error` does not implement Clone)
|
||||
type RegisteredResponses = HashMap<PeerControlRequestType, VecDeque<RegisteredResponse>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MockPeerControllerState {
|
||||
pub(crate) registered_responses: Arc<RwLock<RegisteredResponses>>,
|
||||
|
||||
// additional state for inspecting during testing
|
||||
pub peers: Arc<RwLock<PeersState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PeerState {
|
||||
/// Has IpPair been allocated to the peer?
|
||||
pub register_success: bool,
|
||||
|
||||
// in the future maybe we could extend it with `ClientBandwidth` information
|
||||
/// Has the client handle been spawned
|
||||
pub add_success: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PeersState {
|
||||
pub peers: HashMap<KeyWrapper, PeerState>,
|
||||
}
|
||||
|
||||
impl PeersState {
|
||||
pub fn get_by_x25519_key(&self, key: &x25519::PublicKey) -> Option<&PeerState> {
|
||||
let key = KeyWrapper::from(Key::new(key.to_bytes()));
|
||||
self.peers.get(&key)
|
||||
}
|
||||
}
|
||||
|
||||
impl MockPeerControllerState {
|
||||
pub async fn register_response(
|
||||
&self,
|
||||
request: PeerControlRequestType,
|
||||
response: impl Into<RegisteredResponse>,
|
||||
) {
|
||||
self.registered_responses
|
||||
.write()
|
||||
.await
|
||||
.entry(request)
|
||||
.or_default()
|
||||
.push_back(response.into());
|
||||
}
|
||||
|
||||
pub async fn clear_registered_responses(&self) {
|
||||
self.registered_responses.write().await.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// just a helper trait to help with the duplicate code and the associated noise
|
||||
trait SendDowncasted {
|
||||
fn send_downcasted(self, response: Box<dyn Any + Send + Sync>);
|
||||
}
|
||||
|
||||
impl<T> SendDowncasted for oneshot::Sender<T>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
fn send_downcasted(self, response: Box<dyn Any + Send + Sync>) {
|
||||
if self
|
||||
.send(
|
||||
*response
|
||||
.downcast()
|
||||
.expect("registered response has mismatched type"),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
panic!("attempted to send response on closed channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockPeerController {
|
||||
state: MockPeerControllerState,
|
||||
request_rx: Receiver<PeerControlRequest>,
|
||||
}
|
||||
|
||||
impl MockPeerController {
|
||||
pub fn new(state: MockPeerControllerState, request_rx: Receiver<PeerControlRequest>) -> Self {
|
||||
MockPeerController { state, request_rx }
|
||||
}
|
||||
|
||||
async fn handle_request(&mut self, request: PeerControlRequest) {
|
||||
let mut res_guard = self.state.registered_responses.write().await;
|
||||
let mut peers_guard = self.state.peers.write().await;
|
||||
|
||||
let typ = PeerControlRequestType::from(&request);
|
||||
|
||||
let Some(registered_responses) = res_guard.get_mut(&typ) else {
|
||||
panic!(
|
||||
"received a request for {typ:?} but there are no registered responses - this is probably due to a bug in your test setup"
|
||||
);
|
||||
};
|
||||
|
||||
let Some(response) = registered_responses.pop_front() else {
|
||||
panic!(
|
||||
"received a request for {typ:?} but there are no registered responses - this is probably due to a bug in your test setup"
|
||||
);
|
||||
};
|
||||
|
||||
match request {
|
||||
PeerControlRequest::AddPeer { response_tx, .. } => {
|
||||
let key = typ.peer_key_unchecked();
|
||||
let peer = peers_guard.peers.entry(key).or_default();
|
||||
if response.success {
|
||||
peer.add_success = true;
|
||||
}
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::RegisterPeer { response_tx, .. } => {
|
||||
let key = typ.peer_key_unchecked();
|
||||
let peer = peers_guard.peers.entry(key).or_default();
|
||||
if response.success {
|
||||
peer.register_success = true;
|
||||
}
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::RemovePeer { response_tx, .. } => {
|
||||
let key = typ.peer_key_unchecked();
|
||||
if response.success {
|
||||
peers_guard.peers.remove(&key);
|
||||
}
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::QueryPeer { response_tx, .. } => {
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::GetClientBandwidthByKey { response_tx, .. } => {
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::GetClientBandwidthByIp { response_tx, .. } => {
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::GetVerifierByKey { response_tx, .. } => {
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
PeerControlRequest::GetVerifierByIp { response_tx, .. } => {
|
||||
response_tx.send_downcasted(response.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) {
|
||||
while let Some(request) = self.request_rx.recv().await {
|
||||
self.handle_request(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
-6
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::{
|
||||
IpPool,
|
||||
error::{Error, Result},
|
||||
peer_handle::SharedBandwidthStorageManager,
|
||||
};
|
||||
@@ -21,21 +22,69 @@ use nym_credentials_interface::CredentialSpendingData;
|
||||
use nym_gateway_requests::models::CredentialSpendingRequest;
|
||||
use nym_gateway_storage::traits::BandwidthGatewayStorage;
|
||||
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};
|
||||
|
||||
pub use nym_ip_packet_requests::IpPair;
|
||||
|
||||
#[cfg(feature = "mock")]
|
||||
pub mod mock;
|
||||
|
||||
/// Registration data for a new peer (without pre-allocated IPs)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerRegistrationData {
|
||||
pub public_key: Key,
|
||||
pub preshared_key: Option<Key>,
|
||||
pub endpoint: Option<SocketAddr>,
|
||||
pub persistent_keepalive_interval: Option<u16>,
|
||||
}
|
||||
|
||||
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<AddPeerControlResponse>,
|
||||
},
|
||||
/// Register a new peer and allocate IPs from the pool
|
||||
RegisterPeer {
|
||||
registration_data: PeerRegistrationData,
|
||||
response_tx: oneshot::Sender<RegisterPeerControlResponse>,
|
||||
},
|
||||
RemovePeer {
|
||||
key: Key,
|
||||
response_tx: oneshot::Sender<RemovePeerControlResponse>,
|
||||
@@ -65,6 +114,7 @@ pub enum PeerControlRequest {
|
||||
}
|
||||
|
||||
pub type AddPeerControlResponse = Result<()>;
|
||||
pub type RegisterPeerControlResponse = Result<IpPair>;
|
||||
pub type RemovePeerControlResponse = Result<()>;
|
||||
pub type QueryPeerControlResponse = Result<Option<Peer>>;
|
||||
pub type GetClientBandwidthControlResponse = Result<ClientBandwidth>;
|
||||
@@ -77,6 +127,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<PeerControlRequest>,
|
||||
request_rx: mpsc::Receiver<PeerControlRequest>,
|
||||
@@ -84,6 +137,7 @@ pub struct PeerController {
|
||||
host_information: Arc<RwLock<Host>>,
|
||||
bw_storage_managers: HashMap<Key, SharedBandwidthStorageManager>,
|
||||
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 +150,7 @@ impl PeerController {
|
||||
pub(crate) fn new(
|
||||
ecash_verifier: Arc<dyn EcashManager + Send + Sync>,
|
||||
metrics: NymNodeMetrics,
|
||||
ip_pool: IpPool,
|
||||
wg_api: Arc<dyn WireguardInterfaceApi + Send + Sync>,
|
||||
initial_host_information: Host,
|
||||
bw_storage_managers: HashMap<Key, (SharedBandwidthStorageManager, Peer)>,
|
||||
@@ -106,6 +161,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 +188,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 +213,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 +248,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 +285,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<IpPair> {
|
||||
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<Option<Key>> {
|
||||
Ok(self
|
||||
.bw_storage_managers
|
||||
@@ -393,6 +490,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);
|
||||
info!("Cleaned up {} stale IP allocations", freed);
|
||||
}
|
||||
}
|
||||
_ = self.shutdown_token.cancelled() => {
|
||||
trace!("PeerController handler: Received shutdown");
|
||||
break;
|
||||
@@ -402,6 +507,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 +636,7 @@ pub fn start_controller(
|
||||
Arc<RwLock<nym_gateway_storage::traits::mock::MockGatewayStorage>>,
|
||||
nym_task::ShutdownManager,
|
||||
) {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Arc;
|
||||
|
||||
let storage = Arc::new(RwLock::new(
|
||||
@@ -537,10 +646,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 +683,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::*;
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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 \
|
||||
iptables \
|
||||
&& 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"]
|
||||
@@ -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/9001 → Gateway entry ports
|
||||
- 10001-10005 → Mixnet ports
|
||||
- 20001-20005 → Verloc ports
|
||||
- 30001-30005 → HTTP APIs
|
||||
- 41264/41265 → LP control ports (registration)
|
||||
- 51822/51823 → WireGuard tunnel ports (gateway/gateway2)
|
||||
|
||||
### Startup Flow
|
||||
|
||||
1. **Container Initialization** (parallel)
|
||||
- Each container starts and gets a dynamic IP
|
||||
- Each node runs `nym-node run --init-only` with its container IP
|
||||
- Bonding JSON files are written to shared volume
|
||||
|
||||
2. **Topology Generation** (sequential)
|
||||
- Wait for all 4 bonding JSON files
|
||||
- Get container IPs dynamically
|
||||
- Run `build_topology.py` with container IPs
|
||||
- Generate `network.json` with correct addresses
|
||||
|
||||
3. **Node Startup** (parallel)
|
||||
- Each container starts its node with `--local` flag
|
||||
- Nodes read configuration from init phase
|
||||
- Clients use custom topology file
|
||||
|
||||
4. **Service Providers** (sequential)
|
||||
- Network requester initializes and starts
|
||||
- SOCKS5 client initializes with requester address
|
||||
|
||||
### Network Topology
|
||||
|
||||
The `network.json` file contains the complete network topology:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"key_rotation_id": 0,
|
||||
"absolute_epoch_id": 0,
|
||||
"refreshed_at": "2025-11-03T..."
|
||||
},
|
||||
"rewarded_set": {
|
||||
"epoch_id": 0,
|
||||
"entry_gateways": [4],
|
||||
"exit_gateways": [4],
|
||||
"layer1": [1],
|
||||
"layer2": [2],
|
||||
"layer3": [3],
|
||||
"standby": []
|
||||
},
|
||||
"node_details": {
|
||||
"1": { "mix_host": "192.168.66.3:10001", ... },
|
||||
"2": { "mix_host": "192.168.66.4:10002", ... },
|
||||
"3": { "mix_host": "192.168.66.5:10003", ... },
|
||||
"4": { "mix_host": "192.168.66.6:10004", ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Build
|
||||
```bash
|
||||
./localnet.sh build
|
||||
```
|
||||
Builds the Docker image and loads it into Apple container runtime.
|
||||
|
||||
**Note**: First build takes ~5-10 minutes to compile all components.
|
||||
|
||||
### Start
|
||||
```bash
|
||||
./localnet.sh start
|
||||
```
|
||||
Starts all containers, generates topology, and launches the complete network.
|
||||
|
||||
**Expected output**:
|
||||
```
|
||||
[INFO] Starting Nym Localnet...
|
||||
[SUCCESS] Network created: nym-localnet-network
|
||||
[INFO] Starting nym-mixnode1...
|
||||
[SUCCESS] nym-mixnode1 started
|
||||
...
|
||||
[INFO] Building network topology with container IPs...
|
||||
[SUCCESS] Network topology created successfully
|
||||
[SUCCESS] Nym Localnet is running!
|
||||
|
||||
Test with:
|
||||
curl -x socks5h://127.0.0.1:1080 https://nymtech.net
|
||||
```
|
||||
|
||||
### Stop
|
||||
```bash
|
||||
./localnet.sh stop
|
||||
```
|
||||
Stops and removes all running containers.
|
||||
|
||||
### Clean
|
||||
```bash
|
||||
./localnet.sh clean
|
||||
```
|
||||
Complete cleanup: removes containers, volumes, network, and temporary files.
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
# View logs for a specific container
|
||||
./localnet.sh logs <container-name>
|
||||
|
||||
# Container names:
|
||||
# - mix1, mix2, mix3
|
||||
# - gateway
|
||||
# - requester
|
||||
# - socks5
|
||||
|
||||
# Examples:
|
||||
./localnet.sh logs gateway
|
||||
./localnet.sh logs socks5
|
||||
container logs nym-gateway --follow
|
||||
```
|
||||
|
||||
### Status
|
||||
```bash
|
||||
# List all containers
|
||||
container list
|
||||
|
||||
# Check specific container
|
||||
container logs nym-gateway
|
||||
|
||||
# Inspect network
|
||||
container network inspect nym-localnet-network
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Basic SOCKS5 Test
|
||||
```bash
|
||||
# Simple HTTP request with redirect following
|
||||
curl -L --socks5 localhost:1080 http://example.com
|
||||
|
||||
# HTTPS request
|
||||
curl -L --socks5 localhost:1080 https://nymtech.net
|
||||
|
||||
# Download a file
|
||||
curl -L --socks5 localhost:1080 \
|
||||
https://test-download-files-nym.s3.amazonaws.com/download-files/1MB.zip \
|
||||
--output /tmp/test.zip
|
||||
```
|
||||
|
||||
### Verify Network Topology
|
||||
```bash
|
||||
# View the generated topology
|
||||
container exec nym-gateway cat /localnet/network.json | jq .
|
||||
|
||||
# Check container IPs
|
||||
container list | grep nym-
|
||||
|
||||
# Verify all bonding files exist
|
||||
container exec nym-gateway ls -la /localnet/
|
||||
```
|
||||
|
||||
### Test Mixnet Routing
|
||||
```bash
|
||||
# All traffic flows through: client → mix1 → mix2 → mix3 → gateway → internet
|
||||
# Watch logs to verify routing:
|
||||
container logs nym-mixnode1 --follow &
|
||||
container logs nym-mixnode2 --follow &
|
||||
container logs nym-mixnode3 --follow &
|
||||
container logs nym-gateway --follow &
|
||||
|
||||
# Make a request
|
||||
curl -L --socks5 localhost:1080 https://nymtech.com
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,290 @@
|
||||
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, gateway_name="gateway"):
|
||||
"""Create a node_details entry for a gateway"""
|
||||
debug(f"\n=== Creating {gateway_name} entry ===")
|
||||
gateway_file = Path(base_dir) / f"{gateway_name}.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_name, suffix)
|
||||
|
||||
# Get identity key from bonding JSON (already byte array)
|
||||
identity = gateway_data.get("identity_key")
|
||||
if not identity:
|
||||
raise RuntimeError(f"Missing identity_key in {gateway_name}.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(f"Missing sphinx_key from node-details for {gateway_name}")
|
||||
|
||||
host = host_ip
|
||||
mix_port = 10000 + port_delta
|
||||
# Calculate clients_port: gateway uses 9000, gateway2 uses 9001, etc.
|
||||
clients_port = 9000 + (port_delta - 4)
|
||||
debug(f" Using host: {host} (mix:{mix_port}, clients:{clients_port})")
|
||||
|
||||
entry = {
|
||||
"node_id": node_id,
|
||||
"mix_host": f"{host}:{mix_port}",
|
||||
"entry": {
|
||||
"ip_addresses": [host],
|
||||
"clients_ws_port": clients_port,
|
||||
"hostname": None,
|
||||
"clients_wss_port": None
|
||||
},
|
||||
"identity_key": identity,
|
||||
"sphinx_key": sphinx_key,
|
||||
"supported_roles": {
|
||||
"mixnode": False,
|
||||
"mixnet_entry": True,
|
||||
"mixnet_exit": True
|
||||
}
|
||||
}
|
||||
|
||||
maybe_assign(entry, "version", node_details.get("version") or gateway_data.get("version"))
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def main(args):
|
||||
if not args:
|
||||
raise SystemExit("Usage: build_topology.py <output_dir> [node_suffix] [mix1_ip] [mix2_ip] [mix3_ip] [gateway_ip] [gateway2_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"
|
||||
gateway2_ip = args[6] if len(args) > 6 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}, gateway2={gateway2_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, "gateway"),
|
||||
5: create_gateway_entry(base_dir, 5, 5, suffix, gateway2_ip, "gateway2")
|
||||
}
|
||||
|
||||
# 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, 5],
|
||||
"exit_gateways": [4, 5],
|
||||
"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" - 2 gateways (entry + exit)")
|
||||
debug(f"\n=== Topology generation complete ===\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Tmux-based log viewer for Nym Localnet containers
|
||||
# Shows all container logs in a multi-pane layout
|
||||
|
||||
SESSION_NAME="nym-localnet-logs"
|
||||
|
||||
# Container names
|
||||
CONTAINERS=(
|
||||
"nym-mixnode1"
|
||||
"nym-mixnode2"
|
||||
"nym-mixnode3"
|
||||
"nym-gateway"
|
||||
"nym-network-requester"
|
||||
"nym-socks5-client"
|
||||
)
|
||||
|
||||
# Check if containers are running
|
||||
running_containers=()
|
||||
for container in "${CONTAINERS[@]}"; do
|
||||
if container inspect "$container" &>/dev/null; then
|
||||
running_containers+=("$container")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#running_containers[@]} -eq 0 ]; then
|
||||
echo "Error: No containers are running"
|
||||
echo "Start the localnet first: ./localnet.sh start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're already in tmux
|
||||
if [ -n "$TMUX" ]; then
|
||||
# Inside tmux - create new window
|
||||
tmux new-window -n "logs" "container logs -f ${running_containers[0]}"
|
||||
|
||||
# Split for remaining containers
|
||||
for ((i=1; i<${#running_containers[@]}; i++)); do
|
||||
tmux split-window -t logs "container logs -f ${running_containers[$i]}"
|
||||
tmux select-layout -t logs tiled
|
||||
done
|
||||
|
||||
tmux select-layout -t logs tiled
|
||||
else
|
||||
# Not in tmux - check if session exists
|
||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
# Session exists - attach to it
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
else
|
||||
# Create new session
|
||||
tmux new-session -d -s "$SESSION_NAME" -n "logs" "container logs -f ${running_containers[0]}"
|
||||
|
||||
# Split for remaining containers
|
||||
for ((i=1; i<${#running_containers[@]}; i++)); do
|
||||
tmux split-window -t "$SESSION_NAME:logs" "container logs -f ${running_containers[$i]}"
|
||||
tmux select-layout -t "$SESSION_NAME:logs" tiled
|
||||
done
|
||||
|
||||
tmux select-layout -t "$SESSION_NAME:logs" tiled
|
||||
|
||||
# Attach to the session
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
fi
|
||||
fi
|
||||
Executable
+708
@@ -0,0 +1,708 @@
|
||||
#!/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"
|
||||
GATEWAY2_CONTAINER="nym-gateway2"
|
||||
REQUESTER_CONTAINER="nym-network-requester"
|
||||
SOCKS5_CONTAINER="nym-socks5-client"
|
||||
|
||||
ALL_CONTAINERS=(
|
||||
"$MIXNODE1_CONTAINER"
|
||||
"$MIXNODE2_CONTAINER"
|
||||
"$MIXNODE3_CONTAINER"
|
||||
"$GATEWAY_CONTAINER"
|
||||
"$GATEWAY2_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 gateway2; 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 \
|
||||
-p 51822:51822/udp \
|
||||
-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 gateway2
|
||||
start_gateway2() {
|
||||
log_info "Starting $GATEWAY2_CONTAINER..."
|
||||
|
||||
container run \
|
||||
--name "$GATEWAY2_CONTAINER" \
|
||||
-m 2G \
|
||||
--network "$NETWORK_NAME" \
|
||||
-p 9001:9001 \
|
||||
-p 10005:10005 \
|
||||
-p 20005:20005 \
|
||||
-p 30005:30005 \
|
||||
-p 41265:41265 \
|
||||
-p 51265:51265 \
|
||||
-p 51823:51822/udp \
|
||||
-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 gateway2...";
|
||||
nym-node run --id gateway2-localnet --init-only \
|
||||
--unsafe-disable-replay-protection \
|
||||
--local \
|
||||
--mode entry-gateway \
|
||||
--mode exit-gateway \
|
||||
--mixnet-bind-address=0.0.0.0:10005 \
|
||||
--entry-bind-address=0.0.0.0:9001 \
|
||||
--verloc-bind-address=0.0.0.0:20005 \
|
||||
--http-bind-address=0.0.0.0:30005 \
|
||||
--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/gateway2.json";
|
||||
|
||||
echo "Waiting for network.json...";
|
||||
while [ ! -f /localnet/network.json ]; do
|
||||
sleep 2;
|
||||
done;
|
||||
echo "Starting gateway2 with LP listener (mock ecash)...";
|
||||
exec nym-node run --id gateway2-localnet --unsafe-disable-replay-protection --local --wireguard-enabled true --wireguard-userspace true --lp-use-mock-ecash true
|
||||
'
|
||||
|
||||
log_success "$GATEWAY2_CONTAINER started"
|
||||
|
||||
# Wait for gateway2 to be ready
|
||||
log_info "Waiting for gateway2 to listen on port 9001..."
|
||||
local retries=0
|
||||
local max_retries=30
|
||||
while ! nc -z 127.0.0.1 9001 2>/dev/null; do
|
||||
sleep 2
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge $max_retries ]; then
|
||||
log_error "Gateway2 failed to start on port 9001"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
log_success "Gateway2 is ready on port 9001"
|
||||
}
|
||||
|
||||
# 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 gateway2.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)
|
||||
GATEWAY2_IP=$(container exec "$GATEWAY2_CONTAINER" hostname -i)
|
||||
|
||||
log_info "Container IPs:"
|
||||
echo " mix1: $MIX1_IP"
|
||||
echo " mix2: $MIX2_IP"
|
||||
echo " mix3: $MIX3_IP"
|
||||
echo " gateway: $GATEWAY_IP"
|
||||
echo " gateway2: $GATEWAY2_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" \
|
||||
"$GATEWAY2_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
|
||||
start_gateway2
|
||||
build_topology
|
||||
|
||||
# Configure networking for two-hop WireGuard routing on both gateways
|
||||
# Note: Runs after build_topology to ensure gateways have finished WireGuard setup
|
||||
log_info "Configuring gateway networking (IP forwarding, NAT)..."
|
||||
for gw in "$GATEWAY_CONTAINER" "$GATEWAY2_CONTAINER"; do
|
||||
container exec "$gw" sh -c "
|
||||
# Enable IP forwarding
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
# Add NAT masquerade for outbound traffic
|
||||
iptables-legacy -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
"
|
||||
log_success "Configured $gw"
|
||||
done
|
||||
|
||||
start_network_requester
|
||||
start_socks5_client
|
||||
|
||||
echo ""
|
||||
log_success "Nym Localnet is running!"
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " curl -x socks5h://127.0.0.1:1080 https://nymtech.net"
|
||||
echo ""
|
||||
echo "View logs:"
|
||||
echo " $0 logs # All containers in tmux"
|
||||
echo " $0 logs gateway # Single container"
|
||||
echo ""
|
||||
echo "Stop:"
|
||||
echo " $0 down"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main command handler
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
local command=${1:-help}
|
||||
shift || true
|
||||
|
||||
case "$command" in
|
||||
build)
|
||||
build_image
|
||||
;;
|
||||
up)
|
||||
build_image
|
||||
start_all
|
||||
;;
|
||||
start)
|
||||
start_all
|
||||
;;
|
||||
down|stop)
|
||||
stop_containers
|
||||
remove_volume
|
||||
;;
|
||||
restart)
|
||||
stop_containers
|
||||
start_all
|
||||
;;
|
||||
logs)
|
||||
show_logs "$@"
|
||||
;;
|
||||
status|ps)
|
||||
show_status
|
||||
;;
|
||||
help|--help|-h)
|
||||
cat <<EOF
|
||||
Nym Localnet Orchestration Script
|
||||
|
||||
Usage: $0 <command> [options]
|
||||
|
||||
Commands:
|
||||
build Build the localnet image
|
||||
up Build image and start all services
|
||||
start Start all services (requires built image)
|
||||
down, stop Stop all services and clean up
|
||||
restart Restart all services
|
||||
logs [name] Show logs (no args = tmux overlay, with name = single container)
|
||||
status, ps Show status of all containers and ports
|
||||
help Show this help message
|
||||
|
||||
Examples:
|
||||
$0 up # Build and start everything
|
||||
$0 logs # View all logs in tmux overlay
|
||||
$0 logs gateway # View gateway logs only
|
||||
$0 status # Check what's running
|
||||
$0 down # Stop and clean up
|
||||
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown command: $command"
|
||||
echo "Run '$0 help' for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -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::<u64>() * 2 + // next + receive_cnt = 16 bytes
|
||||
size_of::<u64>() * 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<String>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**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<VerifiedCredential, CredentialError> {
|
||||
// 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<u8>, // 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<u8>),
|
||||
EncryptedData(Vec<u8>),
|
||||
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
|
||||
@@ -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/<id>/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/<id>/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/<id>/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)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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/<id>/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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user