Compare commits

..

1 Commits

Author SHA1 Message Date
Simon Wicky 60731ad2d4 test 2026-01-08 10:07:22 +01:00
230 changed files with 1780 additions and 41269 deletions
-3
View File
@@ -1,5 +1,2 @@
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
+1 -1
View File
@@ -25,7 +25,7 @@ Steps to reproduce the behaviour, if you're familiar with BDD syntax, please wri
*An example:*
- Given I was setting up a mix-node following the instructions in the docs
- And I successfully bonded my node via the wallet
- And I successfully bonded my node via the the wallet
- When I went to start my mixnode
- Then I was presented with an error
+1 -11
View File
@@ -64,14 +64,4 @@ nym-api/redocly/formatted-openapi.json
**/settings.sql
**/enter_db.sh
*.profraw
.beads
CLAUDE.md
docs
.claude
.superego
# Superego (machine-specific paths)
.superego/
.claude/hooks/superego/
.claude/settings.json
*.profraw
-56
View File
@@ -4,62 +4,6 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.1-niolo] (2026-01-13)
- bugfix: mozzarella -> niolo config migration ([#6259])
- chore: remove run DKG migration ([#6253])
- bugfix: reexposed 'derive_extended_private_key' ([#6247])
- Bump js-yaml from 3.14.1 to 3.14.2 in /sdk/typescript/codegen/contract-clients ([#6231])
- Statistics API v2 ([#6227])
- Bump golang.org/x/crypto from 0.39.0 to 0.45.0 in /nym-gateway-probe/netstack_ping ([#6220])
- Update chain registry link ([#6219])
- Bump glob from 10.3.4 to 10.5.0 in /documentation/scripts/post-process ([#6216])
- Bump js-yaml from 4.1.0 to 4.1.1 in /sdk/typescript/tests/integration-tests/mix-fetch ([#6215])
- gateway-probe fixes for run-local ([#6212])
- chore: updated default endpoint for retrieving attestation.json ([#6207])
- chore: remove support for legacy mixnode within the performance contract ([#6205])
- feat: upgrade mode: VPN adjustments ([#6189])
- Bump min-document from 2.19.0 to 2.19.1 ([#6181])
- Bump next from 15.4.1 to 15.4.7 in /nym-node-status-api/nym-node-status-ui ([#6180])
- feat: merge intermediate upgrade mode changes ([#6174])
- Add weighted scoring to NS API ([#6144])
- build(deps): bump mikefarah/yq from 4.47.1 to 4.48.1 ([#6107])
- build(deps): bump SonarSource/sonarqube-scan-action from 5 to 6 in /.github/workflows ([#6068])
- build(deps): bump tar-fs from 3.0.9 to 3.1.1 in /sdk/typescript/tests/integration-tests/mix-fetch ([#6063])
- build(deps): bump ammonia from 4.1.1 to 4.1.2 ([#6057])
- build(deps): bump tower-http from 0.5.2 to 0.6.6 ([#6030])
- build(deps): bump actions/setup-go from 5 to 6 ([#6013])
- build(deps): bump next from 14.2.28 to 14.2.32 ([#5996])
- build(deps): bump tracing-subscriber from 0.3.19 to 0.3.20 ([#5993])
- build(deps): bump actions/upload-pages-artifact from 3 to 4 ([#5992])
[#6259]: https://github.com/nymtech/nym/pull/6259
[#6253]: https://github.com/nymtech/nym/pull/6253
[#6247]: https://github.com/nymtech/nym/pull/6247
[#6231]: https://github.com/nymtech/nym/pull/6231
[#6227]: https://github.com/nymtech/nym/pull/6227
[#6220]: https://github.com/nymtech/nym/pull/6220
[#6219]: https://github.com/nymtech/nym/pull/6219
[#6216]: https://github.com/nymtech/nym/pull/6216
[#6215]: https://github.com/nymtech/nym/pull/6215
[#6212]: https://github.com/nymtech/nym/pull/6212
[#6207]: https://github.com/nymtech/nym/pull/6207
[#6205]: https://github.com/nymtech/nym/pull/6205
[#6189]: https://github.com/nymtech/nym/pull/6189
[#6181]: https://github.com/nymtech/nym/pull/6181
[#6180]: https://github.com/nymtech/nym/pull/6180
[#6174]: https://github.com/nymtech/nym/pull/6174
[#6144]: https://github.com/nymtech/nym/pull/6144
[#6107]: https://github.com/nymtech/nym/pull/6107
[#6068]: https://github.com/nymtech/nym/pull/6068
[#6063]: https://github.com/nymtech/nym/pull/6063
[#6057]: https://github.com/nymtech/nym/pull/6057
[#6030]: https://github.com/nymtech/nym/pull/6030
[#6013]: https://github.com/nymtech/nym/pull/6013
[#5996]: https://github.com/nymtech/nym/pull/5996
[#5993]: https://github.com/nymtech/nym/pull/5993
[#5992]: https://github.com/nymtech/nym/pull/5992
## [2025.21-mozzarella] (2025-11-25)
- [bugfix] Tunnel not waiting on MixnetClient to shut down cleanly ([#6225])
+686
View File
@@ -0,0 +1,686 @@
# 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
+89 -912
View File
File diff suppressed because it is too large Load Diff
+7 -19
View File
@@ -72,10 +72,6 @@ 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",
@@ -157,14 +153,13 @@ 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",
@@ -173,8 +168,7 @@ members = [
"wasm/mix-fetch",
"wasm/node-tester",
"wasm/zknym-lib",
"nym-gateway-probe",
"integration-tests", "common/nym-lp-transport",
"nym-gateway-probe"
]
default-members = [
@@ -192,7 +186,6 @@ default-members = [
"service-providers/ip-packet-router",
"service-providers/network-requester",
"tools/nymvisor",
"nym-registration-client"
]
exclude = ["contracts", "nym-wallet", "cpu-cycles"]
@@ -214,7 +207,6 @@ 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"
@@ -254,9 +246,9 @@ criterion = "0.5"
csv = "1.3.1"
ctr = "0.9.1"
cupid = "0.6.1"
curve25519-dalek = "4.1.3"
dashmap = "5.5.3"
defguard_wireguard_rs = "0.8.0"
# We want https://github.com/DefGuard/wireguard-rs/pull/64 , but there's no crates.io release being pushed out anymore
defguard_wireguard_rs = { git = "https://github.com/DefGuard/wireguard-rs.git", rev = "v0.4.7" }
digest = "0.10.7"
dirs = "6.0"
dotenvy = "0.15.6"
@@ -294,9 +286,7 @@ 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"
@@ -304,9 +294,8 @@ ledger-transport-hid = "0.10.0"
log = "0.4"
mime = "0.3.17"
moka = { version = "0.12", features = ["future"] }
nix = "0.30.1"
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"
@@ -337,7 +326,7 @@ serde_repr = "0.1"
serde_with = "3.9.0"
serde_yaml = "0.9.25"
serde_plain = "1.0.2"
sha2 = "0.10.3"
sha2 = "0.10.9"
si-scale = "0.2.3"
snow = "0.9.6"
sphinx-packet = "=0.6.0"
@@ -353,7 +342,6 @@ 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"
+10 -10
View File
@@ -2,7 +2,7 @@
ansible_ssh_private_key_file: ~/.ssh/<SSH_KEY>
# nym_version: "v2025.21-mozzarella"
#
#
# NOTE:
# if you want to pin Nym to a specific version instead of using the
# latest release from GitHub in /tasks/main.yml then
@@ -13,17 +13,17 @@ tunnel_manager_url: "https://github.com/nymtech/nym/raw/refs/heads/develop/scrip
quic_bridge_deployment_url: "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/quic_bridge_deployment.sh"
# NOTE: These values will be used globally unless overwritten per node in inventory/all
ansible_user: root # used for ssh, like `ssh root@nym-exit.ch-1.mynodes.net`
email: "<EMAIL>" # used in certbot, description.toml and landing page
website: "<WEBSITE>" # it is used in the description.toml
description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
ansible_user: root # used for ssh, like `ssh root@nym-exit.ch-1.mynodes.net`
email: "<EMAIL>" # used in certbot, description.toml and landing page
website: "<WEBSITE>" # it is used in the description.toml
description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
# NOTE: Set these vars if you want them globally for all nodes
# Per node changes in inventory/all will overwrite these global ones:
hostname: "" # this is a fallback, keep it and setup hostname per node in inventory/all
# moniker: "<MONIKER>" # if not setup here not in inventory/all it get's derived from the hostname
# mode: <MODE> # entry-gateway/exit-gateway/mixnode
# wireguard_enabled: <WIREGUARD_ENABLED> # true/false
hostname: "" # this is a fallback, keep it and setup hostname per node in inventory/all
# moniker: "<MONIKER>" # if not setup here not in inventory/all it get's derived from the hostname
# mode: <MODE> # entry-gateway/exit-gateway/mixnode
# wireguard_enabled: <WIREGUARD_ENABLED> # true/false
# NOTE: Possible vars to incule on landing page, etc.
# operator_name: "<OPERATOR_NAME>"
@@ -41,4 +41,4 @@ packages:
- ca-certificates
- jq
- wget
- ufw
- ufw
+3 -4
View File
@@ -1,10 +1,9 @@
---
- name: Set hostname
hostname:
name: "{{ hostname }}"
when: hostname is defined and hostname | length > 0
- name: Install aptitude
- name: Install aptitude
apt:
name: aptitude
update_cache: yes
@@ -15,9 +14,9 @@
apt:
update_cache: yes
upgrade: yes
- name: Install essential packages
package:
name: "{{ packages }}"
state: latest
update_cache: yes
update_cache: yes
@@ -1,10 +0,0 @@
---
- name: Reload nginx
service:
name: nginx
state: reloaded
- name: Restart nginx
service:
name: nginx
state: restarted
+14 -126
View File
@@ -1,4 +1,3 @@
---
- name: Install nginx and certbot
apt:
name:
@@ -6,168 +5,57 @@
- certbot
- python3-certbot-nginx
state: present
update_cache: yes
- name: Ensure nginx snippets directory exists
file:
path: /etc/nginx/snippets
state: directory
mode: "0755"
# own SSL defaults - don't rely on certbot files
- name: Install Nym SSL options snippet
copy:
dest: /etc/nginx/snippets/nym-ssl-options.conf
mode: "0644"
content: |
ssl_session_cache shared:NYMSSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Reasonable modern cipher set (works across Ubuntu nginx builds)
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305";
# OCSP stapling is nice but can break if resolver isn't set; keep minimal here.
notify: Restart nginx
- name: Ensure web root directory exists
- name: Create web root directory
file:
path: "/var/www/{{ hostname }}"
state: directory
mode: "0755"
- name: Deploy landing page
- name: Create landing page template
tags: landing
template:
src: landing.html.j2
dest: "/var/www/{{ hostname }}/index.html"
mode: "0644"
notify: Restart nginx
# remove default site - safe on fresh + redeploy
- name: Disable default nginx site symlink
- name: Remove default nginx site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Restart nginx
- name: Remove default nginx site definition if present
file:
path: /etc/nginx/sites-available/default
state: absent
notify: Restart nginx
# always deploy/enable HTTP vhost
- name: Deploy HTTP vhost
- name: Add bare-bones nginx template
template:
src: nginx-site.conf.j2
dest: "/etc/nginx/sites-available/{{ hostname }}"
mode: "0644"
notify: Restart nginx
- name: Enable HTTP vhost (force correct symlink)
- name: Enable nginx config
file:
src: "/etc/nginx/sites-available/{{ hostname }}"
dest: "/etc/nginx/sites-enabled/{{ hostname }}"
state: link
force: true
notify: Restart nginx
# detect if cert exists already
- name: Check whether certificate exists
stat:
path: "/etc/letsencrypt/live/{{ hostname }}/fullchain.pem"
register: le_cert
# if cert does NOT exist yet, ensure SSL/WSS are NOT enabled
- name: Ensure SSL and WSS vhosts are disabled until cert exists
file:
path: "{{ item }}"
state: absent
loop:
- "/etc/nginx/sites-enabled/{{ hostname }}-ssl"
- "/etc/nginx/sites-enabled/nym-wss-config"
when: not le_cert.stat.exists
notify: Restart nginx
- name: Ensure nginx is enabled and running (needed for ACME http-01)
service:
name: nginx
state: started
enabled: yes
- name: Validate nginx configuration (HTTP stage)
- name: Validate nginx configuration
command: nginx -t
changed_when: false
- name: Flush handlers (ensure HTTP is active before certbot)
meta: flush_handlers
# certbot strategy:
# - if cert exists: webroot - doesn't touch nginx
# - else: --nginx works first-time; may touch nginx
- name: Obtain/renew certificate
- name: Obtain SSL certificate
command:
cmd: >-
{% if le_cert.stat.exists %}
certbot certonly --webroot
-w /var/www/{{ hostname }}
--non-interactive --agree-tos --keep-until-expiring
-m {{ email }} -d {{ hostname }}
{% else %}
certbot --nginx
--non-interactive --agree-tos --redirect
-m {{ email }} -d {{ hostname }}
{% endif %}
register: certbot_result
failed_when: false
cmd: "certbot --nginx --non-interactive --agree-tos --redirect -m {{ email }} -d {{ hostname }}"
# re-check cert after certbot attempt
- name: Re-check whether certificate exists after certbot
stat:
path: "/etc/letsencrypt/live/{{ hostname }}/fullchain.pem"
register: le_cert_after
# only deploy/enable SSL & WSS if cert exists
- name: Deploy HTTPS vhost for {{ hostname }}
template:
src: nginx-site-ssl.conf.j2
dest: "/etc/nginx/sites-available/{{ hostname }}-ssl"
mode: "0644"
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Enable HTTPS vhost (force correct symlink)
file:
src: "/etc/nginx/sites-available/{{ hostname }}-ssl"
dest: "/etc/nginx/sites-enabled/{{ hostname }}-ssl"
state: link
force: true
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Deploy WSS vhost
- name: Add wss config from nginx template
template:
src: wss-config.conf.j2
dest: "/etc/nginx/sites-available/nym-wss-config"
mode: "0644"
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Enable WSS vhost (force correct symlink)
- name: Enable WSS config
file:
src: "/etc/nginx/sites-available/nym-wss-config"
dest: "/etc/nginx/sites-enabled/nym-wss-config"
state: link
force: true
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Validate nginx configuration (final)
- name: Validate nginx config after wss
command: nginx -t
changed_when: false
- name: Flush handlers (apply restart after successful tests)
meta: flush_handlers
- name: Restart nginx to apply changes
service: name=nginx state=restarted enabled=yes
@@ -1,17 +0,0 @@
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{ hostname }};
ssl_certificate /etc/letsencrypt/live/{{ hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ hostname }}/privkey.pem;
include /etc/nginx/snippets/nym-ssl-options.conf;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
@@ -4,15 +4,10 @@ server {
server_name {{ hostname }};
root /var/www/{{ hostname }};
index index.html;
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
@@ -4,9 +4,10 @@ server {
server_name {{ hostname }};
ssl_certificate /etc/letsencrypt/live/{{ hostname }}/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/{{ hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ hostname }}/privkey.pem;
include /etc/nginx/snippets/nym-ssl-options.conf;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
+5 -1
View File
@@ -6,6 +6,10 @@ nym_install_dir: /root/nym-binaries
http_bind_address: "0.0.0.0:8080" # maps to --http-bind-address
mixnet_bind_address: "0.0.0.0:1789" # maps to --mixnet-bind-address
# WireGuard boolean
wireguard_enabled: "{{ wireguard_enabled | default(false) | bool }}"
# Landing page base dir, hostname is appended in the task
landing_page_assets_base_dir: "/var/www"
@@ -33,4 +37,4 @@ nym_ufw_rules:
- { port: 8080, proto: tcp }
- { port: 9000, proto: tcp }
- { port: 9001, proto: tcp }
- { port: 51822, proto: udp }
- { port: 51822, proto: udp }
@@ -1,4 +1,3 @@
---
- name: Reload systemd
systemd:
daemon_reload: yes
+3 -3
View File
@@ -1,5 +1,5 @@
---
# useful when the host is behind a NAT
# Useful when the host is behind a NAT
- name: Fetch the public IP address
command: "curl -4 canhazip.com"
register: ipv4
@@ -11,7 +11,7 @@
public_ip: "{{ ipv4.stdout | default(ansible_default_ipv4.address) }}"
- name: Initialize nym node
# delete the part from --hostname onward if you run mode=mixnode only
# Delete the part from --hostname onward if you run mode=mixnode only
command:
cmd: >
{{ nym_install_dir }}/nym-node run
@@ -25,7 +25,7 @@
{{ nym_extra_flags }}
--hostname {{ hostname }}
--wireguard-enabled {{ (wireguard_enabled | default('false') | bool) | ternary('true','false') }}
--wireguard-enabled {{ wireguard_enabled }}
--landing-page-assets-path {{ landing_page_assets_base_dir }}/{{ hostname }}/
{% if nym_write_flag %}-w{% endif %}
{% if nym_init_only_flag %}--init-only{% endif %}
+1 -11
View File
@@ -1,12 +1,3 @@
---
- name: Ensure UFW is installed
apt:
name: ufw
state: present
update_cache: yes
when: nym_ufw_enable
- name: Configure UFW rules
ufw:
rule: allow
@@ -23,10 +14,9 @@
- name: Allow bandwidth/topup rule inside WG tunnel
command: >
ufw allow in on nymwg to any port 51830 proto tcp comment 'bandwidth queries/topup'
changed_when: false
when:
- nym_ufw_enable
- (wireguard_enabled | default(false) | bool)
- (wireguard_enabled | bool)
- name: Enable UFW
ufw:
@@ -6,10 +6,10 @@ StartLimitBurst=10
[Service]
User={{ ansible_user }}
LimitNOFILE=65536
ExecStart=/root/nym-binaries/nym-node run --mode {{ mode }} --accept-operator-terms-and-conditions --wireguard-enabled {{ (wireguard_enabled | default(false) | bool) | ternary('true','false') }}
ExecStart=/root/nym-binaries/nym-node run --mode {{ mode }} --accept-operator-terms-and-conditions --wireguard-enabled {{ wireguard_enabled }}
KillSignal=SIGINT
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
+9 -6
View File
@@ -1,11 +1,14 @@
---
- name: Download network-tunnel-manager.sh
tags: network tunnel manager
get_url:
url: "{{ tunnel_manager_url }}"
dest: "/root/nym-binaries/network-tunnel-manager.sh"
mode: "0755"
- name: Configure tunnel manager
tags:
- network_tunnel_manager
tags: network tunnel manager
become: true
command:
cmd: "/root/nym-binaries/network-tunnel-manager.sh {{ item }}"
loop:
- complete_networking_configuration
register: tunnel_mgr
failed_when: false
- complete_networking_configuration
@@ -9,7 +9,7 @@
changed_when: false
when: not ansible_check_mode
# show the full stdout
# show the full stdout so we dont depend on regex parsing at all
# show full upgraded version output, line by line
- name: Show upgraded nym-node version info
debug:
@@ -116,7 +116,7 @@
when: not ansible_check_mode and (upgrade_ok | default(false)) == false
# optional: hard-fail the play for CI environments
#- name: fail the play to signal upgrade failure
#- name: Fail the play to signal upgrade failure
# fail:
# msg: "nym-node upgrade failed; rolled back to previous binary."
# when: not ansible_check_mode and (upgrade_ok | default(false)) == false
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-client"
version = "1.1.68"
version = "1.1.67"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
description = "Implementation of the Nym Client"
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-socks5-client"
version = "1.1.68"
version = "1.1.67"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
edition = "2021"
-1
View File
@@ -28,7 +28,6 @@ pub use traits::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
pub mod acquire;
pub mod error;
mod event;
pub mod mock;
mod traits;
mod utils;
-120
View File
@@ -1,120 +0,0 @@
// 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)
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub(crate) enum CommonConfigsWrapper {
// nym-api
NymApi(NymApiConfigLight),
// anything else that might get introduced
// anything else that might get get introduced
Unknown(UnknownConfigWrapper),
}
@@ -30,7 +30,6 @@ 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,13 +59,9 @@ 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
}
_ => {
nym_metrics::inc!("ecash_verification_failures_signature");
EcashTicketError::MalformedTicket
}
_ => EcashTicketError::MalformedTicket,
})?;
self.insert_pay_info(credential.pay_info.into(), insert_index)
@@ -174,14 +170,14 @@ impl EcashManager {
}
pub struct MockEcashManager {
verification_key: tokio::sync::RwLock<VerificationKeyAuth>,
verfication_key: tokio::sync::RwLock<VerificationKeyAuth>,
storage: Box<dyn BandwidthGatewayStorage + Send + Sync>,
}
impl MockEcashManager {
pub fn new(storage: Box<dyn BandwidthGatewayStorage + Send + Sync>) -> Self {
Self {
verification_key: tokio::sync::RwLock::new(
verfication_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,
@@ -237,7 +233,7 @@ impl traits::EcashManager for MockEcashManager {
&self,
_epoch_id: EpochId,
) -> Result<RwLockReadGuard<'_, VerificationKeyAuth>, EcashTicketError> {
Ok(self.verification_key.read().await)
Ok(self.verfication_key.read().await)
}
fn storage(&self) -> Box<dyn BandwidthGatewayStorage + Send + Sync> {
@@ -253,8 +249,4 @@ impl traits::EcashManager for MockEcashManager {
}
fn async_verify(&self, _ticket: ClientTicket) {}
fn is_mock(&self) -> bool {
true
}
}
@@ -222,13 +222,9 @@ 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,10 +20,4 @@ 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
}
}
+2 -37
View File
@@ -8,7 +8,6 @@ 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::*;
@@ -22,10 +21,6 @@ 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>,
@@ -69,7 +64,6 @@ 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(())
@@ -111,9 +105,6 @@ 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();
@@ -122,39 +113,15 @@ 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);
}
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_credential_spending_date(spend_date.ecash_date())?;
self.check_local_db_for_double_spending(&serial_number)
.await?;
// TODO: do we HAVE TO do it?
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?;
self.cryptographically_verify_ticket().await?;
let ticket_id = self.store_received_ticket(received_at).await?;
self.async_verify_ticket(ticket_id);
@@ -168,8 +135,6 @@ impl CredentialVerifier {
.increase_bandwidth(bandwidth, cred_exp_date())
.await?;
nym_metrics::inc!("ecash_verification_success");
Ok(self
.bandwidth_storage_manager
.client_bandwidth
+1 -16
View File
@@ -3,7 +3,6 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use thiserror::Error;
use time::{Date, OffsetDateTime};
@@ -74,7 +73,7 @@ pub struct CredentialSigningData {
pub ticketbook_type: TicketType,
}
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct CredentialSpendingData {
pub payment: Payment,
@@ -87,20 +86,6 @@ 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(
+1 -2
View File
@@ -15,7 +15,6 @@ 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 }
@@ -48,7 +47,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", "curve25519-dalek", "sha2", "zeroize"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2"]
stream_cipher = ["aes", "ctr", "cipher", "generic-array"]
sphinx = ["nym-sphinx-types/sphinx"]
+3 -115
View File
@@ -20,7 +20,6 @@ pub use serde_helpers::*;
#[cfg(feature = "sphinx")]
use nym_sphinx_types::{DESTINATION_ADDRESS_LENGTH, DestinationAddressBytes};
use crate::asymmetric::x25519;
#[cfg(feature = "rand")]
use rand::{CryptoRng, Rng, RngCore};
#[cfg(feature = "serde")]
@@ -111,18 +110,6 @@ impl KeyPair {
index: fake_index(pub_bytes),
})
}
/// Converts this Ed25519 keypair to an X25519 keypair 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 keypair
pub fn to_x25519(&self) -> x25519::KeyPair {
let private_key = self.private_key.to_x25519();
x25519::KeyPair::from(private_key)
}
}
/// Reduces a byte slice into a u32 value by XOR-ing all its bytes into a 4-byte accumulator.
@@ -149,16 +136,6 @@ impl From<PrivateKey> for KeyPair {
}
}
impl From<(PrivateKey, PublicKey)> for KeyPair {
fn from((private_key, public_key): (PrivateKey, PublicKey)) -> Self {
KeyPair {
private_key,
public_key,
index: fake_index(public_key.to_bytes().as_ref()),
}
}
}
impl PemStorableKeyPair for KeyPair {
type PrivatePemKey = PrivateKey;
type PublicPemKey = PublicKey;
@@ -208,25 +185,14 @@ impl PublicKey {
}
/// Convert this public key to a byte array.
#[inline]
pub fn to_bytes(self) -> [u8; PUBLIC_KEY_LENGTH] {
self.0.to_bytes()
}
/// View this public key as a byte array.
#[inline]
pub fn as_bytes(&self) -> &[u8; PUBLIC_KEY_LENGTH] {
self.0.as_bytes()
}
#[inline]
pub fn from_bytes(b: &[u8]) -> Result<Self, Ed25519RecoveryError> {
Self::from_byte_array(b.try_into()?)
}
#[inline]
pub fn from_byte_array(b: &[u8; PUBLIC_KEY_LENGTH]) -> Result<Self, Ed25519RecoveryError> {
Ok(PublicKey(ed25519_dalek::VerifyingKey::from_bytes(b)?))
Ok(PublicKey(ed25519_dalek::VerifyingKey::from_bytes(
b.try_into()?,
)?))
}
pub fn to_base58_string(self) -> String {
@@ -247,37 +213,6 @@ 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")]
@@ -399,30 +334,6 @@ 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")]
@@ -606,27 +517,4 @@ 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);
}
}
+1 -45
View File
@@ -4,7 +4,6 @@
use base64::Engine;
use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair};
use std::fmt::{self, Debug, Display, Formatter};
use std::ops::Deref;
use std::str::FromStr;
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop};
@@ -57,15 +56,6 @@ pub struct KeyPair {
pub(crate) public_key: PublicKey,
}
impl Debug for KeyPair {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("KeyPair")
.field("private_key", &"<redacted>")
.field("public_key", &self.public_key.to_base58_string())
.finish()
}
}
impl KeyPair {
#[cfg(feature = "rand")]
pub fn new<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
@@ -103,15 +93,6 @@ impl From<PrivateKey> for KeyPair {
}
}
impl From<(PrivateKey, PublicKey)> for KeyPair {
fn from((private_key, public_key): (PrivateKey, PublicKey)) -> Self {
KeyPair {
private_key,
public_key,
}
}
}
impl PemStorableKeyPair for KeyPair {
type PrivatePemKey = PrivateKey;
type PublicPemKey = PublicKey;
@@ -135,13 +116,6 @@ impl PemStorableKeyPair for KeyPair {
#[derive(PartialEq, Eq, Hash, Copy, Clone)]
pub struct PublicKey(x25519_dalek::PublicKey);
impl Deref for PublicKey {
type Target = x25519_dalek::PublicKey;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Display for PublicKey {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
Display::fmt(&self.to_base58_string(), f)
@@ -155,17 +129,14 @@ impl Debug for PublicKey {
}
impl PublicKey {
#[inline]
pub fn to_bytes(self) -> [u8; PUBLIC_KEY_SIZE] {
*self.0.as_bytes()
}
#[inline]
pub fn as_bytes(&self) -> &[u8; PUBLIC_KEY_SIZE] {
self.0.as_bytes()
}
#[inline]
pub fn from_bytes(b: &[u8]) -> Result<Self, KeyRecoveryError> {
if b.len() != PUBLIC_KEY_SIZE {
return Err(KeyRecoveryError::InvalidSizePublicKey {
@@ -175,12 +146,7 @@ impl PublicKey {
}
let mut bytes = [0; PUBLIC_KEY_SIZE];
bytes.copy_from_slice(&b[..PUBLIC_KEY_SIZE]);
Ok(Self::from_byte_array(&bytes))
}
#[inline]
pub fn from_byte_array(b: &[u8; PUBLIC_KEY_SIZE]) -> Self {
Self(x25519_dalek::PublicKey::from(*b))
Ok(Self(x25519_dalek::PublicKey::from(bytes)))
}
pub fn to_base58_string(self) -> String {
@@ -208,12 +174,6 @@ impl PublicKey {
}
}
impl From<[u8; PUBLIC_KEY_SIZE]> for PublicKey {
fn from(bytes: [u8; PUBLIC_KEY_SIZE]) -> Self {
PublicKey(x25519_dalek::PublicKey::from(bytes))
}
}
impl FromStr for PublicKey {
type Err = KeyRecoveryError;
@@ -336,10 +296,6 @@ impl PrivateKey {
Ok(Self(x25519_dalek::StaticSecret::from(bytes)))
}
pub fn from_secret(secret: [u8; PRIVATE_KEY_SIZE]) -> Self {
Self(x25519_dalek::StaticSecret::from(secret))
}
pub fn to_base58_string(&self) -> String {
bs58::encode(&self.to_bytes()).into_string()
}
-98
View File
@@ -1,98 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Key Derivation Functions using Blake3.
/// Derives a 32-byte key using Blake3's key derivation mode.
///
/// Uses Blake3's built-in `derive_key` function with domain separation via context string.
///
/// # Arguments
/// * `context` - Context string for domain separation (e.g., "nym-lp-psk-v1")
/// * `key_material` - Input key material (shared secret from ECDH, etc.)
/// * `salt` - Additional salt for freshness (timestamp + nonce)
///
/// # Returns
/// 32-byte derived key suitable for use as PSK
///
/// # Example
/// ```ignore
/// let psk = derive_key_blake3("nym-lp-psk-v1", shared_secret.as_bytes(), &salt);
/// ```
pub fn derive_key_blake3(context: &str, key_material: &[u8], salt: &[u8]) -> [u8; 32] {
// Concatenate key_material and salt as input
let input = [key_material, salt].concat();
// Use Blake3's derive_key with context for domain separation
// blake3::derive_key returns [u8; 32] directly
blake3::derive_key(context, &input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deterministic_derivation() {
let context = "test-context";
let key_material = b"shared_secret_12345";
let salt = b"salt_67890";
let key1 = derive_key_blake3(context, key_material, salt);
let key2 = derive_key_blake3(context, key_material, salt);
assert_eq!(key1, key2, "Same inputs should produce same output");
}
#[test]
fn test_different_contexts_produce_different_keys() {
let key_material = b"shared_secret";
let salt = b"salt";
let key1 = derive_key_blake3("context1", key_material, salt);
let key2 = derive_key_blake3("context2", key_material, salt);
assert_ne!(
key1, key2,
"Different contexts should produce different keys"
);
}
#[test]
fn test_different_salts_produce_different_keys() {
let context = "test-context";
let key_material = b"shared_secret";
let key1 = derive_key_blake3(context, key_material, b"salt1");
let key2 = derive_key_blake3(context, key_material, b"salt2");
assert_ne!(key1, key2, "Different salts should produce different keys");
}
#[test]
fn test_different_key_material_produces_different_keys() {
let context = "test-context";
let salt = b"salt";
let key1 = derive_key_blake3(context, b"secret1", salt);
let key2 = derive_key_blake3(context, b"secret2", salt);
assert_ne!(
key1, key2,
"Different key material should produce different keys"
);
}
#[test]
fn test_output_length() {
let key = derive_key_blake3("test", b"key", b"salt");
assert_eq!(key.len(), 32, "Output should be exactly 32 bytes");
}
#[test]
fn test_empty_inputs() {
// Should not panic with empty inputs
let key = derive_key_blake3("test", b"", b"");
assert_eq!(key.len(), 32);
}
}
-2
View File
@@ -10,8 +10,6 @@ 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;
-7
View File
@@ -133,13 +133,6 @@ 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,10 +150,6 @@ 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 {
+1 -1
View File
@@ -7,7 +7,7 @@ use nym_sdk::mixnet::{MixnetClientSender, Recipient};
use tokio_util::sync::CancellationToken;
use tracing::info;
// Import these here for all modules to use, to keep the version consistent
// Import these here for for all modules to use, to keep the version consistent
pub(crate) use nym_ip_packet_requests::v8 as nym_ip_packet_requests_current;
mod error;
-27
View File
@@ -1,27 +0,0 @@
[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"
-80
View File
@@ -1,80 +0,0 @@
use bytes::BytesMut;
use log::info;
use nym_kcp::{packet::KcpPacket, session::KcpSession};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create two KcpSessions, simulating two endpoints
let mut local_sess = KcpSession::new(42);
let mut remote_sess = KcpSession::new(42);
// Set an MSS (max segment size) smaller than our data to force fragmentation
local_sess.set_mtu(40);
remote_sess.set_mtu(40);
// Some data larger than 30 bytes to demonstrate multi-fragment
let big_data = b"The quick brown fox jumps over the lazy dog. This is a test.";
// --- LOCAL sends data ---
info!(
"Local: sending data: {:?}",
String::from_utf8_lossy(big_data)
);
local_sess.send(big_data);
// Update local session's logic at time=0
local_sess.update(100);
// LOCAL fetches outgoing (to be sent across the network)
let outgoing_pkts = local_sess.fetch_outgoing();
info!("Local: outgoing pkts: {:?}", outgoing_pkts);
// Here you'd normally encrypt and send them. Well just encode them into a buffer.
// Then that buffer is "transferred" to the remote side.
let mut wire_buf = BytesMut::new();
for pkt in &outgoing_pkts {
pkt.encode(&mut wire_buf);
}
// --- REMOTE receives data ---
// The remote side "decrypts" (here we just clone) and decodes
let mut remote_in = wire_buf.clone();
// Decode zero or more KcpPackets from remote_in
while let Some(decoded_pkt) = KcpPacket::decode(&mut remote_in)? {
info!(
"Decoded packet, sn: {}, frg: {}",
decoded_pkt.sn(),
decoded_pkt.frg()
);
remote_sess.input(&decoded_pkt)?;
}
// Update remote session to process newly received data
remote_sess.update(100);
// The remote session likely generated ACK packets
let ack_pkts = remote_sess.fetch_outgoing();
// --- LOCAL receives ACKs ---
// The local side decodes them
let mut ack_buf = BytesMut::new();
for pkt in &ack_pkts {
pkt.encode(&mut ack_buf);
}
while let Some(decoded_pkt) = KcpPacket::decode(&mut ack_buf)? {
local_sess.input(&decoded_pkt)?;
}
// Update local again with some arbitrary time, e.g. 50 ms later
local_sess.update(100);
// Just for completeness, local might produce more packets, though typically it's just empty now
let _ = local_sess.fetch_outgoing();
// --- REMOTE reads reassembled data ---
let incoming = remote_sess.fetch_incoming();
info!("Remote: incoming pkts: {:?}", incoming);
Ok(())
}
-83
View File
@@ -1,83 +0,0 @@
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(())
}
@@ -1,10 +0,0 @@
packet 1
packet 2
packet 3
packet 4
packet 5
packet 6
packet 7
packet 8
packet 9
packet 10
-30
View File
@@ -1,30 +0,0 @@
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(())
}
}
-75
View File
@@ -1,75 +0,0 @@
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)
}
}
-13
View File
@@ -1,13 +0,0 @@
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),
}
-7
View File
@@ -1,7 +0,0 @@
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
-224
View File
@@ -1,224 +0,0 @@
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
-39
View File
@@ -1,39 +0,0 @@
[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 }
num_enum = { workspace = true }
# internal
nym-crypto = { path = "../crypto", features = ["asymmetric", "serde"] }
libcrux-kem = { git = "https://github.com/cryspen/libcrux" }
libcrux-sha3 = { git = "https://github.com/cryspen/libcrux" }
libcrux-ecdh = { git = "https://github.com/cryspen/libcrux", features = ["codec"] }
libcrux-chacha20poly1305 = { git = "https://github.com/cryspen/libcrux" }
#rand = "0.9.2"
rand = "0.9.2"
zeroize = { workspace = true, features = ["zeroize_derive"] }
classic-mceliece-rust = { git = "https://github.com/georgio/classic-mceliece-rust", features = ["mceliece460896f", "zeroize"] }
[dev-dependencies]
rand_chacha = "0.9.0"
anyhow = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "benches"
harness = false
[lints]
workspace = true
-480
View File
@@ -1,480 +0,0 @@
// 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!("{kem}, {hash_function} | Anonymous Initiator: Generate Request",),
|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!(
"{kem}, {hash_function} | Anonymous Initiator: Encode Frame - Request",
),
|b| b.iter(|| i_frame.to_bytes()),
);
let i_frame_bytes = i_frame.to_bytes();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Decode Frame - Request",
),
|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!(
"{kem}, {hash_function} | Anonymous Initiator: Responder Ingest Frame",
),
|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!(
"{kem}, {hash_function} | Anonymous Initiator: Responder Generate Response",
),
|b| {
b.iter(|| {
responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Responder Encode Frame",
),
|b| b.iter(|| r_frame.to_bytes()),
);
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Initiator Ingest Response",
),
|b| {
b.iter(|| {
initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let obtained_key = initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.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!("{kem}, {hash_function} | Initiator OneWay: Generate Request",),
|b| {
b.iter(|| {
initiator_process(
&mut rng,
KKTMode::OneWay,
ciphersuite,
initiator_ed25519_keypair.private_key(),
None,
)
.unwrap()
});
},
);
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Encode Frame - Request",),
|b| b.iter(|| i_frame.to_bytes()),
);
let i_frame_bytes = i_frame.to_bytes();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Decode Frame - Request",),
|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!("{kem}, {hash_function} | Initiator OneWay: Responder Ingest Frame",),
|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!(
"{kem}, {hash_function} | Initiator OneWay: Responder Generate Response",
),
|b| {
b.iter(|| {
responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Responder Encode Frame",),
|b| {
b.iter(|| r_frame.to_bytes());
},
);
c.bench_function(
&format!(
"{kem}, {hash_function} | Initiator OneWay: Initiator Ingest Response",
),
|b| {
b.iter(|| {
initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, Mutual
{
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Generate Request",),
|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!("{kem}, {hash_function} | Initiator Mutual: Encode Frame - Request",),
|b| {
b.iter(|| i_frame.to_bytes());
},
);
let i_frame_bytes = i_frame.to_bytes();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Decode Frame - Request",),
|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!("{kem}, {hash_function} | Initiator Mutual: Responder Ingest Frame",),
|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!(
"{kem}, {hash_function} | Initiator Mutual: Responder Generate Response",
),
|b| {
b.iter(|| {
responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Responder Encode Frame",),
|b| {
b.iter(|| {
r_frame.to_bytes();
});
},
);
c.bench_function(
&format!(
"{kem}, {hash_function} | Initiator Mutual: Initiator Ingest Response",
),
|b| {
b.iter(|| {
initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let obtained_key = initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(obtained_key.encode(), r_kem_key_bytes)
}
}
}
}
criterion_group!(
benches,
gen_ed25519_keypair,
gen_mlkem768_keypair,
kkt_benchmark
);
criterion_main!(benches);
-293
View File
@@ -1,293 +0,0 @@
// 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: usize = 32;
pub const CIPHERSUITE_ENCODING_LEN: usize = 4;
pub const CURVE25519_KEY_LEN: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq)]
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, PartialEq)]
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, PartialEq)]
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, PartialEq)]
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 as u8,
};
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; CIPHERSUITE_ENCODING_LEN] {
// [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 as usize {
HASH_LEN_256 => 0u8,
_ => self.hash_length,
},
match self.signature_scheme {
SignatureScheme::Ed25519 => 0,
},
]
}
pub fn decode(encoding: [u8; CIPHERSUITE_ENCODING_LEN]) -> Result<Self, KKTError> {
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)
}
}
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",
}),
}
}
-241
View File
@@ -1,241 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::ciphersuite::CIPHERSUITE_ENCODING_LEN;
use crate::{KKT_VERSION, ciphersuite::Ciphersuite, error::KKTError, frame::KKT_SESSION_ID_LEN};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use std::fmt::Display;
pub const KKT_CONTEXT_LEN: usize = 7;
// bitmask used: 0b1110_0000
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
pub enum KKTStatus {
Ok = 0b0000_0000,
InvalidRequestFormat = 0b0010_0000,
InvalidResponseFormat = 0b0100_0000,
InvalidSignature = 0b0110_0000,
UnsupportedCiphersuite = 0b1000_0000,
UnsupportedKKTVersion = 0b1010_0000,
InvalidKey = 0b1100_0000,
Timeout = 0b1110_0000,
}
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",
})
}
}
// bitmask used: 0b0000_0011
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
pub enum KKTRole {
Initiator = 0b0000_0000,
Responder = 0b0000_0001,
AnonymousInitiator = 0b0000_0010,
}
// bitmask used: 0b0001_1100
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
pub enum KKTMode {
OneWay = 0b0000_0000,
Mutual = 0b0000_0100,
}
#[derive(Copy, Clone, Debug, PartialEq)]
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 const fn header_len(&self) -> usize {
KKT_CONTEXT_LEN
}
pub const fn session_id_len(&self) -> usize {
// note: if anyone decides to update this function and changes the constant value,
// you will have to adjust encoding/decoding functions
// 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<[u8; KKT_CONTEXT_LEN], KKTError> {
let mut header_bytes = [0u8; KKT_CONTEXT_LEN];
if self.message_sequence >= 1 << 4 {
return Err(KKTError::MessageCountLimitReached);
}
let ciphersuite_bytes = self.ciphersuite.encode();
header_bytes[0] = (KKT_VERSION << 4) + self.message_sequence;
header_bytes[1] = u8::from(self.status) + u8::from(self.mode) + u8::from(self.role);
let mut i = 2;
for b in ciphersuite_bytes.into_iter() {
header_bytes[i] = b;
i += 1;
}
header_bytes[i] = 0;
Ok(header_bytes)
}
pub fn try_decode(header_bytes: [u8; KKT_CONTEXT_LEN]) -> Result<Self, KKTError> {
let kkt_version = (header_bytes[0] & 0b1111_0000) >> 4;
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 > KKT_VERSION {
return Err(KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Version: {kkt_version}"),
});
}
let raw_kkt_status = header_bytes[1] & 0b1110_0000;
let raw_kkt_role = header_bytes[1] & 0b0000_0011;
let raw_kkt_mode = header_bytes[1] & 0b0001_1100;
let status =
KKTStatus::try_from(raw_kkt_status).map_err(|_| KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Status: {raw_kkt_status}"),
})?;
let role = KKTRole::try_from(raw_kkt_role).map_err(|_| KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Role: {raw_kkt_role}"),
})?;
let mode = KKTMode::try_from(raw_kkt_mode).map_err(|_| KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Mode: {raw_kkt_mode}"),
})?;
let ciphersuite_bytes = header_bytes[2..6].try_into().map_err(|_| {
KKTError::CiphersuiteDecodingError {
info: format!(
"Incorrect Encoding Length: actual: 4 != expected: {CIPHERSUITE_ENCODING_LEN}",
),
}
})?;
Ok(KKTContext {
version: kkt_version,
status,
mode,
role,
ciphersuite: Ciphersuite::decode(ciphersuite_bytes)?,
message_sequence: message_sequence_counter,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kkt_context_encoding() {
let valid_context = KKTContext::new(
KKTRole::Initiator,
KKTMode::Mutual,
Ciphersuite::decode([255, 1, 0, 0]).unwrap(),
)
.unwrap();
let encoded = valid_context.encode().unwrap();
let decoded = KKTContext::try_decode(encoded).unwrap();
assert_eq!(decoded, valid_context);
}
}
-257
View File
@@ -1,257 +0,0 @@
// Copyright 2025-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::kkt::KKT_INITIAL_FRAME_AAD;
use crate::{
ciphersuite::CURVE25519_KEY_LEN, context::KKTContext, error::KKTError, frame::KKTFrame,
};
use blake3::Hasher;
use libcrux_chacha20poly1305::{NONCE_LEN, TAG_LEN};
use nym_crypto::asymmetric::x25519;
use rand::{CryptoRng, RngCore};
use zeroize::Zeroize;
#[derive(Clone, Copy, Zeroize)]
pub struct KKTSessionSecret([u8; 32]);
impl KKTSessionSecret {
pub fn new<R>(rng: &mut R, remote_public_key: &x25519::PublicKey) -> (Self, x25519::PublicKey)
where
R: RngCore + CryptoRng,
{
let mut private_key_bytes = [0u8; x25519::PRIVATE_KEY_SIZE];
rng.fill_bytes(&mut private_key_bytes);
let ephemeral_private_key = x25519::PrivateKey::from_secret(private_key_bytes);
let ephemeral_public_key = x25519::PublicKey::from(&ephemeral_private_key);
(
Self::derive(&ephemeral_private_key, remote_public_key),
ephemeral_public_key,
)
}
pub fn from_bytes(secret: [u8; 32]) -> Self {
Self(secret)
}
fn try_derive(private_key: &x25519::PrivateKey, public_key: &[u8]) -> Result<Self, KKTError> {
let mut pub_key: [u8; 32] = [0u8; 32];
pub_key.copy_from_slice(&public_key[0..CURVE25519_KEY_LEN]);
// Todo: check validity of pk...
let pk = x25519::PublicKey::from(pub_key);
Ok(Self::derive(private_key, &pk))
}
pub fn derive(private_key: &x25519::PrivateKey, public_key: &x25519::PublicKey) -> Self {
let mut shared_secret = private_key.diffie_hellman(public_key);
let mut hasher = Hasher::new();
hasher.update(&shared_secret);
shared_secret.zeroize();
Self(hasher.finalize().as_bytes().to_owned())
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
pub fn encrypt_initial_kkt_frame<R>(
rng: &mut R,
remote_public_key: &x25519::PublicKey,
kkt_frame: &KKTFrame,
) -> Result<(KKTSessionSecret, Vec<u8>), KKTError>
where
R: CryptoRng + RngCore,
{
let (session_secret_key, ephemeral_public_key) = KKTSessionSecret::new(rng, remote_public_key);
let mut encrypted_frame =
encrypt_kkt_frame(rng, &session_secret_key, kkt_frame, KKT_INITIAL_FRAME_AAD)?;
let mut output_buffer = Vec::with_capacity(encrypted_frame.len() + CURVE25519_KEY_LEN);
output_buffer.extend_from_slice(ephemeral_public_key.as_bytes());
output_buffer.append(&mut encrypted_frame);
// [ 32 | 12 | ciphertext | 16];
// [eph_pub_key | nonce | ciphertext | tag];
Ok((session_secret_key, output_buffer))
}
pub fn decrypt_initial_kkt_frame(
responder_private_key: &x25519::PrivateKey,
encrypted_frame_bytes: &[u8],
) -> Result<(KKTSessionSecret, KKTFrame, KKTContext), KKTError> {
if encrypted_frame_bytes.len() < CURVE25519_KEY_LEN + TAG_LEN + NONCE_LEN {
Err(KKTError::AEADError {
info: "Encrypted KKT Frame is too short.",
})
} else {
let shared_secret = KKTSessionSecret::try_derive(
responder_private_key,
&encrypted_frame_bytes[0..CURVE25519_KEY_LEN],
)?;
let (kkt_frame, kkt_context) = decrypt_kkt_frame(
&shared_secret,
&encrypted_frame_bytes[CURVE25519_KEY_LEN..],
KKT_INITIAL_FRAME_AAD,
)?;
Ok((shared_secret, kkt_frame, kkt_context))
}
}
pub fn encrypt_kkt_frame<R>(
rng: &mut R,
secret_key: &KKTSessionSecret,
kkt_frame: &KKTFrame,
aad: &[u8],
) -> Result<Vec<u8>, KKTError>
where
R: CryptoRng + RngCore,
{
let kkt_frame_bytes = kkt_frame.to_bytes();
// generate nonce
let mut nonce: [u8; NONCE_LEN] = [0u8; NONCE_LEN];
rng.fill_bytes(&mut nonce);
let mut ciphertext = encrypt(secret_key.as_bytes(), &kkt_frame_bytes, aad, &nonce)?;
// [ 12 | ciphertext | 16];
// [nonce | ciphertext | tag];
let mut output_buffer: Vec<u8> =
Vec::with_capacity(NONCE_LEN + kkt_frame_bytes.len() + TAG_LEN);
output_buffer.extend_from_slice(&nonce);
output_buffer.append(&mut ciphertext);
Ok(output_buffer)
}
// kkt_frame_bytes should look like this
// [ 12 | ciphertext | 16];
// [nonce | ciphertext | tag];
pub fn decrypt_kkt_frame(
secret_key: &KKTSessionSecret,
kkt_frame_bytes: &[u8],
aad: &[u8],
) -> Result<(KKTFrame, KKTContext), KKTError> {
let mut nonce: [u8; NONCE_LEN] = [0u8; NONCE_LEN];
nonce.copy_from_slice(&kkt_frame_bytes[0..NONCE_LEN]);
let plaintext = decrypt(
secret_key.as_bytes(),
&kkt_frame_bytes[NONCE_LEN..],
aad,
&nonce,
)?;
KKTFrame::from_bytes(&plaintext)
}
fn encrypt(
secret_key: &[u8; 32],
plaintext: &[u8],
aad: &[u8],
nonce: &[u8; NONCE_LEN],
) -> Result<Vec<u8>, KKTError> {
let mut output_buffer = vec![0; plaintext.len() + TAG_LEN];
libcrux_chacha20poly1305::encrypt(secret_key, plaintext, &mut output_buffer, aad, nonce)?;
Ok(output_buffer)
}
fn decrypt(
secret_key: &[u8; 32],
ciphertext: &[u8],
aad: &[u8],
nonce: &[u8; NONCE_LEN],
) -> Result<Vec<u8>, KKTError> {
let mut output_buffer = vec![0; ciphertext.len() - TAG_LEN];
libcrux_chacha20poly1305::decrypt(secret_key, &mut output_buffer, ciphertext, aad, nonce)?;
Ok(output_buffer)
}
#[cfg(test)]
mod test {
use crate::ciphersuite::Ciphersuite;
use crate::context::{KKTContext, KKTMode, KKTRole};
use crate::encryption::{decrypt_kkt_frame, encrypt_kkt_frame};
use crate::frame::{KKT_SESSION_ID_LEN, KKTFrame};
use crate::{
ciphersuite::HASH_LEN_256,
encryption::{KKTSessionSecret, decrypt, encrypt},
key_utils::generate_keypair_x25519,
};
use rand::{RngCore, SeedableRng, rng};
use rand_chacha::ChaCha20Rng;
#[test]
fn test_keygen() {
let mut rng = rng();
let responder_x25519_keypair = generate_keypair_x25519(&mut rng);
let (session_secret_key, ephemeral_public_key) =
KKTSessionSecret::new(&mut rng, responder_x25519_keypair.public_key());
let shared_secret = KKTSessionSecret::try_derive(
responder_x25519_keypair.private_key(),
ephemeral_public_key.as_bytes().as_slice(),
)
.unwrap();
assert_eq!(shared_secret.as_bytes(), session_secret_key.as_bytes())
}
#[test]
fn test_encryption() {
let mut rng = rng();
let mut secret_key = [0u8; HASH_LEN_256];
rng.fill_bytes(&mut secret_key);
let mut plaintext = vec![0; 100];
rng.fill_bytes(&mut plaintext);
let mut nonce = [0; 12];
rng.fill_bytes(&mut nonce);
let mut aad = vec![0; 124];
rng.fill_bytes(&mut aad);
let ciphertext = encrypt(&secret_key, &plaintext, &aad, &nonce).unwrap();
let o_plaintext = decrypt(&secret_key, &ciphertext, &aad, &nonce).unwrap();
assert_eq!(o_plaintext, plaintext)
}
#[test]
fn kkt_frame_encryption() -> anyhow::Result<()> {
let mut rng = ChaCha20Rng::seed_from_u64(42);
let session_key = KKTSessionSecret::from_bytes([42u8; 32]);
let aad = b"my-amazing-aad";
let valid_context = KKTContext::new(
KKTRole::Initiator,
KKTMode::Mutual,
Ciphersuite::decode([255, 1, 0, 0])?,
)?;
let dummy_frame = KKTFrame::new(
valid_context.encode()?,
&[2u8; 32],
[3u8; KKT_SESSION_ID_LEN],
&[4u8; 64],
);
let ciphertext = encrypt_kkt_frame(&mut rng, &session_key, &dummy_frame, aad.as_slice())?;
let (frame, context) = decrypt_kkt_frame(&session_key, &ciphertext, aad.as_slice())?;
assert_eq!(dummy_frame, frame);
assert_eq!(context, valid_context);
Ok(())
}
}
-117
View File
@@ -1,117 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt::Debug;
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("{}", info)]
AEADError { 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,
}
}
}
impl From<libcrux_chacha20poly1305::AeadError> for KKTError {
fn from(err: libcrux_chacha20poly1305::AeadError) -> Self {
KKTError::KEMError {
info: match err {
libcrux_chacha20poly1305::AeadError::PlaintextTooLarge => {
"Plaintext is longer than u32::MAX"
}
libcrux_chacha20poly1305::AeadError::CiphertextTooLarge => {
"Ciphertext is longer than u32::MAX"
}
libcrux_chacha20poly1305::AeadError::AadTooLarge => "Aad is longer than u32::MAX",
libcrux_chacha20poly1305::AeadError::CiphertextTooShort => {
"The provided destination ciphertext does not fit the ciphertext and tag"
}
libcrux_chacha20poly1305::AeadError::PlaintextTooShort => {
"The provided destination plaintext is too short to fit the decrypted plaintext"
}
libcrux_chacha20poly1305::AeadError::InvalidCiphertext => {
"The ciphertext is not a valid encryption under the given key and nonce."
}
},
}
}
}
-155
View File
@@ -1,155 +0,0 @@
// 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 type KKTSessionId = [u8; KKT_SESSION_ID_LEN];
#[derive(Debug, PartialEq, Clone)]
pub struct KKTFrame {
context: [u8; KKT_CONTEXT_LEN],
session_id: KKTSessionId,
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; KKT_CONTEXT_LEN],
body: &[u8],
session_id: [u8; KKT_SESSION_ID_LEN],
signature: &[u8],
) -> Self {
Self {
context,
body: Vec::from(body),
session_id,
signature: Vec::from(signature),
}
}
pub fn context_ref(&self) -> &[u8] {
&self.context
}
pub fn context(&self) -> Result<KKTContext, KKTError> {
KKTContext::try_decode(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 session_id(&self) -> [u8; KKT_SESSION_ID_LEN] {
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> {
let len = bytes.len();
if bytes.len() < KKT_CONTEXT_LEN {
return Err(KKTError::FrameDecodingError {
info: format!(
"Frame is shorter than expected context length: actual {len} != expected {KKT_CONTEXT_LEN}",
),
});
}
// SAFETY: we're using exactly KKT_CONTEXT_LEN bytes
#[allow(clippy::unwrap_used)]
let context_bytes = bytes[0..KKT_CONTEXT_LEN].try_into().unwrap();
let context = KKTContext::try_decode(context_bytes)?;
if bytes.len() != context.full_message_len() {
return Err(KKTError::FrameDecodingError {
info: format!(
"Frame is shorter than expected: actual {len} != expected {}",
context.full_message_len()
),
});
}
let mut body = Vec::new();
let mut signature = Vec::new();
// decode body
if context.body_len() > 0 {
let body_bytes = &bytes[KKT_CONTEXT_LEN..KKT_CONTEXT_LEN + context.body_len()];
body.extend_from_slice(body_bytes);
}
let session_bytes = &bytes[KKT_CONTEXT_LEN + context.body_len()
..KKT_CONTEXT_LEN + context.body_len() + KKT_SESSION_ID_LEN];
// SAFETY: we're using exactly KKT_SESSION_ID_LEN bytes and we checked for sufficient bytes
#[allow(clippy::unwrap_used)]
let session_id = session_bytes.try_into().unwrap();
// // old code left for reference if session id becomes variable in length:
// 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()],
// );
// }
// decode signature
if context.signature_len() > 0 {
let signature_bytes = &bytes[KKT_CONTEXT_LEN + context.body_len() + KKT_SESSION_ID_LEN
..KKT_CONTEXT_LEN
+ context.body_len()
+ KKT_SESSION_ID_LEN
+ context.signature_len()];
signature.extend_from_slice(signature_bytes);
}
Ok((
KKTFrame::new(context_bytes, &body, session_id, &signature),
context,
))
}
}
-133
View File
@@ -1,133 +0,0 @@
use crate::ciphersuite::HashFunction;
use classic_mceliece_rust::keypair_boxed;
use libcrux_sha3;
use rand::{CryptoRng, RngCore};
pub fn generate_keypair_ed25519<R>(
rng: &mut R,
index: Option<u32>,
) -> nym_crypto::asymmetric::ed25519::KeyPair
where
R: RngCore + CryptoRng,
{
let mut secret_initiator: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut secret_initiator);
nym_crypto::asymmetric::ed25519::KeyPair::from_secret(secret_initiator, index.unwrap_or(0))
}
pub fn generate_keypair_x25519<R>(rng: &mut R) -> nym_crypto::asymmetric::x25519::KeyPair
where
R: RngCore + CryptoRng,
{
let mut secret_initiator: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut secret_initiator);
let private_key = nym_crypto::asymmetric::x25519::PrivateKey::from_secret(secret_initiator);
private_key.into()
}
// (decapsulation_key, encapsulation_key)
pub fn generate_keypair_libcrux<R>(
rng: &mut R,
kem: crate::ciphersuite::KEM,
) -> Result<(libcrux_kem::PrivateKey, libcrux_kem::PublicKey), crate::error::KKTError>
where
R: RngCore + CryptoRng,
{
match kem {
crate::ciphersuite::KEM::MlKem768 => {
Ok(libcrux_kem::key_gen(libcrux_kem::Algorithm::MlKem768, rng)?)
}
crate::ciphersuite::KEM::XWing => Ok(libcrux_kem::key_gen(
libcrux_kem::Algorithm::XWingKemDraft06,
rng,
)?),
crate::ciphersuite::KEM::X25519 => {
Ok(libcrux_kem::key_gen(libcrux_kem::Algorithm::X25519, rng)?)
}
_ => Err(crate::error::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)
}
-453
View File
@@ -1,453 +0,0 @@
// 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, x25519};
use rand::{CryptoRng, RngCore};
use crate::{
ciphersuite::{Ciphersuite, EncapsulationKey},
context::{KKTContext, KKTMode},
encryption::{decrypt_initial_kkt_frame, decrypt_kkt_frame, encrypt_kkt_frame},
error::KKTError,
};
// 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,
};
use crate::encryption::{KKTSessionSecret, encrypt_initial_kkt_frame};
use crate::frame::KKTFrame;
pub(crate) const KKT_RESPONSE_AAD: &[u8] = b"KKT_Response";
pub(crate) const KKT_INITIAL_FRAME_AAD: &[u8] = b"KKT_INITIAL_FRAME";
/// Perform an *Encrypted* request for 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
/// * `responder_dh_public_key` - Responder's long-term x25519 Diffie-Hellman public key
///
/// # Returns
/// * `KKTSessionSecret` - Session Secret Key to use when decrypting responses
/// * `KKTContext` - Context to use when validating the response
/// * `Vec<u8>` - Contains the client's ephemeral public key and encrypted and signed bytes to send to responder
///
/// # Example
/// ```ignore
/// let (session_secret, context, request_frame) = request_kem_key(
/// &mut rng,
/// ciphersuite,
/// client_signing_key,
/// responder_dh_public_key,
/// )?;
/// // Send request_frame to gateway
/// ```
pub fn request_kem_key<R: CryptoRng + RngCore>(
rng: &mut R,
ciphersuite: Ciphersuite,
signing_key: &ed25519::PrivateKey,
responder_dh_public_key: &x25519::PublicKey,
) -> Result<(KKTSessionSecret, KKTContext, Vec<u8>), KKTError> {
// OneWay mode: client only wants responder's KEM key
// None: client doesn't send their own KEM key
let (initiator_context, initiator_frame) =
initiator_process(rng, KKTMode::OneWay, ciphersuite, signing_key, None)?;
// Generate the session's shared secret and encrypt the Initiator's request
let (session_secret, encrypted_request_bytes) =
encrypt_initial_kkt_frame(rng, responder_dh_public_key, &initiator_frame)?;
Ok((session_secret, initiator_context, encrypted_request_bytes))
}
/// Decrypt, validate an *Encrypted* 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
/// * `session_secret` - Session Secret Key (generated with 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,
/// &session_secret,
/// &gateway_verification_key,
/// &expected_hash_from_directory,
/// &response_bytes,
/// )?;
/// // Use gateway_kem_key for PSQ
/// ```
pub fn validate_kem_response<'a>(
context: &mut KKTContext,
session_secret: &KKTSessionSecret,
responder_vk: &ed25519::PublicKey,
expected_key_hash: &[u8],
encrypted_response_bytes: &[u8],
) -> Result<EncapsulationKey<'a>, KKTError> {
let (responder_frame, responder_context) =
decrypt_kkt_response_frame(session_secret, encrypted_response_bytes)?;
initiator_ingest_response(
context,
&responder_frame,
&responder_context,
responder_vk,
expected_key_hash,
)
}
/// Decrypts and validates an *Encrypted* KKT response
///
/// This is the client-side operation that processes the gateway's response.
pub fn decrypt_kkt_response_frame(
session_secret: &KKTSessionSecret,
frame_ciphertext: &[u8],
) -> Result<(KKTFrame, KKTContext), KKTError> {
decrypt_kkt_frame(session_secret, frame_ciphertext, KKT_RESPONSE_AAD)
}
/// Handle an *Encrypted* 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
/// * `encrypted_request_bytes` - encrypted KEM request
/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous)
/// * `responder_signing_key` - Gateway's Ed25519 signing key
/// * `responder_dh_public_key` - Gateway's long-term x25519 Diffie-Hellman private 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, R>(
rng: &mut R,
encrypted_request_bytes: &[u8],
initiator_vk: Option<&ed25519::PublicKey>,
responder_signing_key: &ed25519::PrivateKey,
responder_dh_private_key: &x25519::PrivateKey,
responder_kem_key: &EncapsulationKey<'a>,
) -> Result<Vec<u8>, KKTError>
where
R: RngCore + CryptoRng,
{
// Compute the session's shared secret, decrypt and parse context from the request frame
let (session_secret, request_frame, initiator_context) =
decrypt_initial_kkt_frame(responder_dh_private_key, encrypted_request_bytes)?;
// Validate the request (verifies signature if initiator_vk provided)
let (mut response_context, _) = responder_ingest_message(
&initiator_context,
initiator_vk,
None, // Not checking initiator's KEM key in OneWay mode
&request_frame,
)?;
// Generate signed response with our KEM public key
let responder_frame = responder_process(
&mut response_context,
request_frame.session_id(),
responder_signing_key,
responder_kem_key,
)?;
// Encrypt the responder's response with the session's shared secret
encrypt_kkt_frame(rng, &session_secret, &responder_frame, KKT_RESPONSE_AAD)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ciphersuite::{HashFunction, KEM, SignatureScheme},
key_utils::{generate_keypair_libcrux, hash_encapsulation_key},
};
fn random_x25519_key() -> x25519::PrivateKey {
let mut bytes = [0u8; 32];
let mut rng = rand::rng();
rng.fill_bytes(&mut bytes);
x25519::PrivateKey::from_secret(bytes)
}
#[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 ed25519_init = ed25519::KeyPair::from_secret(initiator_secret, 0);
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let ed25519_resp = ed25519::KeyPair::from_secret(responder_secret, 1);
let x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
// 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 (session_key, mut context, request_frame_ciphertext) = request_kem_key(
&mut rng,
ciphersuite,
ed25519_init.private_key(),
&x25519_resp_pub,
)
.unwrap();
// Gateway: Handle request
let response_frame_ciphertext = handle_kem_request(
&mut rng,
&request_frame_ciphertext,
Some(ed25519_init.public_key()), // Authenticated
ed25519_resp.private_key(),
&x25519_resp_priv,
&responder_kem_key,
)
.unwrap();
// Client: Validate response
let obtained_key = validate_kem_response(
&mut context,
&session_key,
ed25519_resp.public_key(),
&key_hash,
&response_frame_ciphertext,
)
.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 x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
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();
// Generate the session's shared secret and encrypt the Initiator's request
let (session_secret, encrypted_request_bytes) =
encrypt_initial_kkt_frame(&mut rng, &x25519_resp_pub, &request_frame).unwrap();
// Gateway: Handle anonymous request
let response_frame = handle_kem_request(
&mut rng,
&encrypted_request_bytes,
None, // Anonymous - no verification key
responder_keypair.private_key(),
&x25519_resp_priv,
&responder_kem_key,
)
.unwrap();
// Initiator: Validate response
let obtained_key = validate_kem_response(
&mut context,
&session_secret,
responder_keypair.public_key(),
&key_hash,
&response_frame,
)
.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);
let x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
// 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 (_session_key, _context, request_frame_ciphertext) = request_kem_key(
&mut rng,
ciphersuite,
initiator_keypair.private_key(),
&x25519_resp_pub,
)
.unwrap();
// Gateway handles request but we provide WRONG verification key
let result = handle_kem_request(
&mut rng,
&request_frame_ciphertext,
Some(wrong_keypair.public_key()), // Wrong key!
responder_keypair.private_key(),
&x25519_resp_priv,
&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 x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
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 (session_key, mut context, request_frame) = request_kem_key(
&mut rng,
ciphersuite,
initiator_keypair.private_key(),
&x25519_resp_pub,
)
.unwrap();
let response_frame = handle_kem_request(
&mut rng,
&request_frame,
Some(initiator_keypair.public_key()),
responder_keypair.private_key(),
&x25519_resp_priv,
&responder_kem_key,
)
.unwrap();
// Client validates with WRONG hash
let result = validate_kem_response(
&mut context,
&session_key,
responder_keypair.public_key(),
&wrong_hash, // Wrong!
&response_frame,
);
// Should fail hash validation
assert!(result.is_err());
}
}
-497
View File
@@ -1,497 +0,0 @@
// 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;
// This must be less than 4 bits
pub const KKT_VERSION: u8 = 1;
const _: () = assert!(KKT_VERSION < 1 << 4);
#[cfg(test)]
mod test {
use crate::kkt::KKT_RESPONSE_AAD;
use crate::{
ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM},
encryption::{
decrypt_initial_kkt_frame, decrypt_kkt_frame, encrypt_initial_kkt_frame,
encrypt_kkt_frame,
},
frame::KKTFrame,
key_utils::{
generate_keypair_ed25519, generate_keypair_libcrux, generate_keypair_mceliece,
generate_keypair_x25519, 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 initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(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(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
let r_bytes = r_frame.to_bytes();
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_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(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
let r_bytes = r_frame.to_bytes();
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.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(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
let r_bytes = r_frame.to_bytes();
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
}
}
}
#[test]
fn test_kkt_psq_e2e_encrypted() {
let mut rng = rand::rng();
// generate ed25519 keys
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
// generate responder x25519 keys
let responder_x25519_keypair = generate_keypair_x25519(&mut rng);
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();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
.unwrap();
// decryption - initiator frame
let (r_session_secret, i_frame_r, i_context_r) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_bytes)
.unwrap();
let (mut r_context, _) =
responder_ingest_message(&i_context_r, None, None, &i_frame_r).unwrap();
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
// decryption - responder frame
let (i_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_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();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
.unwrap();
// decryption - initiator frame
let (r_session_secret, i_frame_r, r_context) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_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(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
// decryption - responder frame
let (i_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.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();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
.unwrap();
// decryption - initiator frame
let (r_session_secret, i_frame_r, i_context_r) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_bytes)
.unwrap();
let (mut r_context, r_obtained_key) = responder_ingest_message(
&i_context_r,
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(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
// decryption - responder frame
let (i_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
}
}
}
}
-230
View File
@@ -1,230 +0,0 @@
use nym_crypto::asymmetric::ed25519::{self, Signature};
use rand::{CryptoRng, RngCore};
use crate::frame::KKTSessionId;
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_frame: &KKTFrame,
remote_context: &KKTContext,
remote_verification_key: &ed25519::PublicKey,
expected_hash: &[u8],
) -> Result<EncapsulationKey<'a>, KKTError> {
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(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_verification_key.verify(bytes_to_verify, &sig) {
Ok(()) => {
let received_encapsulation_key = EncapsulationKey::decode(
own_context.ciphersuite().kem(),
remote_frame.body_ref(),
)?;
match validate_encapsulation_key(
&own_context.ciphersuite().hash_function(),
own_context.ciphersuite().hash_len(),
remote_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: KKTSessionId,
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(())
}
-7
View File
@@ -1,7 +0,0 @@
[package]
name = "nym-lp-common"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[dependencies]
-31
View File
@@ -1,31 +0,0 @@
// 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)
}
-21
View File
@@ -1,21 +0,0 @@
[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
-4
View File
@@ -1,4 +0,0 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod traits;
-38
View File
@@ -1,38 +0,0 @@
// 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(())
}
}
-44
View File
@@ -1,44 +0,0 @@
[package]
name = "nym-lp"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[dependencies]
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 }
tracing = { workspace = true }
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" }
# 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"
nym-crypto = { path = "../crypto", features = ["rand"] }
[[bench]]
name = "replay_protection"
harness = false
-365
View File
@@ -1,365 +0,0 @@
# 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/
-309
View File
@@ -1,309 +0,0 @@
# 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
```
-238
View File
@@ -1,238 +0,0 @@
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
-79
View File
@@ -1,79 +0,0 @@
// 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))),
}
}
}
-93
View File
@@ -1,93 +0,0 @@
// 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,
/// Received an LP packet with an incompatible, future, version
#[error("incompatible LP packet version. got: {got}, highest supported: {highest_supported}")]
IncompatibleFuturePacketVersion { got: u8, highest_supported: u8 },
/// Received an LP packet with an incompatible, legacy, version
#[error("incompatible LP packet version. got: {got}, lowest supported: {lowest_supported}")]
IncompatibleLegacyPacketVersion { got: u8, lowest_supported: u8 },
}
-499
View File
@@ -1,499 +0,0 @@
// 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 (session_secret, client_context, request_data) = create_request(
//! ciphersuite,
//! &client_signing_key,
//! &responder_dh_public_key
//! ).unwrap();
//!
//! // Gateway: Handle request
//! let response_data = handle_request(
//! &request_data,
//! Some(&client_verification_key),
//! &gateway_signing_key,
//! &gateway_dh_private_key,
//! &gateway_kem_public_key,
//! ).unwrap();
//!
//! // Client: Process response
//! let gateway_kem_key = process_response(
//! client_context,
//! &session_secret,
//! &gateway_verification_key,
//! &expected_key_hash,
//! &response_data,
//! ).unwrap();
//! ```
use crate::LpError;
use crate::message::{KKTRequestData, KKTResponseData};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{Ciphersuite, EncapsulationKey};
use nym_kkt::context::KKTContext;
use nym_kkt::encryption::KKTSessionSecret;
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
/// * `responder_dh_public_key` - Gateway's x25519 public key (from directory)
///
/// # Returns
/// * `KKTSessionSecret` - Session secret key to encrypt/decrypt KKT messages for this session
/// * `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,
responder_dh_public_key: &x25519::PublicKey,
) -> Result<(KKTSessionSecret, KKTContext, KKTRequestData), LpError> {
// Note: Uses rand 0.9's thread_rng() to match nym-kkt's rand version
let mut rng = rand09::rng();
let (session_secret, context, request_bytes) =
request_kem_key(&mut rng, ciphersuite, signing_key, responder_dh_public_key)
.map_err(|e| LpError::KKTError(e.to_string()))?;
Ok((session_secret, 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
/// * `session_secret` - The KKT session secret key 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,
session_secret: &KKTSessionSecret,
responder_vk: &ed25519::PublicKey,
expected_key_hash: &[u8],
response_data: &KKTResponseData,
) -> Result<EncapsulationKey<'a>, LpError> {
validate_kem_response(
&mut context,
session_secret,
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_dh_private_key` - Gateway's x25519 private 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_dh_private_key: &x25519::PrivateKey,
responder_kem_key: &EncapsulationKey<'a>,
) -> Result<KKTResponseData, LpError> {
let mut rng = rand09::rng();
// Handle the request and generate response
let response_bytes = handle_kem_request(
&mut rng,
&request_data.0,
initiator_vk,
responder_signing_key,
responder_dh_private_key,
responder_kem_key,
)
.map_err(|e| LpError::KKTError(e.to_string()))?;
Ok(KKTResponseData(response_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use nym_kkt::ciphersuite::{HashFunction, KEM, SignatureScheme};
use nym_kkt::key_utils::{
generate_keypair_ed25519, generate_keypair_libcrux, generate_keypair_x25519,
hash_encapsulation_key,
};
use rand09::RngCore;
#[test]
fn test_kkt_roundtrip_authenticated() {
let mut rng = rand09::rng();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
// 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 (session_secret, context, request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
// Gateway: Handle request
let response_data = handle_request(
&request_data,
Some(initiator_ed25519_keypair.public_key()),
responder_ed25519_keypair.private_key(),
responder_x25519.private_key(),
&responder_kem_key,
)
.unwrap();
// Client: Process response
let obtained_key = process_response(
context,
&session_secret,
responder_ed25519_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)
// // Generate Ed25519 keypairs for both parties
// let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
// let responder_x25519 = generate_keypair_x25519(&mut rng);
// 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,
// responder_ed25519_keypair.private_key(),
// &responder_x25519_sk,
// &responder_kem_key,
// )
// .unwrap();
// // Initiator: Validate response
// let obtained_key = initiator_ingest_response(
// &mut context,
// responder_ed25519_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();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
// 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 (_session_secret, _context, request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_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_ed25519_keypair.private_key(),
responder_x25519.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();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
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 (session_secret, context, request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
let response_data = handle_request(
&request_data,
Some(initiator_ed25519_keypair.public_key()),
responder_ed25519_keypair.private_key(),
responder_x25519.private_key(),
&responder_kem_key,
)
.unwrap();
// Client validates with WRONG hash
let result = process_response(
context,
&session_secret,
responder_ed25519_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_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
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_ed25519_keypair.private_key(),
responder_x25519.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();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (session_secret, context, _request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
// Create malformed response data
let malformed_response = KKTResponseData(vec![0xFF; 100]);
let key_hash = [0u8; 32];
let result = process_response(
context,
&session_secret,
responder_ed25519_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");
}
}
}
-327
View File
@@ -1,327 +0,0 @@
// 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 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 mod state_machine;
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;
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 nym_crypto::asymmetric::{ed25519, x25519};
use std::sync::Arc;
let mut rng = rand::thread_rng();
// X25519 keypairs for Noise protocol
let keypair_1 = Arc::new(x25519::KeyPair::new(&mut rng));
let keypair_2 = Arc::new(x25519::KeyPair::new(&mut rng));
// 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);
let ed25519_keypair1_pubkey = *ed25519_keypair_1.public_key();
// 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,
Arc::new(ed25519_keypair_1),
keypair_1.clone(),
ed25519_keypair_2.public_key(),
keypair_2.public_key(),
&salt,
)
.expect("Test session creation failed");
let responder_session = LpSession::new(
receiver_index,
false,
Arc::new(ed25519_keypair_2),
keypair_2.clone(),
&ed25519_keypair1_pubkey,
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;
use std::sync::Arc;
// 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: [0u8; 3],
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: [0u8; 3],
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: [0u8; 3],
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);
let ed25519_keypair_local_pubkey = *ed25519_keypair_local.public_key();
let x25519_keypair_local_pubkey = ed25519_keypair_local_pubkey.to_x25519().unwrap();
let x25519_keypair_remote_pubkey = ed25519_keypair_remote.public_key().to_x25519().unwrap();
// 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,
Arc::new(ed25519_keypair_local),
ed25519_keypair_remote.public_key(),
&x25519_keypair_remote_pubkey,
true,
&salt,
)
.unwrap();
let _ = remote_manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair_remote),
&ed25519_keypair_local_pubkey,
&x25519_keypair_local_pubkey,
false,
&salt,
)
.unwrap();
// === Packet 1 (Counter 0 - Should succeed) ===
let packet1 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
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: [0u8; 3],
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: [0u8; 3],
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
}
}
-710
View File
@@ -1,710 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{BOOTSTRAP_RECEIVER_IDX, LpError};
use bytes::{BufMut, BytesMut};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use nym_crypto::asymmetric::{ed25519, x25519};
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: x25519::PublicKey,
/// Client's Ed25519 public key (32 bytes) - for PSQ authentication
pub client_ed25519_public_key: ed25519::PublicKey,
/// Salt for PSK derivation (32 bytes: 8-byte timestamp + 24-byte nonce)
pub salt: [u8; 32],
}
impl ClientHelloData {
// 4 bytes for receiver index + 32 bytes for client lp key, 32 bytes for client ed25519 key + 32 bytes for salt
pub const LEN: usize = 100;
fn len(&self) -> usize {
Self::LEN
}
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: x25519::PublicKey,
client_ed25519_public_key: ed25519::PublicKey,
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(&timestamp.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, dst: &mut BytesMut) {
dst.put_u32_le(self.receiver_index);
dst.put_slice(self.client_lp_public_key.as_bytes());
dst.put_slice(self.client_ed25519_public_key.as_bytes());
dst.put_slice(&self.salt);
}
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)]
let client_lp_public_key_bytes = b[4..36].try_into().unwrap();
let client_ed25519_public_key_bytes = b[36..68].try_into().unwrap();
Ok(ClientHelloData {
receiver_index: u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
client_lp_public_key: x25519::PublicKey::from_byte_array(client_lp_public_key_bytes),
client_ed25519_public_key: ed25519::PublicKey::from_byte_array(
client_ed25519_public_key_bytes,
)?,
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>);
impl HandshakeData {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(HandshakeData(bytes.to_vec()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedDataPayload(pub Vec<u8>);
impl EncryptedDataPayload {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(EncryptedDataPayload(bytes.to_vec()))
}
}
/// KKT request frame data (serialized KKTFrame bytes)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KKTRequestData(pub Vec<u8>);
impl KKTRequestData {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(KKTRequestData(bytes.to_vec()))
}
}
/// KKT response frame data (serialized KKTFrame bytes)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KKTResponseData(pub Vec<u8>);
impl KKTResponseData {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(KKTResponseData(bytes.to_vec()))
}
}
/// Packet forwarding request with embedded inner LP packet
#[derive(Debug, Clone)]
pub struct ForwardPacketData {
/// Target gateway's Ed25519 identity (32 bytes)
pub target_gateway_identity: [u8; 32],
// TODO: replace it with `SocketAddr`
/// 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>,
}
impl ForwardPacketData {
fn len(&self) -> usize {
// 32 bytes target gateway identity
// +
// 4 bytes length of target lp address
// +
// target_lp_address.len()
// +
// 4 bytes of length of inner packet bytes
// +
// inner_packet_bytes.len()
32 + 4 + self.target_lp_address.len() + 4 + self.inner_packet_bytes.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.target_gateway_identity);
dst.put_u16_le(self.target_lp_address.len() as u16);
dst.put_slice(self.target_lp_address.as_bytes());
dst.put_u32_le(self.inner_packet_bytes.len() as u32);
dst.put_slice(&self.inner_packet_bytes);
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = BytesMut::new();
self.encode(&mut buf);
buf.into()
}
pub fn decode(bytes: &[u8]) -> Result<Self, LpError> {
// smallest possible packet with empty address and empty data
if bytes.len() < 38 {
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ForwardPacketData[1]. got {}",
bytes.len()
)));
}
// SAFETY: we ensured we have sufficient data
#[allow(clippy::unwrap_used)]
let target_gateway_identity = bytes[0..32].try_into().unwrap();
let target_lp_address_len = u16::from_le_bytes([bytes[32], bytes[33]]);
// smallest possible packet with empty data
if bytes[34..].len() < 4 + target_lp_address_len as usize {
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ForwardPacketData[2]. got {}",
bytes.len()
)));
}
let target_lp_address =
String::from_utf8_lossy(&bytes[34..34 + target_lp_address_len as usize]).to_string();
let inner_packet_bytes_len = u32::from_le_bytes([
bytes[34 + target_lp_address_len as usize],
bytes[34 + target_lp_address_len as usize + 1],
bytes[34 + target_lp_address_len as usize + 2],
bytes[34 + target_lp_address_len as usize + 3],
]);
if bytes[34 + target_lp_address_len as usize + 4..].len() != inner_packet_bytes_len as usize
{
return Err(LpError::DeserializationError(format!(
"Expected {inner_packet_bytes_len} bytes to deserialise inner packet bytes of ForwardPacketData. got {}",
bytes[34 + target_lp_address_len as usize + 4..].len()
)));
}
let inner_packet_bytes = bytes[34 + target_lp_address_len as usize + 4..].to_vec();
Ok(ForwardPacketData {
target_gateway_identity,
target_lp_address,
inner_packet_bytes,
})
}
}
/// Subsession KK1 message - first message of Noise KK handshake
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsessionKK1Data {
/// Noise KK first message payload (ephemeral key + encrypted static)
pub payload: Vec<u8>,
}
impl SubsessionKK1Data {
fn len(&self) -> usize {
self.payload.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.payload);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(SubsessionKK1Data {
payload: bytes.to_vec(),
})
}
}
/// Subsession KK2 message - second message of Noise KK handshake
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsessionKK2Data {
/// Noise KK second message payload (ephemeral key + encrypted response)
pub payload: Vec<u8>,
}
impl SubsessionKK2Data {
fn len(&self) -> usize {
self.payload.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.payload);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(SubsessionKK2Data {
payload: bytes.to_vec(),
})
}
}
/// Subsession ready confirmation with new session index
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsessionReadyData {
/// New subsession's receiver index for routing
pub receiver_index: u32,
}
impl SubsessionReadyData {
pub const LEN: usize = 4;
fn len(&self) -> usize {
Self::LEN
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_u32_le(self.receiver_index);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
if bytes.len() != 4 {
return Err(LpError::DeserializationError(format!(
"Expected 4 bytes to deserialise SubsessionReadyData. got {}",
bytes.len()
)));
}
Ok(SubsessionReadyData {
receiver_index: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
})
}
}
#[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.len(),
LpMessage::EncryptedData(payload) => payload.len(),
LpMessage::ClientHello(payload) => payload.len(),
LpMessage::KKTRequest(payload) => payload.len(),
LpMessage::KKTResponse(payload) => payload.len(),
LpMessage::ForwardPacket(payload) => payload.len(),
LpMessage::Collision => 0,
LpMessage::Ack => 0,
LpMessage::SubsessionRequest => 0,
LpMessage::SubsessionKK1(payload) => payload.len(),
LpMessage::SubsessionKK2(payload) => payload.len(),
LpMessage::SubsessionReady(payload) => payload.len(),
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) => payload.encode(dst),
LpMessage::EncryptedData(payload) => payload.encode(dst),
LpMessage::ClientHello(data) => data.encode(dst),
LpMessage::KKTRequest(payload) => payload.encode(dst),
LpMessage::KKTResponse(payload) => payload.encode(dst),
LpMessage::ForwardPacket(data) => data.encode(dst),
LpMessage::Collision => { /* No content */ }
LpMessage::Ack => { /* No content */ }
LpMessage::SubsessionRequest => { /* No content - signal only */ }
LpMessage::SubsessionKK1(data) => data.encode(dst),
LpMessage::SubsessionKK2(data) => data.encode(dst),
LpMessage::SubsessionReady(data) => data.encode(dst),
LpMessage::SubsessionAbort => { /* No content - signal only */ }
}
}
/// Parse message from its type and content bytes.
///
/// Used when decrypting outer-encrypted packets where the message type
/// was encrypted along with the content.
pub fn decode_content(content: &[u8], message_type: MessageType) -> Result<Self, LpError> {
match message_type {
MessageType::Busy => {
content.ensure_empty()?;
Ok(LpMessage::Busy)
}
MessageType::Handshake => Ok(LpMessage::Handshake(HandshakeData::decode(content)?)),
MessageType::EncryptedData => Ok(LpMessage::EncryptedData(
EncryptedDataPayload::decode(content)?,
)),
MessageType::ClientHello => {
Ok(LpMessage::ClientHello(ClientHelloData::decode(content)?))
}
MessageType::KKTRequest => Ok(LpMessage::KKTRequest(KKTRequestData::decode(content)?)),
MessageType::KKTResponse => {
Ok(LpMessage::KKTResponse(KKTResponseData::decode(content)?))
}
MessageType::ForwardPacket => Ok(LpMessage::ForwardPacket(ForwardPacketData::decode(
content,
)?)),
MessageType::Collision => {
content.ensure_empty()?;
Ok(LpMessage::Collision)
}
MessageType::Ack => {
content.ensure_empty()?;
Ok(LpMessage::Ack)
}
MessageType::SubsessionRequest => {
content.ensure_empty()?;
Ok(LpMessage::SubsessionRequest)
}
MessageType::SubsessionKK1 => Ok(LpMessage::SubsessionKK1(SubsessionKK1Data::decode(
content,
)?)),
MessageType::SubsessionKK2 => Ok(LpMessage::SubsessionKK2(SubsessionKK2Data::decode(
content,
)?)),
MessageType::SubsessionReady => Ok(LpMessage::SubsessionReady(
SubsessionReadyData::decode(content)?,
)),
MessageType::SubsessionAbort => {
content.ensure_empty()?;
Ok(LpMessage::SubsessionAbort)
}
}
}
}
/// Helper trait for improving readability to return error if bytes content is not empty
trait EnsureEmptyContent {
fn ensure_empty(&self) -> Result<(), LpError>;
}
impl EnsureEmptyContent for &[u8] {
fn ensure_empty(&self) -> Result<(), LpError> {
if !self.is_empty() {
return Err(LpError::InvalidPayloadSize {
expected: 0,
actual: self.len(),
});
}
Ok(())
}
}
#[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: [0u8; 3],
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 mut rng = rand::thread_rng();
let ed25519 = ed25519::KeyPair::new(&mut rng);
let x25519 = ed25519.to_x25519();
let client_key = *x25519.public_key();
let client_ed25519_key = *ed25519.public_key();
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 mut rng = rand::thread_rng();
let ed25519 = ed25519::KeyPair::new(&mut rng);
let x25519 = ed25519.to_x25519();
let client_key = *x25519.public_key();
let client_ed25519_key = *ed25519.public_key();
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 mut rng = rand::thread_rng();
let ed25519 = ed25519::KeyPair::new(&mut rng);
let x25519 = ed25519.to_x25519();
let client_key = *x25519.public_key();
let client_ed25519_key = *ed25519.public_key();
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);
}
}
-330
View File
@@ -1,330 +0,0 @@
// 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))
}
-285
View File
@@ -1,285 +0,0 @@
// 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;
use tracing::warn;
#[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;
pub mod version {
/// The current version of the Lewes Protocol that is put into each new constructed header.
pub const CURRENT: u8 = 1;
}
#[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: [u8; 3],
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: version::CURRENT,
reserved: [0u8; 3],
receiver_idx,
counter,
}
}
pub fn encode(&self, dst: &mut BytesMut) {
// protocol version
dst.put_u8(self.protocol_version);
// reserved
dst.put_slice(&self.reserved);
// 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];
// Ensure we are using compatible protocol
// right now only support a single version
if protocol_version > version::CURRENT {
return Err(LpError::IncompatibleFuturePacketVersion {
got: protocol_version,
highest_supported: version::CURRENT,
});
}
if protocol_version < version::CURRENT {
return Err(LpError::IncompatibleLegacyPacketVersion {
got: protocol_version,
lowest_supported: version::CURRENT,
});
}
// skip reserved bytes, but log if they're different from the expected zeroes
let reserved = [src[1], src[2], src[3]];
if reserved != [0u8; 3] {
warn!("received non-zero reserved bytes. got: {reserved:?}");
}
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: [0u8; 3],
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
-792
View File
@@ -1,792 +0,0 @@
// 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 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, x25519};
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: &x25519::PrivateKey,
remote_x25519_public: &x25519::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);
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: &x25519::PrivateKey,
remote_x25519_public: &x25519::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);
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: &x25519::PrivateKey,
remote_x25519_public: &x25519::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);
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: &x25519::PrivateKey,
remote_x25519_public: &x25519::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);
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 rand::thread_rng;
fn generate_x25519_keypair() -> x25519::KeyPair {
x25519::KeyPair::new(&mut thread_rng())
}
#[test]
fn test_psk_derivation_is_symmetric() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
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 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
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 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let keypair_3 = generate_x25519_keypair();
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 = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// 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 = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// 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 = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// 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 = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
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 = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
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");
}
}
-64
View File
@@ -1,64 +0,0 @@
// 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));
}
}
-15
View File
@@ -1,15 +0,0 @@
// 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;
-281
View File
@@ -1,281 +0,0 @@
// 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;
}
}
}
-71
View File
@@ -1,71 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! SIMD optimizations for the replay protection bitmap operations.
//!
//! This module provides architecture-specific SIMD implementations with a common interface.
// Re-export the appropriate implementation
#[cfg(target_arch = "x86_64")]
mod x86;
#[cfg(target_arch = "x86_64")]
pub use self::x86::*;
#[cfg(target_arch = "aarch64")]
mod arm;
#[cfg(target_arch = "aarch64")]
pub use self::arm::*;
// Fallback scalar implementation for all other architectures
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
mod scalar;
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub use self::scalar::*;
/// Trait defining SIMD operations for bitmap manipulation
pub trait BitmapOps {
/// Clear a range of words in the bitmap
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize);
/// Check if a range of words in the bitmap is all zeros
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool;
/// Set a specific bit in the bitmap
fn set_bit(bitmap: &mut [u64], bit_idx: u64);
/// Clear a specific bit in the bitmap
fn clear_bit(bitmap: &mut [u64], bit_idx: u64);
/// Check if a specific bit is set in the bitmap
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool;
}
/// Get the optimal number of words to process in a SIMD operation
/// for the current architecture
#[inline(always)]
pub fn optimal_simd_width() -> usize {
// This value is specialized for each architecture in their respective modules
OPTIMAL_SIMD_WIDTH
}
/// Constant indicating the optimal SIMD processing width in number of u64 words
/// for the current architecture
#[cfg(target_arch = "x86_64")]
#[cfg(target_feature = "avx2")]
pub const OPTIMAL_SIMD_WIDTH: usize = 4; // 256 bits = 4 u64 words
#[cfg(target_arch = "x86_64")]
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
#[cfg(target_arch = "aarch64")]
#[cfg(target_feature = "neon")]
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
// Fallback for non-SIMD platforms or when features aren't available
#[cfg(not(any(
all(target_arch = "x86_64", target_feature = "avx2"),
all(target_arch = "x86_64", target_feature = "sse2"),
all(target_arch = "aarch64", target_feature = "neon")
)))]
pub const OPTIMAL_SIMD_WIDTH: usize = 1; // Scalar fallback
-114
View File
@@ -1,114 +0,0 @@
// 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;
}
}
}
-496
View File
@@ -1,496 +0,0 @@
// 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;
}
}
}
-876
View File
@@ -1,876 +0,0 @@
// 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)
));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-366
View File
@@ -1,366 +0,0 @@
// 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, x25519};
use std::sync::Arc;
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: Arc<ed25519::KeyPair>,
remote_ed25519_key: &ed25519::PublicKey,
remote_x25519_key: &x25519::PublicKey,
is_initiator: bool,
salt: &[u8; 32],
) -> Result<u32, LpError> {
let sm = LpStateMachine::new(
receiver_index,
is_initiator,
local_ed25519_keypair,
remote_ed25519_key,
remote_x25519_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: &x25519::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 ed25519_keypair2 = ed25519::KeyPair::from_secret([16u8; 32], 0);
let x25519_keypair2 = ed25519_keypair2.to_x25519();
let salt = [47u8; 32];
let receiver_index: u32 = 1001;
let sm_1_id = manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair),
ed25519_keypair2.public_key(),
x25519_keypair2.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 ed25519_keypair2 = ed25519::KeyPair::from_secret([16u8; 32], 0);
let x25519_keypair2 = ed25519_keypair2.to_x25519();
let salt = [48u8; 32];
let receiver_index: u32 = 2002;
let sm_1_id = manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair),
ed25519_keypair2.public_key(),
x25519_keypair2.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 pubkey1 = *ed25519_keypair_1.public_key();
let pubkey2 = *ed25519_keypair_2.public_key();
let pubkey3 = *ed25519_keypair_3.public_key();
let xpubkey1 = *ed25519_keypair_1.to_x25519().public_key();
let xpubkey2 = *ed25519_keypair_2.to_x25519().public_key();
let xpubkey3 = *ed25519_keypair_3.to_x25519().public_key();
let sm_1 = manager
.create_session_state_machine(
3001,
Arc::new(ed25519_keypair_1),
&pubkey2,
&xpubkey2,
true,
&salt,
)
.unwrap();
let sm_2 = manager
.create_session_state_machine(
3002,
Arc::new(ed25519_keypair_2),
&pubkey3,
&xpubkey3,
true,
&salt,
)
.unwrap();
let sm_3 = manager
.create_session_state_machine(
3003,
Arc::new(ed25519_keypair_3),
&pubkey1,
&xpubkey1,
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 ed25519_keypair2 = ed25519::KeyPair::from_secret([16u8; 32], 0);
let salt = [50u8; 32];
let receiver_index: u32 = 4004;
let x25519_keypair2 = ed25519_keypair2.to_x25519();
let sm = manager.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair),
ed25519_keypair2.public_key(),
x25519_keypair2.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
-5
View File
@@ -9,7 +9,6 @@ repository = { workspace = true }
[dependencies]
bytes = { workspace = true }
cfg-if = { workspace = true }
tokio-util = { workspace = true, features = ["codec"] }
thiserror = { workspace = true }
tracing = { workspace = true }
@@ -22,7 +21,3 @@ 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 = []
+12 -24
View File
@@ -14,7 +14,7 @@ use nym_sphinx_types::{
};
use std::fmt::Display;
use thiserror::Error;
use tracing::{debug, trace};
use tracing::{debug, info, trace};
#[derive(Debug)]
pub enum MixProcessingResultData {
@@ -364,33 +364,21 @@ fn split_into_ack_and_message(
| PacketSize::ExtendedPacket32
| PacketSize::OutfoxRegularPacket => {
trace!("received a normal packet!");
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))
}
}
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))
}
}
}
#[allow(dead_code)]
fn split_hop_data_into_ack_and_message(
mut extracted_data: Vec<u8>,
packet_type: PacketType,
+1 -8
View File
@@ -12,17 +12,10 @@ license.workspace = true
workspace = true
[dependencies]
bincode = { workspace = true }
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 -10
View File
@@ -1,21 +1,13 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
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};
mod lp_messages;
mod serialisation;
pub use lp_messages::{
LpGatewayData, LpRegistrationRequest, LpRegistrationResponse, RegistrationMode,
};
pub use serialisation::BincodeError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NymNode {
@@ -23,7 +15,6 @@ 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)]
-305
View File
@@ -1,305 +0,0 @@
// 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;
use crate::serialisation::{BincodeError, BincodeOptions, lp_bincode_serializer};
/// 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
}
/// Attempt to serialise this `LpRegistrationRequest` into bytes.
pub fn serialise(&self) -> Result<Vec<u8>, BincodeError> {
lp_bincode_serializer().serialize(self)
}
/// Attempt to deserialise a `LpRegistrationRequest` from bytes.
pub fn try_deserialise(b: &[u8]) -> Result<Self, BincodeError> {
lp_bincode_serializer().deserialize(b)
}
}
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,
}
}
/// Attempt to serialise this `LpRegistrationResponse` into bytes.
pub fn serialise(&self) -> Result<Vec<u8>, BincodeError> {
lp_bincode_serializer().serialize(self)
}
/// Attempt to deserialise a `LpRegistrationResponse` from bytes.
pub fn try_deserialise(b: &[u8]) -> Result<Self, BincodeError> {
lp_bincode_serializer().deserialize(b)
}
}
#[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]);
}
}
-16
View File
@@ -1,16 +0,0 @@
// 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()
}
-3
View File
@@ -15,9 +15,6 @@ 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"] }
-19
View File
@@ -8,12 +8,6 @@ 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))
}
@@ -37,16 +31,3 @@ 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,32 +3,10 @@
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
@@ -39,13 +17,8 @@ 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>>,
@@ -53,41 +26,14 @@ pub struct MockIOStream {
rx: InnerWrapper<Vec<u8>>,
}
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> {
@@ -99,25 +45,6 @@ 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 {
@@ -128,13 +55,11 @@ 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_at_most(unfilled);
let data = guard.take_content();
if data.is_empty() {
// nothing to retrieve - store the waiter so that the sender could trigger it
guard.waker = Some(cx.waker().clone());
@@ -144,7 +69,6 @@ impl AsyncRead for MockIOStream {
return Poll::Pending;
}
self.log_read(data.len());
// if let Some(waker) = guard.waker.take() {
// waker.wake();
// }
@@ -181,8 +105,6 @@ impl AsyncWrite for MockIOStream {
// return Poll::Pending;
// }
self.log_write(buf.len());
Poll::Ready(Ok(len))
}
-43
View File
@@ -106,47 +106,4 @@ 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]);
}
}

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