From ecdeeb096e9bfe888d5287f29326ddb8b2129e58 Mon Sep 17 00:00:00 2001 From: Drazen Urch Date: Thu, 20 Nov 2025 17:22:32 +0100 Subject: [PATCH] KKT + PSQ (#6203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add nymkkt with KKT convenience wrappers for nym-lp integration Integrates nymkkt module from georgio/noise-psq branch to enable post-quantum key distribution for nym-lp. Changes: - Add common/nymkkt from georgio/noise-psq (KKT protocol implementation) - Add convenience wrapper layer (kkt.rs) with simplified API: - request_kem_key() - Client requests gateway's KEM key - validate_kem_response() - Client validates signed response - handle_kem_request() - Gateway handles requests - Add nymkkt to workspace members in root Cargo.toml - Export kkt module in lib.rs The KKT (Key Encapsulation Mechanism Transport) protocol enables efficient distribution of post-quantum KEM public keys. Instead of storing large PQ keys in the directory (1KB-500KB), we store 32-byte hashes and fetch actual keys on-demand via this authenticated protocol. Tests: All 5 unit tests passing (authenticated, anonymous, signature verification, hash validation) * feat(lp): add Ed25519 authentication to PSQ protocol Replace basic PSQ v0 API with authenticated v1 API that includes cryptographic authentication via Ed25519 signatures. Changes: - PSQ initiator now signs encapsulated keys with Ed25519 private key - PSQ responder verifies Ed25519 signatures before deriving PSK - Prevents MITM attacks through mutual authentication - Fixed test helpers to use role-based Ed25519 keypair assignment (initiator uses [1u8;32], responder uses [2u8;32]) Security: This adds a critical authentication layer to the post-quantum PSK derivation protocol, ensuring both parties can verify each other's identity during the handshake. Tests: All 77 tests passing (was 11 failures, now 0) * feat(lp): integrate PSQ post-quantum PSK derivation Complete integration of Post-Quantum Secure (PSQ) protocol for PSK derivation in the Lewes Protocol, replacing simple Blake3 derivation with cryptographically secure DHKEM-based PSK establishment. This commit encompasses three completed tasks: - Add KKTRequest/KKTResponse message types to LpMessage enum - Update codec to handle KKT message serialization/deserialization - Add kkt_orchestrator.rs with high-level KKT API wrappers - Enable key exchange orchestration for PSQ protocol - Add set_psk() method to NoiseProtocol for dynamic PSK injection - Integrate PSQ derivation into LpSession handshake flow - PSQ payload embedded in first Noise message (ClientHello) - Derive PSK using libcrux-psq before Noise handshake completion - Add helper functions for X25519 to KEM conversions - Add comprehensive PSQ integration tests in session_integration/ - Test PSQ handshake end-to-end flow - Validate PSK derivation correctness between initiator/responder - Test PSQ + Noise combined protocol operation Dependencies: - libcrux-psq: Post-quantum PSK protocol implementation - libcrux-kem: Key Encapsulation Mechanism primitives - nym-kkt: KKT key exchange protocol wrappers - rand 0.9: Required for KKT compatibility Security: This adds Harvest-Now-Decrypt-Later (HNDL) resistance by combining classical ECDH with post-quantum KEM for PSK derivation. Even if X25519 is broken by quantum computers, the PSK remains secure. Tests: All 77 tests passing * feat(lp): add PSQ error handling documentation and tests (nym-bbi) Formalize the "always abort" error handling strategy for PSQ failures. PSQ errors indicate attacks, misconfigurations, or protocol violations that should not be silently ignored or worked around. Changes: - Add comprehensive error handling documentation to psk.rs module - Add diagnostic logging with error categorization: * CredError → warn about potential attack * TimestampElapsed → warn about potential replay * Other errors → log as errors - Add 4 error scenario tests: * test_psq_deserialization_failure * test_handshake_abort_on_psq_failure * test_psq_invalid_signature * test_psq_state_unchanged_on_error - Add log dependency to Cargo.toml Error handling strategy: All PSQ failures abort the handshake cleanly with no retry or fallback. This prevents silent security degradation and ensures misconfigurations are detected early. State guarantees: PSQ errors leave session in clean state - dummy PSK remains, Noise HandshakeState unchanged, no partial data, no cleanup needed. Tests: 81 tests passing (77 original + 4 new error tests) Closes: nym-bbi * feat(lp): add PSK injection tracking to prevent dummy PSK usage (nym-ep2) Add safety mechanism to ensure real post-quantum PSK was injected before allowing transport mode operations (encrypt/decrypt). This prevents accidentally using the insecure dummy PSK [0u8; 32] if PSQ injection fails. Changes: - Add `psk_injected: AtomicBool` field to LpSession - Initialize to `false` in LpSession::new() - Set to `true` after successful PSK injection: * Initiator: In prepare_handshake_message() after set_psk() * Responder: In process_handshake_message() after set_psk() - Add NoiseError::PskNotInjected error variant - Add PSK injection checks in encrypt_data() and decrypt_data() * Check happens before handshake completion check * Returns PskNotInjected if flag is false - Add comprehensive PSK injection lifecycle documentation to LpSession - Add test_transport_fails_without_psk_injection test - Update test_encrypt_decrypt_before_handshake to expect PskNotInjected PSK Injection Lifecycle: 1. Session created with dummy PSK [0u8; 32] in Noise HandshakeState 2. During handshake, PSQ runs and derives real post-quantum PSK 3. Real PSK injected via set_psk() - psk_injected flag set to true 4. Handshake completes, transport mode available 5. Transport operations check psk_injected flag for safety This is defensive programming - normal PSQ flow always injects the real PSK. The safety check prevents transport mode if PSQ somehow fails silently or is bypassed due to implementation bugs. Tests: 82 tests passing (81 original + 1 new) Closes: nym-ep2 * docs(lp): fix PSK state documentation inaccuracy Correct error handling documentation to clarify that PSK slot 3 remains unmodified only on error, not in all cases. Previous: "PSK slot 3 = dummy [0u8; 32] (never modified)" Corrected: "PSK slot 3 = dummy [0u8; 32] (not modified on error)" This is more accurate since: - On error: PSK remains as dummy value (never injected) - On success: PSK is replaced with real post-quantum PSK Documentation-only change, no functional impact. * feat(lp): add KKTExchange state to state machine for pre-handshake KEM key transfer (nym-4za) Add KKTExchange state to LpStateMachine to properly orchestrate KKT (KEM Key Transfer) protocol before Noise handshake begins. This enables dynamic KEM public key exchange, allowing post-quantum KEM algorithms to be used without pre-published keys. Changes: - Add KKTExchange state and KKTComplete action to state machine - Implement automatic KKT exchange on StartHandshake: * Initiator: sends KKT request → waits for response → validates signature * Responder: waits for request → validates → sends signed KEM key - Update process_kkt_response() to accept Option<&[u8]> for hash validation: * Some(hash): full KKT validation with directory hash (future) * None: signature-only mode (current deployment) - Add local_x25519_public() helper for responder KEM key derivation - Update state flow: ReadyToHandshake → KKTExchange → Handshaking → Transport - Add PSK handle storage (psk_handle) for future re-registration - Export generate_fresh_salt() for session creation - Update psq_responder_process_message() to return encrypted PSK handle (ctxt_B) - Add comprehensive tests: * test_kkt_exchange_initiator_flow * test_kkt_exchange_responder_flow * test_kkt_exchange_full_roundtrip * test_kkt_exchange_close * test_kkt_exchange_rejects_invalid_inputs * Updated test_state_machine_simplified_flow for KKT phase All tests passing. Ready for nym-8y5 (PSQ handshake KKT integration). * docs(lp): add state machine and post-quantum security protocol documentation Add comprehensive documentation of the Lewes Protocol state machine and post-quantum security architecture to LP_PROTOCOL.md. New sections: - State Machine and Security Protocol overview - Detailed state transition diagram (ReadyToHandshake → KKTExchange → Handshaking → Transport) - Complete message sequence diagram showing KKT + PSQ + Noise flow - KKT (KEM Key Transfer) protocol specification - PSQ (Post-Quantum Secure PSK) protocol details - Security guarantees and implementation status - Algorithm choices (current X25519, future ML-KEM-768) - Message type specifications for KKT - Version 1.1 changelog entry documenting KKT/PSQ integration Documentation includes: - ASCII art state machine diagram - Message sequence diagram with all protocol phases - PSK derivation formulas - Security properties checklist - Migration path to post-quantum KEMs - Integration details (PSQ embedded in Noise, no extra round-trips) Related to nym-4za (KKTExchange state implementation). * feat(lp): use KKT-authenticated KEM key in PSQ handshake (nym-8y5) Replace direct X25519→KEM conversion with KKT-derived authenticated key in PSQ initiator flow. This ensures PSQ uses the responder's authenticated KEM public key obtained via KKT protocol instead of blindly converting their X25519 key, properly completing the post-quantum security chain. Changes: - session.rs: Extract KEM key from KKTState::Completed in prepare_handshake_message() - session.rs: Add set_kkt_completed_for_test() helper for test initialization - session.rs: Update create_handshake_test_session() to initialize KKT state - session.rs: Fix test_handshake_abort_on_psq_failure and test_psq_invalid_signature - session_manager.rs: Add init_kkt_for_test() for integration test setup - session_integration/mod.rs: Update tests for KKT-first flow (6 rounds total) - session_integration/mod.rs: Fix state machine test expectations for KKTExchange state All 87 tests passing. Unblocks nym-w8f (KKT tests) and nym-m15 (production integration). * feat(lp): simplify API to Ed25519-only, derive X25519 internally Refactored LP state machine to use Ed25519 keys exclusively in the public API, with X25519 keys derived internally via RFC 7748. This simplifies the API from 6 parameters to 4 while maintaining protocol security. **Core API Changes:** - LpStateMachine::new(): Removed explicit X25519 keypair parameters - Old: new(is_initiator, local_keypair, local_ed25519_keypair, remote_public_key, remote_ed25519_key, salt) - New: new(is_initiator, local_ed25519_keypair, remote_ed25519_key, salt) - X25519 keys now derived internally from Ed25519 using RFC 7748 - lp_id calculation moved inside state machine (uses derived X25519 keys) **Protocol Changes:** - ClientHello message extended from 65 to 97 bytes - Now includes client_ed25519_public_key field (32 bytes) - Required for PSQ authentication in KKT + PSQ handshake flow - Breaking change: gateway must extract Ed25519 from ClientHello **Gateway Updates:** - receive_client_hello() now extracts Ed25519 public key - LpGatewayHandshake::new_responder() accepts Ed25519 keys only - Removed manual X25519 conversion (handled by state machine) **Registration Client Updates:** - LpRegistrationClient now uses Ed25519 keypairs - Generate fresh ephemeral Ed25519 keys for LP registration - ClientHello includes Ed25519 public key for gateway authentication - Fixed 7 pre-existing build errors: * mixnet_client_startup_timeout field removal * IprClientConnect API change (async → sync) * Error variant renames (use helper function) * LP client key type mismatches (X25519 → Ed25519) **Test Suite:** - Updated 16+ test functions to use new 4-parameter constructor - Fixed 5 integration test failures caused by lp_id mismatch - Tests now derive X25519 from Ed25519 (matching production behavior) - Added missing PublicKey imports in test modules - All 87 tests passing (100% success rate) **Implementation Details:** - Added Ed25519RecoveryError variant to LpError enum - Type conversion: nym_crypto X25519 → nym_lp keypair types - Maintained backward compatibility for PSQ/KKT protocol flow - Session manager updated to use new API signature This change completes the Ed25519-only API migration, hiding X25519 as an implementation detail while preserving all security properties of the KKT-authenticated PSQ handshake protocol. * chore: run cargo fmt * chore: run cargo clippy --fix to resolve simple linter issues * Basic handshake working * Final tweaks * Wrap PR comments, 2024 --------- Co-authored-by: Jędrzej Stuczyński --- .gitignore | 4 +- CLAUDE.md | 700 --------- CODEMAP.md | 452 ------ Cargo.lock | 428 +++++- Cargo.toml | 11 +- FUNCTION_LEXICON.md | 909 ----------- .../types/src/types/rust/NodeConfigUpdate.ts | 6 +- .../types/src/types/rust/NymNode.ts | 7 +- common/credential-verification/src/lib.rs | 10 +- common/nym-kcp/CLAUDE.md | 81 - common/nym-kcp/Cargo.toml | 3 +- common/nym-kcp/src/session.rs | 30 +- common/nym-kkt/Cargo.toml | 47 + common/nym-kkt/benches/benches.rs | 518 +++++++ common/nym-kkt/src/ciphersuite.rs | 301 ++++ common/nym-kkt/src/context.rs | 258 ++++ common/nym-kkt/src/encryption.rs | 95 ++ common/nym-kkt/src/error.rs | 85 ++ common/nym-kkt/src/frame.rs | 129 ++ common/nym-kkt/src/key_utils.rs | 107 ++ common/nym-kkt/src/kkt.rs | 355 +++++ common/nym-kkt/src/lib.rs | 232 +++ common/nym-kkt/src/session.rs | 234 +++ common/nym-lp-common/Cargo.toml | 3 +- common/nym-lp/Cargo.toml | 16 +- common/nym-lp/benches/replay_protection.rs | 2 +- common/nym-lp/src/codec.rs | 31 +- common/nym-lp/src/error.rs | 8 + common/nym-lp/src/keypair.rs | 16 + common/nym-lp/src/kkt_orchestrator.rs | 468 ++++++ common/nym-lp/src/lib.rs | 92 +- common/nym-lp/src/message.rs | 75 +- common/nym-lp/src/noise_protocol.rs | 31 +- common/nym-lp/src/packet.rs | 2 +- common/nym-lp/src/psk.rs | 680 ++++++++- common/nym-lp/src/replay/error.rs | 8 +- common/nym-lp/src/replay/simd/arm.rs | 13 +- common/nym-lp/src/replay/validator.rs | 19 +- common/nym-lp/src/session.rs | 1355 ++++++++++++++++- common/nym-lp/src/session_integration/mod.rs | 414 ++++- common/nym-lp/src/session_manager.rs | 91 +- common/nym-lp/src/state_machine.rs | 626 ++++++-- .../types/src/types/rust/NymNodeBond.ts | 7 +- common/wireguard-types/src/lib.rs | 2 + common/wireguard/Cargo.toml | 4 + common/wireguard/src/error.rs | 3 + common/wireguard/src/ip_pool.rs | 202 +++ common/wireguard/src/lib.rs | 46 +- common/wireguard/src/peer_controller.rs | 116 +- contracts/Cargo.lock | 6 +- contracts/mixnet/src/support/tests/mod.rs | 1 + contracts/performance/src/testing/mod.rs | 1 + docs/LP_PROTOCOL.md | 990 ++++++++++++ gateway/src/error.rs | 9 + gateway/src/node/lp_listener/handler.rs | 147 +- gateway/src/node/lp_listener/handshake.rs | 21 +- gateway/src/node/lp_listener/registration.rs | 72 +- gateway/src/node/mod.rs | 13 +- nym-gateway-probe/src/bandwidth_helpers.rs | 5 +- nym-gateway-probe/src/lib.rs | 79 +- nym-gateway-probe/src/main.rs | 2 + nym-gateway-probe/src/nodes.rs | 24 +- nym-gateway-probe/src/run.rs | 7 +- nym-registration-client/src/builder/config.rs | 4 +- nym-registration-client/src/builder/mod.rs | 8 +- nym-registration-client/src/lib.rs | 97 +- .../src/lp_client/client.rs | 94 +- .../src/lp_client/config.rs | 2 +- .../src/lp_client/error.rs | 4 + nym-wallet/Cargo.lock | 2 + 70 files changed, 8029 insertions(+), 2891 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 CODEMAP.md delete mode 100644 FUNCTION_LEXICON.md delete mode 100644 common/nym-kcp/CLAUDE.md create mode 100644 common/nym-kkt/Cargo.toml create mode 100644 common/nym-kkt/benches/benches.rs create mode 100644 common/nym-kkt/src/ciphersuite.rs create mode 100644 common/nym-kkt/src/context.rs create mode 100644 common/nym-kkt/src/encryption.rs create mode 100644 common/nym-kkt/src/error.rs create mode 100644 common/nym-kkt/src/frame.rs create mode 100644 common/nym-kkt/src/key_utils.rs create mode 100644 common/nym-kkt/src/kkt.rs create mode 100644 common/nym-kkt/src/lib.rs create mode 100644 common/nym-kkt/src/session.rs create mode 100644 common/nym-lp/src/kkt_orchestrator.rs create mode 100644 common/wireguard/src/ip_pool.rs create mode 100644 docs/LP_PROTOCOL.md diff --git a/.gitignore b/.gitignore index eddf33eadc..c4d8a91c01 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,6 @@ nym-api/redocly/formatted-openapi.json **/settings.sql **/enter_db.sh -.beads \ No newline at end of file +.beads +CLAUDE.md +docs \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a196d98d00..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,700 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Nym is a privacy platform that uses mixnet technology to protect against metadata surveillance. The platform consists of several key components: -- Mixnet nodes (mixnodes) for packet mixing -- Gateways (entry/exit points for the network) -- Clients for interacting with the network -- Network monitoring tools -- Validators for network consensus -- Various service providers and integrations - -## Navigation Aids - -This repository includes comprehensive navigation documents for efficient code exploration: - -- **[CODEMAP.md](./CODEMAP.md)**: Structural overview of the entire repository with directory hierarchy, package descriptions, and navigation hints. Use this to quickly understand the codebase layout and find specific components. - -- **[FUNCTION_LEXICON.md](./FUNCTION_LEXICON.md)**: Comprehensive catalog of key functions, signatures, and API patterns across all major modules. Use this to quickly find available functions and understand their usage patterns. - -When working with this codebase, start by consulting these documents to understand the structure and available APIs before diving into specific files. - -## Build Commands - -### Rust Components - -```bash -# Default build (debug) -cargo build - -# Release build -cargo build --release - -# Build a specific package -cargo build -p - -# Build main components -make build - -# Build release versions of main binaries and contracts -make build-release - -# Build specific binaries -make build-nym-cli -cargo build -p nym-node --release -cargo build -p nym-api --release -``` - -### Testing - -```bash -# Run clippy, unit tests, and formatting -make test - -# Run all tests including slow tests -make test-all - -# Run clippy on all workspaces -make clippy - -# Run unit tests for a specific package -cargo test -p - -# Run only expensive/ignored tests -cargo test --workspace -- --ignored - -# Run API tests -dotenv -f envs/sandbox.env -- cargo test --test public-api-tests - -# Run tests with specific log level -RUST_LOG=debug cargo test -p - -# Run specific test scripts -./nym-node/tests/test_apis.sh -./scripts/wireguard-exit-policy/exit-policy-tests.sh -``` - -### Linting and Formatting - -```bash -# Run rustfmt on all code -make fmt - -# Check formatting without modifying -cargo fmt --all -- --check - -# Run clippy with all targets -cargo clippy --workspace --all-targets -- -D warnings - -# TypeScript linting -yarn lint -yarn lint:fix -yarn types:lint:fix - -# Check dependencies for security/licensing issues -cargo deny check -``` - -### WASM Components - -```bash -# Build all WASM components -make sdk-wasm-build - -# Build TypeScript SDK -yarn build:sdk -npx lerna run --scope @nymproject/sdk build --stream - -# Build and test WASM components -make sdk-wasm - -# Build specific WASM packages -cd wasm/client && make -cd wasm/mix-fetch && make -cd wasm/node-tester && make -``` - -### Contract Development - -```bash -# Build all contracts -make contracts - -# Build contracts in release mode -make build-release-contracts - -# Generate contract schemas -make contract-schema - -# Run wasm-opt on contracts -make wasm-opt-contracts - -# Check contracts with cosmwasm-check -make cosmwasm-check-contracts -``` - -### Running Components - -```bash -# Run nym-node as a mixnode -cargo run -p nym-node -- run --mode mixnode - -# Run nym-node as a gateway -cargo run -p nym-node -- run --mode gateway - -# Run the network monitor -cargo run -p nym-network-monitor - -# Run the API server -cargo run -p nym-api - -# Run with specific environment -dotenv -f envs/sandbox.env -- cargo run -p nym-api - -# Start a local network -./scripts/localnet_start.sh -``` - -## Architecture - -The Nym platform consists of various components organized as a monorepo. For a detailed structural overview with directory hierarchy and navigation hints, see [CODEMAP.md](./CODEMAP.md). - -1. **Core Mixnet Infrastructure**: - - `nym-node`: Core binary supporting mixnode and gateway modes - - `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 - -**Note**: Before diving into specific workflows, consult [CODEMAP.md](./CODEMAP.md) to understand the repository structure and [FUNCTION_LEXICON.md](./FUNCTION_LEXICON.md) to discover available APIs and functions. - -### Running a Node - -To run the mixnode or gateway: - -```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 - -For a comprehensive catalog of functions and APIs available in these libraries, see [FUNCTION_LEXICON.md](./FUNCTION_LEXICON.md). - -- `common/types`: Shared data types across all components -- `common/crypto`: Cryptographic primitives and wrappers -- `common/client-core`: Core client functionality -- `common/gateway-client`: Client-gateway communication -- `common/task`: Task management and concurrency utilities -- `common/nymsphinx`: Sphinx packet implementation for mixnet -- `common/topology`: Network topology management -- `common/credentials`: Credential system for privacy-preserving authentication -- `common/bandwidth-controller`: Bandwidth management and accounting - -## Code Conventions - -- Error handling: Use anyhow/thiserror for structured error handling -- Logging: Use the tracing framework for logging and diagnostics -- State management: Generally use Tokio/futures for async code -- Configuration: Use the config crate and env vars with defaults -- Database: Use sqlx for type-safe database queries -- Follow clippy recommendations and rustfmt formatting -- Use semantic commit messages: feat, fix, docs, refactor, test, chore - -## When Making Changes - -- Run `make test` before submitting PRs -- Follow Rust naming conventions -- Use `clippy` to check for common issues -- Update SQLx query caches when modifying DB queries: `cargo sqlx prepare` -- Consider backward compatibility for protocol changes -- Use lefthook pre-commit hooks for TypeScript formatting -- Run `cargo deny check` to verify dependency compliance -- Test against both sandbox and local environments when possible -- Update relevant documentation and CHANGELOG.md - -## Development Tools - -### Useful Cargo Commands - -```bash -# Check for outdated dependencies -cargo outdated - -# Analyze binary size -cargo bloat --release -p nym-node - -# Generate dependency graph -cargo tree -p nym-api - -# Run with instrumentation -cargo run --features profiling -p nym-node - -# Check for security advisories -cargo audit -``` - -### Database Tools - -```bash -# SQLx CLI for migrations -cargo install sqlx-cli - -# Create new migration -cd nym-api && sqlx migrate add - -# Prepare query metadata for offline compilation -cargo sqlx prepare --workspace - -# View database schema -./nym-api/enter_db.sh -``` - -### Development Scripts - -- `scripts/build_topology.py`: Generate network topology files -- `scripts/node_api_check.py`: Verify node API endpoints -- `scripts/network_tunnel_manager.sh`: Manage network tunnels -- `scripts/localnet_start.sh`: Start a local test network -- Various deployment scripts in `deployment/` for different environments - -## Debugging - -- Enable more verbose logging with the RUST_LOG environment variable: - ``` - RUST_LOG=debug,nym_node=trace cargo run -p nym-node -- run --mode mixnode - ``` -- Use the HTTP API endpoints for status information -- Check monitoring data in the database for network performance metrics -- For complex issues, use tracing tools to follow packet flow -- Enable backtraces: `RUST_BACKTRACE=full` -- For WASM debugging: Use browser developer tools with source maps - -## Deployment and Advanced Configurations - -### Deployment Structure - -The `deployment/` directory contains Ansible playbooks and configurations for various deployment scenarios: - -- **`aws/`**: AWS-specific deployment configurations -- **`mixnode/`**: Mixnode deployment playbooks -- **`gateway/`**: Gateway deployment playbooks -- **`validator/`**: Validator node deployment -- **`sandbox-v2/`**: Complete sandbox environment setup -- **`big-dipper-2/`**: Block explorer deployment - -### Sandbox V2 Deployment - -The sandbox-v2 deployment (`deployment/sandbox-v2/`) provides a complete test environment: - -```bash -# Key playbooks: -- deploy.yaml # Main deployment orchestrator -- deploy-mixnodes.yaml # Deploy mixnodes -- deploy-gateways.yaml # Deploy gateways -- deploy-validators.yaml # Deploy validator nodes -- deploy-nym-api.yaml # Deploy API services -``` - -### Custom Environment Setup - -To create a custom environment: - -1. Copy an existing env file: `cp envs/sandbox.env envs/custom.env` -2. Modify the network endpoints and contract addresses -3. Update the `NETWORK_NAME` to your identifier -4. Set appropriate mnemonics and keys (use fresh ones for production!) - -### Contract Addresses - -Contract addresses are network-specific and defined in environment files: -- Mixnet contract: Manages mixnode/gateway registry -- Vesting contract: Handles token vesting schedules -- Coconut contracts: Privacy-preserving credentials -- Name service: Human-readable address mapping -- Ecash contract: Electronic cash functionality - -### Local Network Setup - -For a completely local network: -```bash -# Start local chain -./scripts/localnet_start.sh - -# Deploy contracts -cd contracts -make deploy-local - -# Start nodes with local config -dotenv -f envs/local.env -- cargo run -p nym-node -- run --mode mixnode -``` - -## Common Issues and Troubleshooting - -### Database Issues - -- When modifying database queries, you must update SQLx query caches: - ```bash - cargo sqlx prepare - ``` -- If you see SQLx errors about missing query files, this is likely the cause -- For "database is locked" errors with SQLite, ensure only one process accesses the DB -- For PostgreSQL connection issues, verify DATABASE_URL and that the server is running - -### API Connection Issues - -- Check the environment variables pointing to the APIs (NYM_API, NYXD) -- Verify network connectivity and API health endpoints -- For authentication issues, check node keys and credentials -- Common endpoints to verify: - - API health: `$NYM_API/health` - - Chain status: `$NYXD/status` - - Contract info: `$NYXD/cosmwasm/wasm/v1/contract/$CONTRACT_ADDRESS` - -### Build Problems - -- Clean dependencies with `cargo clean` for a fresh build -- Check for compatible Rust version (1.80+ recommended) -- For smart contract builds, ensure wasm-opt is installed: `npm install -g wasm-opt` -- For cross-compilation issues, check target-specific dependencies -- WASM build issues: Ensure wasm32-unknown-unknown target is installed: - ```bash - rustup target add wasm32-unknown-unknown - ``` -- For "cannot find -lpq" errors, install PostgreSQL development files: - ```bash - # Ubuntu/Debian - sudo apt-get install libpq-dev - # macOS - brew install postgresql - ``` - -### Environment Issues - -- Contract address mismatches: Ensure you're using the correct environment file -- "Account sequence mismatch": The account nonce is out of sync, wait and retry -- Token decimal issues: Sandbox uses different decimal places than mainnet -- API version mismatches: Ensure your local API version matches the network -- "Insufficient funds": Get test tokens from faucet (sandbox) or check balance -- Gateway/mixnode bonding issues: Verify minimum stake requirements - -## Working with Routes and Monitoring - -1. Route monitoring metrics are stored in a `routes` table with: - - Layer node IDs (layer1, layer2, layer3, gw) - - Success flag (boolean) - - Timestamp - -2. To analyze routes: - - Check `NetworkAccount` and `AccountingRoute` in `nym-network-monitor/src/accounting.rs` - - View monitoring logic in `common/nymsphinx/chunking/monitoring.rs` - - Observe how routes are submitted to the database in the `submit_accounting_routes_to_db` function - -## Performance Optimization - -### Profiling and Benchmarking - -```bash -# Run benchmarks -cargo bench -p nym-node - -# Profile with perf (Linux) -cargo build --release --features profiling -perf record --call-graph=dwarf ./target/release/nym-node run --mode mixnode -perf report - -# Generate flamegraph -cargo install flamegraph -cargo flamegraph --bin nym-node -- run --mode mixnode -``` - -### Common Performance Considerations - -- Use bounded channels for backpressure -- Batch database operations where possible -- Monitor memory usage with `RUST_LOG=nym_node::metrics=debug` -- Use connection pooling for database connections -- Consider using `jemalloc` for better memory allocation performance \ No newline at end of file diff --git a/CODEMAP.md b/CODEMAP.md deleted file mode 100644 index 37039c03c8..0000000000 --- a/CODEMAP.md +++ /dev/null @@ -1,452 +0,0 @@ -# Nym Repository Codemap - - - -## Quick Navigation Index - -| Component | Location | Purpose | -|-----------|----------|---------| -| [Main Executables](#main-executables) | Root directories | Core binaries and services | -| [Client Implementations](#client-implementations) | `/clients/` | Various client types | -| [Common Libraries](#common-libraries) | `/common/` | 70+ shared modules | -| [Smart Contracts](#smart-contracts) | `/contracts/` | CosmWasm contracts | -| [SDKs](#sdks) | `/sdk/` | Multi-language SDKs | -| [WASM Modules](#wasm-modules) | `/wasm/` | Browser implementations | -| [Service Providers](#service-providers) | `/service-providers/` | Exit nodes & routers | -| [Tools](#tools-and-utilities) | `/tools/` | CLI tools & utilities | -| [Configuration](#configuration-and-environments) | `/envs/` | Environment configs | - -## Repository Structure Overview - -``` -nym/ -├── Cargo.toml # Workspace manifest (170+ members) -├── Cargo.lock # Locked dependencies -├── Makefile # Build automation -├── CLAUDE.md # Development guidelines -├── envs/ # Environment configurations -│ ├── local.env # Local development -│ ├── sandbox.env # Test network -│ ├── mainnet.env # Production -│ └── canary.env # Pre-release -├── assets/ # Images, logos, fonts -├── docker/ # Docker configurations -└── scripts/ # Deployment & setup scripts -``` - - - -## Main Executables - -### Core Network Nodes - -#### **nym-node** (v1.19.0) - Universal Node Binary -- **Path**: `/nym-node/` -- **Entry**: `src/main.rs` -- **Modes**: `mixnode`, `gateway` -- **Key Modules**: - - `cli/` - Command-line interface - - `config/` - Configuration management - - `node/` - Core node logic - - `wireguard/` - WireGuard VPN integration - - `throughput_tester/` - Performance testing - - - -#### **nym-api** - Network API Server -- **Path**: `/nym-api/` -- **Entry**: `src/main.rs` -- **Database**: PostgreSQL with SQLx -- **Migrations**: `/migrations/` (25+ migration files) -- **Key Subsystems**: - - `circulating_supply_api/` - Token supply tracking - - `ecash/` - E-cash credential management - - `epoch_operations/` - Epoch advancement - - `network_monitor/` - Health monitoring - - `node_performance/` - Performance metrics - - `nym_nodes/` - Node registry - -#### **gateway** (Legacy, v1.1.36) -- **Path**: `/gateway/` -- **Status**: Being phased out for nym-node -- **New**: `src/node/lp_listener/` (branch: drazen/lp-reg) - -### Supporting Services - -| Service | Path | Purpose | -|---------|------|---------| -| `nym-network-monitor` | `/nym-network-monitor/` | Network reliability testing | -| `nym-validator-rewarder` | `/nym-validator-rewarder/` | Reward calculation | -| `nyx-chain-watcher` | `/nyx-chain-watcher/` | Blockchain monitoring | -| `nym-credential-proxy` | `/nym-credential-proxy/` | Credential services | -| `nym-statistics-api` | `/nym-statistics-api/` | Statistics aggregation | -| `nym-node-status-api` | `/nym-node-status-api/` | Node status tracking | - -## Client Implementations - -### Directory: `/clients/` - -``` -clients/ -├── native/ # Native Rust client -│ └── websocket-requests/ # WebSocket protocol -├── socks5/ # SOCKS5 proxy client -├── validator/ # Blockchain validator client -└── webassembly/ # Browser-based client -``` - - - -## Common Libraries - -### Directory: `/common/` (70+ modules) - -### Core Infrastructure -| Module | Purpose | Key Types | -|--------|---------|-----------| -| `nym-common` | Shared utilities | Constants, helpers | -| `types` | Common data types | NodeId, MixId | -| `config` | Configuration system | Config traits | -| `commands` | CLI structures | Command builders | -| `bin-common` | Binary utilities | Logging, banners | - -### Cryptography & Security -| Module | Purpose | Dependencies | -|--------|---------|-------------| -| `crypto` | Crypto primitives | Ed25519, X25519 | -| `credentials` | Credential system | BLS12-381 | -| `credentials-interface` | Interface definitions | - | -| `credential-verification` | Validation logic | - | -| `pemstore` | PEM storage | - | - -### Network Protocol (Sphinx) - - -``` -nymsphinx/ -├── types/ # Core types -├── chunking/ # Message fragmentation -├── forwarding/ # Packet forwarding -├── routing/ # Route selection -├── addressing/ # Address handling -├── anonymous-replies/ # SURB system -├── acknowledgements/ # ACK handling -├── cover/ # Cover traffic -├── params/ # Protocol parameters -└── framing/ # Wire format -``` - -### New Components (Branch: drazen/lp-reg) - - -| Module | Path | Status | -|--------|------|--------| -| `nym-lp` | `/common/nym-lp/` | New LP protocol | -| `nym-lp-common` | `/common/nym-lp-common/` | LP utilities | -| `nym-kcp` | `/common/nym-kcp/` | KCP protocol | - -### Client Systems -``` -client-core/ -├── config-types/ # Configuration types -├── gateways-storage/ # Gateway persistence -└── surb-storage/ # SURB storage - -client-libs/ -├── gateway-client/ # Gateway connection -├── mixnet-client/ # Mixnet interaction -└── validator-client/ # Blockchain queries -``` - -### Additional Common Modules - -**Storage & Data**: -- `statistics/` - Statistical collection -- `topology/` - Network topology -- `node-tester-utils/` - Testing utilities -- `ticketbooks-merkle/` - Merkle trees - -**Advanced Features**: -- `dkg/` - Distributed Key Generation -- `ecash-signer-check/` - E-cash validation -- `nym_offline_compact_ecash/` - Offline e-cash - -**Blockchain**: -- `ledger/` - Ledger operations -- `nyxd-scraper/` - Chain scraping -- `cosmwasm-smart-contracts/` - Contract interfaces - -**Utilities**: -- `task/` - Async task management -- `async-file-watcher/` - File watching -- `nym-cache/` - Caching layer -- `nym-metrics/` - Metrics (Prometheus) -- `bandwidth-controller/` - Bandwidth accounting - -## Smart Contracts - -### Directory: `/contracts/` - - - -``` -contracts/ -├── Cargo.toml # Workspace config -├── .cargo/config.toml # WASM build config -├── coconut-dkg/ # DKG contract -├── ecash/ # E-cash contract -├── mixnet/ # Node registry -├── vesting/ # Token vesting -├── nym-pool/ # Liquidity pool -├── multisig/ # Multi-sig wallet -├── performance/ # Performance tracking -└── mixnet-vesting-integration-tests/ -``` - -### Contract Build Process -```bash -make contracts # Build all -make contract-schema # Generate schemas -make wasm-opt-contracts # Optimize -``` - -## SDKs - -### Directory: `/sdk/` - -``` -sdk/ -├── rust/ -│ └── nym-sdk/ # Primary Rust SDK -├── typescript/ -│ ├── packages/ # NPM packages -│ ├── codegen/ # Code generation -│ └── examples/ # Usage examples -└── ffi/ - ├── cpp/ # C++ bindings - ├── go/ # Go bindings - └── shared/ # Shared FFI code -``` - -## WASM Modules - -### Directory: `/wasm/` - -| Module | Purpose | Build Command | -|--------|---------|---------------| -| `client` | Browser client | `make` in directory | -| `mix-fetch` | Privacy fetch API | `make` in directory | -| `node-tester` | Network testing | `make` in directory | -| `zknym-lib` | Zero-knowledge lib | `make` in directory | - - - -## Service Providers - -### Directory: `/service-providers/` - -``` -service-providers/ -├── network-requester/ # Exit node for external requests -├── ip-packet-router/ # IP packet routing (VPN-like) -└── common/ # Shared utilities -``` - -## Tools and Utilities - -### Directory: `/tools/` - -### Public Tools -| Tool | Path | Purpose | -|------|------|---------| -| `nym-cli` | `/tools/nym-cli/` | Node management CLI | -| `nym-id-cli` | `/tools/nym-id-cli/` | Identity management | -| `nymvisor` | `/tools/nymvisor/` | Process supervisor | -| `nym-nr-query` | `/tools/nym-nr-query/` | Network queries | -| `echo-server` | `/tools/echo-server/` | Testing server | - -### Internal Tools -``` -internal/ -├── mixnet-connectivity-check/ # Network diagnostics -├── contract-state-importer/ # Migration tools -├── validator-status-check/ # Validator health -├── ssl-inject/ # SSL injection -├── testnet-manager/ # Testnet management -└── sdk-version-bump/ # Version management -``` - -## Configuration and Environments - -### Environment Files: `/envs/` - - - -| Environment | File | API Endpoint | Use Case | -|------------|------|--------------|----------| -| Local | `local.env` | localhost | Development | -| Sandbox | `sandbox.env` | sandbox-nym-api1.nymtech.net | Testing | -| Mainnet | `mainnet.env` | validator.nymtech.net | Production | -| Canary | `canary.env` | - | Pre-release | - -### Key Environment Variables -```bash -NETWORK_NAME # Network identifier -NYM_API # API endpoint -NYXD # Blockchain RPC -MIXNET_CONTRACT_ADDRESS # Contract addresses -MNEMONIC # Test mnemonic (NEVER in production) -RUST_LOG # Logging level -DATABASE_URL # PostgreSQL connection -``` - -## Build System - -### Primary Build Commands -```bash -make build # Debug build -make build-release # Release build -make test # Run tests -make clippy # Lint code -make fmt # Format code -make contracts # Build contracts -make sdk-wasm-build # Build WASM -``` - -### Workspace Configuration - - - -**Root Cargo.toml Structure**: -- `[workspace]` - Lists all 170+ members -- `[workspace.dependencies]` - Shared dependency versions -- `[workspace.lints]` - Shared lint rules -- `[profile.*]` - Build profiles - -## Database Structure - -### SQLx Usage Pattern -- **Compile-time verified**: All queries checked at build -- **Migration files**: In package `/migrations/` directories -- **Query cache**: `.sqlx/` directory - -### Key Tables (nym-api) -```sql --- Network monitoring -mixnode_status -gateway_status -routes -monitor_run - --- Node registry -nym_nodes -node_descriptions - --- Performance -node_uptime -node_performance -``` - -## Current Branch Context (drazen/lp-reg) - -### New Additions -- `/common/nym-lp/` - Low-level protocol implementation -- `/common/nym-lp-common/` - LP common utilities -- `/common/nym-kcp/` - KCP protocol -- `/gateway/src/node/lp_listener/` - LP listener - -### Modified Files -``` -M Cargo.lock -M Cargo.toml -M common/registration/ -M common/wireguard/ -M gateway/ -M nym-node/ -M nym-node/nym-node-metrics/ -``` - -## Navigation Patterns - - - -### Finding Code by Type -| Code Type | Look In | -|-----------|---------| -| Main executables | Root directories with `src/main.rs` | -| Libraries | `/common/` with descriptive names | -| Contracts | `/contracts/[name]/src/contract.rs` | -| Tests | Colocated with source, `#[cfg(test)]` | -| Configurations | `/envs/` and `config/` subdirs | -| Database queries | Files with `.sql` or SQLx macros | -| API endpoints | `/nym-api/src/` subdirectories | -| CLI commands | `/cli/commands/` in executables | - -### Common Import Locations -```rust -// Crypto -use nym_crypto::asymmetric::{ed25519, x25519}; - -// Network -use nym_sphinx::forwarding::packet::MixPacket; -use nym_topology::NymTopology; - -// Client -use nym_client_core::client::Client; - -// Configuration -use nym_network_defaults::NymNetworkDetails; - -// Contracts -use nym_mixnet_contract_common::*; -``` - -## Module Relationships - - - -### Dependency Graph (Simplified) -``` -nym-node -├── common/nym-common -├── common/crypto -├── common/nymsphinx -├── common/topology -├── common/client-libs/validator-client -└── common/wireguard - -nym-api -├── common/nym-common -├── nym-api-requests -├── common/client-libs/validator-client -├── common/credentials -└── sqlx (database) - -clients/native -├── common/client-core -├── common/client-libs/gateway-client -├── common/nymsphinx -└── common/credentials -``` - -## Development Workflows - -### Adding New Feature -1. Check `/envs/` for configuration -2. Find similar code in `/common/` -3. Implement in appropriate module -4. Add tests colocated with code -5. Update `/nym-api/` if needed -6. Run `make test` and `make clippy` - -### Debugging Network Issues -1. Start with `/nym-network-monitor/` -2. Check `/common/topology/` for routing -3. Review `/common/nymsphinx/` for protocol -4. Examine logs with `RUST_LOG=debug` - -### Contract Development -1. Create in `/contracts/[name]/` -2. Use existing contracts as templates -3. Build with `make contracts` -4. Test with `cw-multi-test` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4b9e037c37..cefa200acc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,6 +1274,16 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "classic-mceliece-rust" +version = "3.2.0" +source = "git+https://github.com/georgio/classic-mceliece-rust#f2f27048b621df103bbe64369a18174ffec04ae1" +dependencies = [ + "rand 0.9.2", + "sha3", + "zeroize", +] + [[package]] name = "coarsetime" version = "0.1.36" @@ -1447,6 +1457,16 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.9.2", +] + [[package]] name = "cosmos-sdk-proto" version = "0.26.1" @@ -1874,6 +1894,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", + "rand_core 0.6.4", "rustc_version 0.4.1", "serde", "subtle 2.6.1", @@ -3174,6 +3195,43 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "hax-lib" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -4122,6 +4180,15 @@ dependencies = [ "signature", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "keystream" version = "1.0.0" @@ -4200,6 +4267,213 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-ed25519" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "rand_core 0.9.3", + "tls_codec", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.2" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-psq" +version = "0.0.5" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-chacha20poly1305", + "libcrux-ecdh", + "libcrux-ed25519", + "libcrux-hkdf", + "libcrux-hmac", + "libcrux-kem", + "libcrux-ml-kem", + "libcrux-sha2", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.4" +source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea" +dependencies = [ + "libcrux-secrets", + "rand 0.9.2", +] + [[package]] name = "libm" version = "0.2.15" @@ -4829,6 +5103,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -4840,7 +5136,7 @@ dependencies = [ [[package]] name = "nym-api" -version = "1.1.69" +version = "1.1.68" dependencies = [ "anyhow", "async-trait", @@ -5003,7 +5299,6 @@ dependencies = [ "nym-network-defaults", "nym-service-provider-requests-common", "nym-sphinx", - "nym-test-utils", "nym-wireguard-types", "rand 0.8.5", "semver 1.0.26", @@ -5011,7 +5306,6 @@ dependencies = [ "sha2 0.10.9", "strum_macros", "thiserror 2.0.12", - "tracing", "x25519-dalek", ] @@ -5020,16 +5314,21 @@ name = "nym-bandwidth-controller" version = "0.1.0" dependencies = [ "async-trait", + "bip39", "log", "nym-credential-storage", "nym-credentials", "nym-credentials-interface", "nym-crypto", + "nym-ecash-contract-common", "nym-ecash-time", + "nym-network-defaults", "nym-task", "nym-validator-client", "rand 0.8.5", "thiserror 2.0.12", + "url", + "zeroize", ] [[package]] @@ -5063,7 +5362,7 @@ dependencies = [ [[package]] name = "nym-cli" -version = "1.1.66" +version = "1.1.65" dependencies = [ "anyhow", "base64 0.22.1", @@ -5146,7 +5445,7 @@ dependencies = [ [[package]] name = "nym-client" -version = "1.1.66" +version = "1.1.65" dependencies = [ "bs58", "clap", @@ -5582,7 +5881,6 @@ dependencies = [ "sqlx", "sqlx-pool-guard", "thiserror 2.0.12", - "time", "tokio", "zeroize", ] @@ -5618,14 +5916,13 @@ dependencies = [ "nym-api-requests", "nym-credentials", "nym-credentials-interface", - "nym-crypto", "nym-ecash-contract-common", "nym-gateway-requests", "nym-gateway-storage", "nym-metrics", "nym-task", - "nym-upgrade-mode-check", "nym-validator-client", + "rand 0.8.5", "si-scale", "thiserror 2.0.12", "time", @@ -5665,7 +5962,6 @@ dependencies = [ "nym-compact-ecash", "nym-ecash-time", "nym-network-defaults", - "nym-upgrade-mode-check", "rand 0.8.5", "serde", "strum", @@ -5817,6 +6113,7 @@ dependencies = [ name = "nym-gateway" version = "1.1.36" dependencies = [ + "anyhow", "async-trait", "bincode", "bip39", @@ -5828,6 +6125,7 @@ dependencies = [ "futures", "ipnetwork", "mock_instant", + "nym-api-requests", "nym-authenticator-requests", "nym-client-core", "nym-credential-verification", @@ -5843,6 +6141,7 @@ dependencies = [ "nym-lp", "nym-metrics", "nym-mixnet-client", + "nym-mixnode-common", "nym-network-defaults", "nym-network-requester", "nym-node-metrics", @@ -5853,18 +6152,20 @@ dependencies = [ "nym-statistics-common", "nym-task", "nym-topology", - "nym-upgrade-mode-check", + "nym-types", "nym-validator-client", "nym-wireguard", "nym-wireguard-private-metadata-server", "nym-wireguard-types", "rand 0.8.5", "serde", + "sha2 0.10.9", "thiserror 2.0.12", "time", "tokio", "tokio-stream", "tokio-tungstenite", + "tokio-util", "tracing", "url", "zeroize", @@ -6083,7 +6384,6 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tracing", - "tracing-subscriber", "url", "wasmtimer", ] @@ -6247,6 +6547,35 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "nym-kkt" +version = "0.1.0" +dependencies = [ + "aead", + "arc-swap", + "blake3", + "bytes", + "classic-mceliece-rust", + "criterion", + "curve25519-dalek", + "futures", + "libcrux-ecdh", + "libcrux-kem", + "libcrux-ml-kem", + "libcrux-psq", + "libcrux-sha3", + "libcrux-traits", + "nym-crypto", + "pin-project", + "rand 0.9.2", + "strum", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", + "zeroize", +] + [[package]] name = "nym-ledger" version = "0.1.0" @@ -6268,16 +6597,24 @@ dependencies = [ "bytes", "criterion", "dashmap", + "libcrux-kem", + "libcrux-psq", + "libcrux-traits", + "num_enum", "nym-crypto", + "nym-kkt", "nym-lp-common", "nym-sphinx", "parking_lot", "rand 0.8.5", + "rand 0.9.2", "rand_chacha 0.3.1", "serde", "sha2 0.10.9", "snow", "thiserror 2.0.12", + "tls_codec", + "tracing", "utoipa", ] @@ -6428,7 +6765,7 @@ dependencies = [ [[package]] name = "nym-network-requester" -version = "1.1.67" +version = "1.1.66" dependencies = [ "addr", "anyhow", @@ -6478,7 +6815,7 @@ dependencies = [ [[package]] name = "nym-node" -version = "1.21.0" +version = "1.20.0" dependencies = [ "anyhow", "arc-swap", @@ -6509,7 +6846,6 @@ dependencies = [ "nym-bin-common", "nym-client-core-config-types", "nym-config", - "nym-credential-verification", "nym-crypto", "nym-gateway", "nym-gateway-stats-storage", @@ -6582,13 +6918,13 @@ version = "0.1.0" dependencies = [ "async-trait", "celes", + "humantime", "humantime-serde", "nym-bin-common", "nym-crypto", "nym-exit-policy", "nym-http-api-client", "nym-noise-keys", - "nym-upgrade-mode-check", "nym-wireguard-types", "rand_chacha 0.3.1", "schemars 0.8.22", @@ -6599,7 +6935,6 @@ dependencies = [ "thiserror 2.0.12", "time", "tokio", - "url", "utoipa", ] @@ -7018,7 +7353,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.66" +version = "1.1.65" dependencies = [ "bs58", "clap", @@ -7652,24 +7987,36 @@ dependencies = [ name = "nym-wireguard" version = "0.1.0" dependencies = [ + "async-trait", "base64 0.22.1", + "bincode", + "chrono", + "dashmap", "defguard_wireguard_rs", + "dyn-clone", "futures", "ip_network", + "ipnetwork", + "log", + "nym-authenticator-requests", "nym-credential-verification", "nym-credentials-interface", "nym-crypto", "nym-gateway-requests", "nym-gateway-storage", + "nym-ip-packet-requests", "nym-metrics", "nym-network-defaults", "nym-node-metrics", "nym-task", "nym-wireguard-types", + "rand 0.8.5", "thiserror 2.0.12", + "time", "tokio", "tokio-stream", "tracing", + "x25519-dalek", ] [[package]] @@ -7721,20 +8068,15 @@ version = "1.0.0" dependencies = [ "async-trait", "axum", - "futures", "nym-credential-verification", "nym-credentials-interface", - "nym-crypto", "nym-http-api-client", "nym-http-api-common", - "nym-upgrade-mode-check", "nym-wireguard", "nym-wireguard-private-metadata-client", "nym-wireguard-private-metadata-server", "nym-wireguard-private-metadata-shared", - "time", "tokio", - "tower 0.5.2", "tower-http 0.5.2", "utoipa", ] @@ -7744,7 +8086,10 @@ name = "nym-wireguard-types" version = "0.1.0" dependencies = [ "base64 0.22.1", + "log", + "nym-config", "nym-crypto", + "nym-network-defaults", "rand 0.8.5", "serde", "thiserror 2.0.12", @@ -7753,7 +8098,7 @@ dependencies = [ [[package]] name = "nymvisor" -version = "0.1.31" +version = "0.1.30" dependencies = [ "anyhow", "bytes", @@ -8107,6 +8452,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "peg" version = "0.8.5" @@ -9852,6 +10203,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -10838,6 +11199,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tokio" version = "1.47.1" diff --git a/Cargo.toml b/Cargo.toml index d6e5f7464a..8bf7cc2533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ members = [ "common/nym-kcp", "common/nym-lp", "common/nym-lp-common", + "common/nym-kkt", "common/nym-metrics", "common/nym_offline_compact_ecash", "common/nymnoise", @@ -153,7 +154,7 @@ members = [ "tools/internal/contract-state-importer/importer-cli", "tools/internal/contract-state-importer/importer-contract", "tools/internal/mixnet-connectivity-check", -# "tools/internal/sdk-version-bump", + # "tools/internal/sdk-version-bump", "tools/internal/ssl-inject", "tools/internal/testnet-manager", "tools/internal/testnet-manager/dkg-bypass-contract", @@ -168,7 +169,7 @@ members = [ "wasm/mix-fetch", "wasm/node-tester", "wasm/zknym-lib", - "nym-gateway-probe" + "nym-gateway-probe", ] default-members = [ @@ -288,7 +289,9 @@ inventory = "0.3.21" ip_network = "0.4.1" ipnetwork = "0.20" itertools = "0.14.0" -jwt-simple = { version = "0.12.12", default-features = false, features = ["pure-rust"] } +jwt-simple = { version = "0.12.12", default-features = false, features = [ + "pure-rust", +] } k256 = "0.13" lazy_static = "1.5.0" ledger-transport = "0.10.0" @@ -298,6 +301,7 @@ mime = "0.3.17" moka = { version = "0.12", features = ["future"] } nix = "0.27.1" notify = "5.1.0" +num_enum = "0.7.5" once_cell = "1.21.3" opentelemetry = "0.19.0" opentelemetry-jaeger = "0.18.0" @@ -344,6 +348,7 @@ test-with = { version = "0.15.4", default-features = false } tempfile = "3.20" thiserror = "2.0" time = "0.3.41" +tls_codec = "0.4.1" tokio = "1.47" tokio-postgres = "0.7" tokio-stream = "0.1.17" diff --git a/FUNCTION_LEXICON.md b/FUNCTION_LEXICON.md deleted file mode 100644 index d71fbc038f..0000000000 --- a/FUNCTION_LEXICON.md +++ /dev/null @@ -1,909 +0,0 @@ -# Nym Function Lexicon - - - -## Quick Reference Index - -| Category | Section | Key Operations | -|----------|---------|----------------| -| [Node Operations](#1-node-operations) | Mixnode & Gateway | Initialization, key management, tasks | -| [Sphinx Protocol](#2-sphinx-packet-protocol) | Packet Processing | Message creation, chunking, routing | -| [Client APIs](#3-client-apis) | Client Operations | Connection, sending, receiving | -| [Network Topology](#4-network-topology) | Routing | Topology queries, route selection | -| [Blockchain](#5-blockchain-operations) | Validator Client | Queries, transactions, contracts | -| [REST APIs](#6-rest-api-endpoints) | HTTP Handlers | API routes and responses | -| [Credentials](#7-credential--ecash) | E-cash | Credential creation, verification | -| [Smart Contracts](#8-smart-contracts) | CosmWasm | Entry points, messages | -| [Common Patterns](#9-common-patterns) | Conventions | Naming, errors, async | - ---- - -## 1. Node Operations - -### nym-node Core Functions - - -**Module**: `nym-node/src/node/mod.rs` - -```rust -// Node initialization -pub async fn initialise_node( - config: &Config, - rng: &mut impl CryptoRng + RngCore, -) -> Result - -// Key management -pub fn load_x25519_wireguard_keypair( - paths: &KeysPaths, -) -> Result - -pub fn load_ed25519_identity_keypair( - paths: &KeysPaths, -) -> Result - -// Gateway-specific initialization -impl GatewayTasksData { - pub async fn new( - config: &GatewayTasksConfig, - client_storage: ClientStorage, - ) -> Result - - pub fn initialise( - config: &GatewayTasksConfig, - force_init: bool, - ) -> Result<(), GatewayError> -} - -// Service provider initialization -impl ServiceProvidersData { - pub fn initialise_client_keys( - rng: &mut R, - gateway_paths: &GatewayPaths, - ) -> Result - - pub async fn initialise_network_requester( - rng: &mut R, - config: &Config, - ) -> Result, GatewayError> -} -``` - -### Gateway Task Builder Pattern -**Module**: `gateway/src/node/mod.rs` - -```rust -pub struct GatewayTasksBuilder { - // Builder methods - pub fn new( - identity_keypair: Arc, - config: Config, - client_storage: ClientStorage, - ) -> GatewayTasksBuilder - - pub fn set_network_requester_opts( - &mut self, - opts: Option - ) -> &mut Self - - pub fn set_ip_packet_router_opts( - &mut self, - opts: Option - ) -> &mut Self - - pub async fn build_and_run( - self, - shutdown: TaskManager, - ) -> Result<(), GatewayError> -} -``` - - - ---- - -## 2. Sphinx Packet Protocol - -### Message Construction & Processing -**Module**: `common/nymsphinx/src/message.rs` - -```rust -// Core message types -pub enum NymMessage { - Plain(Vec), - Repliable(RepliableMessage), - Reply(ReplyMessage), -} - -impl NymMessage { - // Constructors - pub fn new_plain(msg: Vec) -> NymMessage - pub fn new_repliable(msg: RepliableMessage) -> NymMessage - pub fn new_reply(msg: ReplyMessage) -> NymMessage - pub fn new_additional_surbs_request( - recipient: Recipient, - amount: u32 - ) -> NymMessage - - // Processing - pub fn pad_to_full_packet_lengths( - self, - plaintext_per_packet: usize - ) -> PaddedMessage - - pub fn split_into_fragments( - self, - rng: &mut R, - packet_size: PacketSize, - ) -> Vec - - pub fn remove_padding(self) -> Result - - // Queries - pub fn is_reply_surb_request(&self) -> bool - pub fn available_sphinx_plaintext_per_packet( - &self, - packet_size: PacketSize - ) -> usize - pub fn required_packets(&self, packet_size: PacketSize) -> usize -} -``` - -### Payload Building & Preparation -**Module**: `common/nymsphinx/src/preparer.rs` - -```rust -pub struct NymPayloadBuilder { - // Main preparation methods - pub async fn prepare_chunk_for_sending( - &mut self, - message: NymMessage, - topology: &NymTopology, - ) -> Result, NymPayloadBuilderError> - - pub async fn prepare_reply_chunk_for_sending( - &mut self, - reply: NymMessage, - reply_surb: ReplySurb, - ) -> Result, NymPayloadBuilderError> - - // SURB generation - pub fn generate_reply_surbs( - &mut self, - amount: u32, - topology: &NymTopology, - ) -> Result, NymPayloadBuilderError> - - // Fragment splitting - pub fn pad_and_split_message( - &mut self, - message: NymMessage, - ) -> Result, NymPayloadBuilderError> -} - -// Builder constructors -pub fn build_regular( - rng: R, - sender_address: Option, -) -> NymPayloadBuilder - -pub fn build_reply( - sender_address: Recipient, - sender_tag: AnonymousSenderTag, -) -> NymPayloadBuilder -``` - -### Chunking & Fragmentation -**Module**: `common/nymsphinx/chunking/src/lib.rs` - - - -```rust -// Main chunking function -pub fn split_into_sets( - message: &[u8], - max_plaintext_size: usize, - max_fragments_per_set: usize, -) -> Result>, ChunkingError> - -// Fragment monitoring (optional feature) -pub mod monitoring { - pub fn enable() - pub fn enabled() -> bool - pub fn fragment_received(fragment: &Fragment) - pub fn fragment_sent( - fragment: &Fragment, - client_nonce: i32, - destination: PublicKey - ) -} -``` - ---- - -## 3. Client APIs - -### Gateway Client -**Module**: `common/client-libs/gateway-client/src/lib.rs` - -```rust -pub struct GatewayClient { - // Connection management - pub async fn connect( - config: GatewayClientConfig, - ) -> Result - - pub async fn authenticate( - &mut self, - credentials: Credentials, - ) -> Result<(), GatewayClientError> - - // Message operations - pub async fn send_mix_packet( - &self, - packet: MixPacket, - ) -> Result<(), GatewayClientError> - - pub async fn receive_messages( - &mut self, - ) -> Result, GatewayClientError> -} - -// Packet routing -pub struct PacketRouter { - pub fn new( - mix_tx: MixnetMessageSender, - ack_tx: AcknowledgementSender, - ) -> PacketRouter - - pub async fn route_packet( - &self, - packet: MixPacket, - ) -> Result<(), PacketRouterError> -} -``` - -### Mixnet Client -**Module**: `common/client-libs/mixnet-client/src/lib.rs` - -```rust -pub struct Client { - // Core client operations - pub async fn new(config: Config) -> Result - - pub async fn send_message( - &mut self, - recipient: Recipient, - message: Vec, - ) -> Result<(), ClientError> - - pub async fn receive_message( - &mut self, - ) -> Result - - // Connection management - pub fn is_connected(&self) -> bool - pub async fn reconnect(&mut self) -> Result<(), ClientError> -} - -// Send without response trait -pub trait SendWithoutResponse { - fn send_without_response( - &self, - packet: MixPacket, - ) -> io::Result<()> -} -``` - - - -### Client Core Initialization -**Module**: `common/client-core/src/init.rs` - -```rust -// Key generation -pub fn generate_new_client_keys( - rng: &mut R, -) -> (ed25519::KeyPair, x25519::KeyPair) - -// Storage initialization -pub async fn init_storage( - paths: &ClientPaths, -) -> Result - -// Configuration setup -pub fn setup_client_config( - id: &str, - network: Network, -) -> Result -``` - ---- - -## 4. Network Topology - -### Topology Management -**Module**: `common/topology/src/lib.rs` - -```rust -pub struct NymTopology { - // Query methods - pub fn mixnodes(&self) -> &[RoutingNode] - pub fn gateways(&self) -> &[RoutingNode] - pub fn layer_nodes(&self, layer: MixLayer) -> Vec<&RoutingNode> - - // Route selection - pub fn random_route( - &self, - rng: &mut R, - ) -> Option> - - pub fn get_node_by_id(&self, node_id: NodeId) -> Option<&RoutingNode> -} - -// Route provider -pub struct NymRouteProvider { - pub fn new(topology: NymTopology) -> NymRouteProvider - - pub fn random_route( - &self, - rng: &mut R, - ) -> Option> -} - -// Topology provider trait -pub trait TopologyProvider { - async fn get_topology(&self) -> Result - async fn refresh_topology(&mut self) -> Result<(), NymTopologyError> -} -``` - -### Routing Node -**Module**: `common/topology/src/node.rs` - -```rust -pub struct RoutingNode { - pub fn node_id(&self) -> NodeId - pub fn identity_key(&self) -> &ed25519::PublicKey - pub fn sphinx_key(&self) -> &x25519::PublicKey - pub fn mix_host(&self) -> &SocketAddr - pub fn clients_ws_address(&self) -> Option<&Url> -} -``` - ---- - -## 5. Blockchain Operations - -### Validator Client -**Module**: `common/client-libs/validator-client/src/client.rs` - - - -```rust -pub struct Client { - // Contract queries - pub async fn query_contract_state( - &self, - contract: &str, - query: T, - ) -> Result - where T: Into - - // Transaction execution (requires signer) - pub async fn execute_contract_message( - &self, - contract: &str, - msg: M, - funds: Vec, - ) -> Result - where M: Into - - // Specific contract operations - pub async fn bond_mixnode( - &self, - mixnode: MixNode, - cost_params: MixNodeCostParams, - pledge: Coin, - ) -> Result - - pub async fn unbond_mixnode(&self) -> Result - - pub async fn delegate_to_mixnode( - &self, - mix_id: MixId, - amount: Coin, - ) -> Result -} - -// Nyxd-specific client -pub type DirectSigningHttpRpcNyxdClient = - nyxd::NyxdClient; -``` - -### Contract Queries -**Module**: `common/client-libs/validator-client/src/nyxd/contract_traits/` - -```rust -// Mixnet contract queries -pub trait MixnetQueryClient { - async fn get_mixnodes(&self) -> Result, NyxdError> - async fn get_gateways(&self) -> Result, NyxdError> - async fn get_current_epoch(&self) -> Result - async fn get_rewarded_set(&self) -> Result -} - -// Vesting contract queries -pub trait VestingQueryClient { - async fn get_vesting_details(&self, address: &str) - -> Result -} - -// E-cash contract queries -pub trait EcashQueryClient { - async fn get_deposit(&self, id: DepositId) - -> Result -} -``` - ---- - -## 6. REST API Endpoints - -### nym-api Main Routes -**Module**: `nym-api/src/main.rs` and submodules - - - -```rust -// Main API setup -#[tokio::main] -async fn main() -> Result<(), anyhow::Error> { - // Router configuration - let app = Router::new() - .merge(api_routes()) - .merge(swagger_ui()) - .layer(cors_layer()) - .layer(trace_layer()); -} - -// Core API routes (various modules) -pub fn api_routes() -> Router { - Router::new() - .nest("/v1/status", status_routes()) - .nest("/v1/mixnodes", mixnode_routes()) - .nest("/v1/gateways", gateway_routes()) - .nest("/v1/network", network_routes()) - .nest("/v1/ecash", ecash_routes()) -} -``` - -### Status Routes -**Module**: `nym-api/src/status/mod.rs` - -```rust -pub async fn status_handler() -> impl IntoResponse { - Json(ApiStatusResponse { - status: "ok", - uptime: get_uptime(), - }) -} - -pub async fn health_check() -> impl IntoResponse { - StatusCode::OK -} -``` - -### Network Monitor Routes -**Module**: `nym-api/src/network_monitor/mod.rs` - -```rust -pub async fn get_monitor_report( - State(state): State, -) -> Result, ApiError> { - // Returns network reliability report -} - -pub async fn get_node_reliability( - Path(node_id): Path, - State(state): State, -) -> Result, ApiError> { - // Returns specific node reliability -} -``` - -### E-cash API -**Module**: `nym-api/src/ecash/mod.rs` - -```rust -pub async fn verify_credential( - Json(credential): Json, - State(state): State, -) -> Result, ApiError> { - // Verifies e-cash credentials -} - -pub async fn issue_credential( - Json(request): Json, - State(state): State, -) -> Result, ApiError> { - // Issues new e-cash credentials -} -``` - ---- - -## 7. Credential & E-cash - -### Credential Operations -**Module**: `common/credentials/src/ecash/mod.rs` - -```rust -// Credential spending -pub struct CredentialSpendingData { - pub fn new( - ticketbook: IssuedTicketBook, - gateway_identity: ed25519::PublicKey, - ) -> CredentialSpendingData - - pub fn prepare_for_spending( - &self, - request_id: i64, - ) -> PreparedCredential -} - -// Credential signing -pub struct CredentialSigningData { - pub fn sign_credential( - &self, - blinded_credential: BlindedCredential, - ) -> Result -} - -// Aggregation utilities -pub fn aggregate_verification_keys( - keys: Vec, -) -> AggregatedVerificationKey - -pub fn obtain_aggregate_wallet( - verification_keys: Vec, - commitments: Vec, -) -> Result -``` - -### Ticketbook Operations -**Module**: `common/credentials/src/ecash/bandwidth/mod.rs` - - - -```rust -pub struct IssuedTicketBook { - pub fn new( - tickets: Vec, - expiration: OffsetDateTime, - ) -> IssuedTicketBook - - pub fn total_bandwidth(&self) -> Bandwidth - pub fn is_expired(&self) -> bool - pub fn consume_ticket(&mut self) -> Option -} - -pub struct ImportableTicketBook { - pub fn try_from_base58(s: &str) -> Result - pub fn into_issued(self) -> Result -} -``` - ---- - -## 8. Smart Contracts - -### Mixnet Contract Entry Points -**Module**: `contracts/mixnet/src/contract.rs` - -```rust -#[entry_point] -pub fn instantiate( - deps: DepsMut, - env: Env, - info: MessageInfo, - msg: InstantiateMsg, -) -> Result - -#[entry_point] -pub fn execute( - deps: DepsMut, - env: Env, - info: MessageInfo, - msg: ExecuteMsg, -) -> Result - -#[entry_point] -pub fn query( - deps: Deps, - env: Env, - msg: QueryMsg, -) -> StdResult -``` - -### Execute Message Handlers -**Module**: `contracts/mixnet/src/contract.rs` - -```rust -// Node operations -fn try_bond_mixnode( - deps: DepsMut, - env: Env, - info: MessageInfo, - mixnode: MixNode, -) -> Result - -fn try_unbond_mixnode( - deps: DepsMut, - env: Env, - info: MessageInfo, -) -> Result - -// Delegation operations -fn try_delegate( - deps: DepsMut, - env: Env, - info: MessageInfo, - mix_id: MixId, -) -> Result - -fn try_undelegate( - deps: DepsMut, - env: Env, - info: MessageInfo, - mix_id: MixId, -) -> Result - -// Reward operations -fn try_reward_mixnode( - deps: DepsMut, - env: Env, - mix_id: MixId, - performance: Performance, -) -> Result -``` - -### Query Message Handlers -```rust -fn query_mixnode(deps: Deps, mix_id: MixId) -> StdResult -fn query_gateways(deps: Deps) -> StdResult> -fn query_rewarded_set(deps: Deps, epoch: Epoch) -> StdResult -fn query_current_epoch(deps: Deps) -> StdResult -``` - ---- - -## 9. Common Patterns - -### Function Naming Conventions - - -```rust -// Constructors -pub fn new(...) -> Self // Standard constructor -pub fn with_defaults() -> Self // Constructor with defaults -pub fn from_config(config: Config) -> Self // From configuration - -// Async initialization -pub async fn init(...) -> Result // Async initialization -pub async fn initialise(...) -> Result // British spelling variant -pub async fn setup(...) -> Result // Setup function - -// Builder pattern -pub fn builder() -> TBuilder // Create builder -pub fn set_field(mut self, val: T) -> Self // Builder setter -pub fn build(self) -> Result // Build final object - -// Getters -pub fn field(&self) -> &T // Immutable reference -pub fn field_mut(&mut self) -> &mut T // Mutable reference -pub fn into_inner(self) -> T // Consume and return inner - -// Queries -pub fn is_valid(&self) -> bool // Boolean check -pub fn has_field(&self) -> bool // Existence check -pub fn contains(&self, item: &T) -> bool // Contains check - -// Transformations -pub fn to_type(&self) -> Type // Convert to type -pub fn into_type(self) -> Type // Consume and convert -pub fn try_into_type(self) -> Result // Fallible conversion -``` - -### Error Handling Patterns - -```rust -// Custom error types with thiserror -#[derive(Error, Debug)] -pub enum ModuleError { - #[error("Network error: {0}")] - Network(#[from] NetworkError), - - #[error("Invalid configuration: {reason}")] - InvalidConfig { reason: String }, - - #[error(transparent)] - Other(#[from] anyhow::Error), -} - -// Result type alias -pub type Result = std::result::Result; - -// Error conversion -impl From for ModuleError { - fn from(err: io::Error) -> Self { - ModuleError::Io(err) - } -} -``` - -### Async Patterns - -```rust -// Async trait (with async-trait crate) -#[async_trait] -pub trait AsyncOperation { - async fn perform(&self) -> Result<()>; -} - -// Spawning tasks -tokio::spawn(async move { - // Task code -}); - -// Channels for communication -let (tx, mut rx) = mpsc::channel(100); - -// Select on multiple futures -tokio::select! { - result = future1 => { /* handle */ }, - result = future2 => { /* handle */ }, - _ = shutdown.recv() => { /* shutdown */ }, -} -``` - -### Storage Patterns - -```rust -// SQLx queries -sqlx::query!( - "SELECT * FROM nodes WHERE id = ?", - node_id -) -.fetch_optional(&pool) -.await?; - -// In-memory caching -use dashmap::DashMap; -let cache: DashMap = DashMap::new(); - -// File storage -use std::fs; -fs::write(path, data)?; -let content = fs::read_to_string(path)?; -``` - ---- - -## 10. Import Reference - -### Standard Imports by Category - -```rust -// Nym crypto -use nym_crypto::asymmetric::{ed25519, x25519}; -use nym_crypto::symmetric::stream_cipher; - -// Sphinx protocol -use nym_sphinx::forwarding::packet::MixPacket; -use nym_sphinx::framing::codec::NymCodec; -use nym_sphinx::addressing::nodes::NymNodeRoutingAddress; -use nym_sphinx::params::{PacketSize, DEFAULT_PACKET_SIZE}; - -// Client libraries -use nym_client_core::client::Client; -use nym_gateway_client::GatewayClient; -use nym_validator_client::ValidatorClient; - -// Topology -use nym_topology::{NymTopology, RoutingNode}; -use nym_mixnet_contract_common::NodeId; - -// Configuration -use nym_network_defaults::NymNetworkDetails; -use nym_config::defaults::NymNetwork; - -// Async runtime -use tokio::sync::{mpsc, RwLock, Mutex}; -use tokio::time::{sleep, Duration}; -use futures::{StreamExt, SinkExt}; - -// Error handling -use thiserror::Error; -use anyhow::{anyhow, Result, Context}; - -// Logging -use tracing::{debug, info, warn, error, instrument}; - -// Serialization -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - -// Web framework (API) -use axum::{Router, extract::{Path, Query, State}, response::IntoResponse}; -use axum::Json; -``` - ---- - -## 11. Feature Flags - -### Common Feature Gates - -```rust -// Client-specific features -#[cfg(feature = "client")] -#[cfg(feature = "cli")] - -// Platform-specific -#[cfg(not(target_arch = "wasm32"))] -#[cfg(target_arch = "wasm32")] - -// Testing -#[cfg(test)] -#[cfg(feature = "testing")] -#[cfg(feature = "contract-testing")] - -// Storage backends -#[cfg(feature = "fs-surb-storage")] -#[cfg(feature = "fs-credentials-storage")] - -// Network features -#[cfg(feature = "http-client")] -#[cfg(feature = "websocket")] -``` - ---- - -## Quick Lookup Tables - -### Async vs Sync Functions - -| Operation Type | Typically Async | Typically Sync | -|---------------|-----------------|----------------| -| Network I/O | ✓ | | -| Database queries | ✓ | | -| Contract execution | ✓ | | -| Cryptographic ops | | ✓ | -| Message construction | | ✓ | -| Configuration parsing | | ✓ | -| Topology queries | Both | Both | - -### Return Type Patterns - -| Pattern | Usage | Example | -|---------|-------|---------| -| `Result` | Fallible operations | `connect() -> Result` | -| `Option` | May not exist | `get_node() -> Option` | -| `impl Trait` | Return trait impl | `handler() -> impl IntoResponse` | -| `Box` | Dynamic dispatch | `create() -> Box` | -| Direct type | Infallible ops | `new() -> Self` | - -### Module Organization - -| Module Type | Location Pattern | Naming Convention | -|------------|------------------|-------------------| -| Binary entry | `/src/main.rs` | - | -| Library root | `/src/lib.rs` | - | -| Submodules | `/src/module/mod.rs` | snake_case | -| Tests | `/src/module/tests.rs` | #[cfg(test)] | -| Errors | `/src/error.rs` | ModuleError | -| Config | `/src/config.rs` | Config struct | - ---- - - \ No newline at end of file diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts index b39d3997e4..34c6534fdc 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts +++ b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NodeConfigUpdate.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeConfigUpdate = { host: string | null, custom_http_port: number | null, restore_default_http_port: boolean, }; +export type NodeConfigUpdate = { host: string | null, custom_http_port: number | null, restore_default_http_port: boolean, +/** + * LP listener address for direct gateway connections (format: "host:port") + */ +lp_address: string | null, restore_default_lp_address: boolean, }; diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts index a113827957..9cdd09cbb5 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts +++ b/common/cosmwasm-smart-contracts/mixnet-contract/bindings/ts-packages/types/src/types/rust/NymNode.ts @@ -17,4 +17,9 @@ custom_http_port: number | null, /** * Base58-encoded ed25519 EdDSA public key. */ -identity_key: string, }; +identity_key: string, +/** + * Optional LP (Lewes Protocol) listener address for direct gateway connections. + * Format: "host:port", for example "1.1.1.1:41264" or "gateway.example.com:41264" + */ +lp_address: string | null, }; diff --git a/common/credential-verification/src/lib.rs b/common/credential-verification/src/lib.rs index 6a0187dcd7..cafc5bfcfd 100644 --- a/common/credential-verification/src/lib.rs +++ b/common/credential-verification/src/lib.rs @@ -23,9 +23,8 @@ 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, -]; +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, @@ -148,7 +147,10 @@ impl CredentialVerifier { // 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); + 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 diff --git a/common/nym-kcp/CLAUDE.md b/common/nym-kcp/CLAUDE.md deleted file mode 100644 index 910c68e8ae..0000000000 --- a/common/nym-kcp/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md - nym-kcp - -KCP (Fast and Reliable ARQ Protocol) implementation providing reliability over UDP for the Nym network. This crate ensures ordered, reliable delivery of packets. - -## Architecture Overview - -### Core Components - -**KcpDriver** (src/driver.rs) -- High-level interface for KCP operations -- Manages single KCP session and I/O buffer -- Handles packet encoding/decoding - -**KcpSession** (src/session.rs) -- Core KCP state machine -- Manages send/receive windows, RTT, congestion control -- Implements ARQ (Automatic Repeat Request) logic - -**KcpPacket** (src/packet.rs) -- Wire format: conv(4B) | cmd(1B) | frg(1B) | wnd(2B) | ts(4B) | sn(4B) | una(4B) | len(4B) | data -- Commands: PSH (data), ACK, WND (window probe), ERR - -## Key Concepts - -### Conversation ID (conv) -- Unique identifier for each KCP connection -- Generated from hash of destination in nym-lp-node -- Must match on both ends for successful communication - -### Packet Flow -1. **Send Path**: `send()` → Queue in send buffer → `fetch_outgoing()` → Wire -2. **Receive Path**: Wire → `input()` → Process ACKs/data → Application buffer -3. **Update Loop**: Call `update()` regularly to handle timeouts/retransmissions - -### Reliability Mechanisms -- **Sequence Numbers (sn)**: Track packet ordering -- **Fragment Numbers (frg)**: Handle message fragmentation -- **UNA (Unacknowledged)**: Cumulative ACK up to this sequence -- **Selective ACK**: Via individual ACK packets -- **Fast Retransmit**: Triggered by duplicate ACKs -- **RTO Calculation**: Smoothed RTT with variance - -## Configuration Parameters - -```rust -// In KcpSession -MSS: 1400 // Maximum segment size -WINDOW_SIZE: 128 // Send/receive window -RTO_MIN: 100ms // Minimum retransmission timeout -RTO_MAX: 60000ms // Maximum retransmission timeout -FAST_RESEND: 2 // Fast retransmit threshold -``` - -## Common Operations - -### Processing Incoming Data -```rust -driver.input(data)?; // Decode and process packets -let packets = driver.fetch_outgoing(); // Get any response packets -``` - -### Sending Data -```rust -driver.send(&data); // Queue for sending -driver.update(current_time); // Trigger flush -let packets = driver.fetch_outgoing(); // Get packets to send -``` - -## Debugging Tips - -- Enable `trace!` logs to see packet-level details -- Monitor `ts_flush` vs `ts_current` for timing issues -- Check `snd_wnd` and `rcv_wnd` for flow control problems -- Watch for "fast retransmit" messages indicating packet loss - -## Integration Notes - -- AIDEV-NOTE: MSS must account for Sphinx packet overhead -- AIDEV-NOTE: Window size affects memory usage and throughput -- Update frequency impacts latency vs CPU usage tradeoff -- Conv ID must be consistent across session lifecycle \ No newline at end of file diff --git a/common/nym-kcp/Cargo.toml b/common/nym-kcp/Cargo.toml index 43cafb1414..beafab5ee9 100644 --- a/common/nym-kcp/Cargo.toml +++ b/common/nym-kcp/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "nym-kcp" version = "0.1.0" -edition = "2021" +edition = { workspace = true } +license = { workspace = true } [lib] name = "nym_kcp" diff --git a/common/nym-kcp/src/session.rs b/common/nym-kcp/src/session.rs index 2e56c9073f..f72cd19ef7 100644 --- a/common/nym-kcp/src/session.rs +++ b/common/nym-kcp/src/session.rs @@ -490,8 +490,16 @@ impl KcpSession { post_retain_sns ); // Corrected format string arguments for the removed count log - debug!("[ConvID: {}, Thread: {:?}] parse_una(una={}): Removed {} segment(s) from snd_buf ({} -> {}). Remaining sns: {:?}", - self.conv, thread::current().id(), una, removed_count, original_len, self.snd_buf.len(), post_retain_sns); + debug!( + "[ConvID: {}, Thread: {:?}] parse_una(una={}): Removed {} segment(s) from snd_buf ({} -> {}). Remaining sns: {:?}", + self.conv, + thread::current().id(), + una, + removed_count, + original_len, + self.snd_buf.len(), + post_retain_sns + ); if removed_count > 0 { // Use trace level if no segments were removed but buffer wasn't empty @@ -955,7 +963,7 @@ mod tests { // Check that snd_buf now contains segments up to the new cwnd (8) // The total number of segments should be 7 (initial 5 - 1 acked + 3 moved from queue) - let expected_buf_len_after_ack = initial_cwnd as usize - 1 + (8 - initial_cwnd as usize); + let _expected_buf_len_after_ack = initial_cwnd as usize - 1 + (8 - initial_cwnd as usize); assert_eq!( session.snd_buf.len(), 7, @@ -1028,7 +1036,7 @@ mod tests { .expect("Segment must be in buffer") .clone(); // Clone for inspection let initial_rto = session.rx_rto; - let expected_resendts = session.current + initial_rto; + let _expected_resendts = session.current + initial_rto; assert_eq!(segment.xmit, 1, "Initial transmit count should be 1"); assert_eq!( segment.rto, initial_rto, @@ -1255,11 +1263,11 @@ mod tests { session.set_mtu(50); // Send 5 segments (SN 0, 1, 2, 3, 4) - session.send(&vec![1u8; 30]); // sn=0 - session.send(&vec![2u8; 30]); // sn=1 - session.send(&vec![3u8; 30]); // sn=2 - session.send(&vec![4u8; 30]); // sn=3 - session.send(&vec![5u8; 30]); // sn=4 + session.send(&[1u8; 30]); // sn=0 + session.send(&[2u8; 30]); // sn=1 + session.send(&[3u8; 30]); // sn=2 + session.send(&[4u8; 30]); // sn=3 + session.send(&[5u8; 30]); // sn=4 assert_eq!(session.snd_queue.len(), 5); // Move all to snd_buf @@ -1616,9 +1624,9 @@ mod tests { debug!("Simulating loss of fragment sn={}", lost_packet_sn); // Deliver all packets *except* the lost one - for i in 0..num_fragments { + for (i, packet) in packets.iter().enumerate().take(num_fragments) { if i != 1 { - receiver.input(&packets[i]); + receiver.input(packet); } } receiver.update(0); // Process inputs diff --git a/common/nym-kkt/Cargo.toml b/common/nym-kkt/Cargo.toml new file mode 100644 index 0000000000..3c717d5d41 --- /dev/null +++ b/common/nym-kkt/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "nym-kkt" +version = "0.1.0" +authors = ["Georgio Nicolas "] +edition = { workspace = true } +license.workspace = true + +[dependencies] +arc-swap = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +pin-project = { workspace = true } +blake3 = { workspace = true } +aead = { workspace = true } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tokio-util = { workspace = true, features = ["codec"] } + + + +# internal +nym-crypto = { path = "../crypto", features = ["asymmetric", "serde"]} + +libcrux-traits = { git = "https://github.com/cryspen/libcrux" } +libcrux-kem = { git = "https://github.com/cryspen/libcrux" } +libcrux-psq = { git = "https://github.com/cryspen/libcrux", features = ["test-utils"] } +libcrux-sha3 = { git = "https://github.com/cryspen/libcrux" } +libcrux-ml-kem = { git = "https://github.com/cryspen/libcrux" } +libcrux-ecdh = { git = "https://github.com/cryspen/libcrux", features = ["codec"]} + +rand = "0.9.2" +curve25519-dalek = {version = "4.1.3", features = ["rand_core", "serde"] } +zeroize = { workspace = true, features = ["zeroize_derive"] } +classic-mceliece-rust = { git = "https://github.com/georgio/classic-mceliece-rust", features = ["mceliece460896f","zeroize"]} + + +[dev-dependencies] +criterion = {workspace = true} + +[[bench]] +name = "benches" +harness = false + +[lints] +workspace = true diff --git a/common/nym-kkt/benches/benches.rs b/common/nym-kkt/benches/benches.rs new file mode 100644 index 0000000000..8df4dd3e42 --- /dev/null +++ b/common/nym-kkt/benches/benches.rs @@ -0,0 +1,518 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use criterion::{Criterion, criterion_group, criterion_main}; + +use nym_crypto::asymmetric::ed25519; +use nym_kkt::{ + ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM, SignatureScheme}, + context::KKTMode, + frame::KKTFrame, + key_utils::{generate_keypair_libcrux, generate_keypair_mceliece, hash_encapsulation_key}, + session::{ + anonymous_initiator_process, initiator_ingest_response, initiator_process, + responder_ingest_message, responder_process, + }, +}; +use rand::prelude::*; + +pub fn gen_ed25519_keypair(c: &mut Criterion) { + c.bench_function("Generate Ed25519 Keypair", |b| { + b.iter(|| { + let mut s: [u8; 32] = [0u8; 32]; + rand::rng().fill_bytes(&mut s); + ed25519::KeyPair::from_secret(s, 0) + }); + }); +} + +pub fn gen_mlkem768_keypair(c: &mut Criterion) { + c.bench_function("Generate MlKem768 Keypair", |b| { + b.iter(|| { + libcrux_kem::key_gen(libcrux_kem::Algorithm::MlKem768, &mut rand::rng()).unwrap() + }); + }); +} + +pub fn kkt_benchmark(c: &mut Criterion) { + let mut rng = rand::rng(); + + // generate ed25519 keys + let mut secret_initiator: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_initiator); + let initiator_ed25519_keypair = ed25519::KeyPair::from_secret(secret_initiator, 0); + + let mut secret_responder: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_responder); + let responder_ed25519_keypair = ed25519::KeyPair::from_secret(secret_responder, 1); + for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] { + for hash_function in [ + HashFunction::Blake3, + HashFunction::SHA256, + HashFunction::SHAKE128, + HashFunction::SHAKE256, + ] { + let ciphersuite = Ciphersuite::resolve_ciphersuite( + kem, + hash_function, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // generate kem public keys + + let (responder_kem_public_key, initiator_kem_public_key) = match kem { + KEM::MlKem768 => ( + EncapsulationKey::MlKem768(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::MlKem768(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::XWing => ( + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::X25519 => ( + EncapsulationKey::X25519(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::X25519(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::McEliece => ( + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + ), + }; + + let i_kem_key_bytes = initiator_kem_public_key.encode(); + + let r_kem_key_bytes = responder_kem_public_key.encode(); + + let i_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &i_kem_key_bytes, + ); + + let r_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &r_kem_key_bytes, + ); + + // Anonymous Initiator, OneWay + { + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Generate Request", + kem, hash_function + ), + |b| { + b.iter(|| anonymous_initiator_process(&mut rng, ciphersuite).unwrap()); + }, + ); + + let (mut i_context, i_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Encode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| i_frame.to_bytes()), + ); + + let i_frame_bytes = i_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Decode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()), + ); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Responder Ingest Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap() + }); + }, + ); + + let (mut r_context, _) = + responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Responder Generate Response", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap() + }); + }, + ); + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Responder Encode Frame", + kem, hash_function + ), + |b| b.iter(|| r_frame.to_bytes()), + ); + + let r_bytes = r_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Anonymous Initiator: Initiator Ingest Response", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap() + }); + }, + ); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + // Initiator, OneWay + { + let (mut i_context, i_frame) = initiator_process( + &mut rng, + KKTMode::OneWay, + ciphersuite, + initiator_ed25519_keypair.private_key(), + None, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Generate Request", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_process( + &mut rng, + KKTMode::OneWay, + ciphersuite, + initiator_ed25519_keypair.private_key(), + None, + ) + .unwrap() + }); + }, + ); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Encode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| i_frame.to_bytes()), + ); + + let i_frame_bytes = i_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Decode Frame - Request", + kem, hash_function + ), + |b| b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()), + ); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Responder Ingest Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + None, + &i_frame_r, + ) + .unwrap() + }); + }, + ); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + None, + &i_frame_r, + ) + .unwrap(); + + assert!(r_obtained_key.is_none()); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Responder Generate Response", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap() + }); + }, + ); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Responder Encode Frame", + kem, hash_function + ), + |b| { + b.iter(|| r_frame.to_bytes()); + }, + ); + + let r_bytes = r_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator OneWay: Initiator Ingest Response", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap() + }); + }, + ); + + let i_obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(i_obtained_key.encode(), r_kem_key_bytes) + } + + // Initiator, Mutual + { + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Generate Request", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_process( + &mut rng, + KKTMode::Mutual, + ciphersuite, + initiator_ed25519_keypair.private_key(), + Some(&initiator_kem_public_key), + ) + .unwrap() + }); + }, + ); + + let (mut i_context, i_frame) = initiator_process( + &mut rng, + KKTMode::Mutual, + ciphersuite, + initiator_ed25519_keypair.private_key(), + Some(&initiator_kem_public_key), + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Encode Frame - Request", + kem, hash_function + ), + |b| { + b.iter(|| i_frame.to_bytes()); + }, + ); + + let i_frame_bytes = i_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Decode Frame - Request", + kem, hash_function + ), + |b| { + b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()); + }, + ); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Responder Ingest Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + Some(&i_dir_hash), + &i_frame_r, + ) + .unwrap() + }); + }, + ); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + Some(&i_dir_hash), + &i_frame_r, + ) + .unwrap(); + + assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Responder Generate Response", + kem, hash_function + ), + |b| { + b.iter(|| { + responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap() + }); + }, + ); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Responder Encode Frame", + kem, hash_function + ), + |b| { + b.iter(|| { + r_frame.to_bytes(); + }); + }, + ); + + let r_bytes = r_frame.to_bytes(); + + c.bench_function( + &format!( + "{}, {} | Initiator Mutual: Initiator Ingest Response", + kem, hash_function + ), + |b| { + b.iter(|| { + initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap() + }); + }, + ); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + } + } +} + +criterion_group!( + benches, + gen_ed25519_keypair, + gen_mlkem768_keypair, + kkt_benchmark +); +criterion_main!(benches); diff --git a/common/nym-kkt/src/ciphersuite.rs b/common/nym-kkt/src/ciphersuite.rs new file mode 100644 index 0000000000..cc7877e690 --- /dev/null +++ b/common/nym-kkt/src/ciphersuite.rs @@ -0,0 +1,301 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use libcrux_kem::{Algorithm, MlKem768PublicKey}; +use nym_crypto::asymmetric::ed25519; + +use crate::error::KKTError; + +pub const HASH_LEN_256: u8 = 32; +pub const CIPHERSUITE_ENCODING_LEN: usize = 4; + +pub const CURVE25519_KEY_LEN: usize = 32; + +#[derive(Clone, Copy, Debug)] +pub enum HashFunction { + Blake3, + SHAKE128, + SHAKE256, + SHA256, +} +impl Display for HashFunction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + HashFunction::Blake3 => "Blake3", + HashFunction::SHAKE128 => "SHAKE128", + HashFunction::SHAKE256 => "SHAKE256", + HashFunction::SHA256 => "SHA256", + }) + } +} + +pub enum EncapsulationKey<'a> { + MlKem768(libcrux_kem::PublicKey), + XWing(libcrux_kem::PublicKey), + X25519(libcrux_kem::PublicKey), + McEliece(classic_mceliece_rust::PublicKey<'a>), +} + +pub enum DecapsulationKey<'a> { + MlKem768(libcrux_kem::PrivateKey), + XWing(libcrux_kem::PrivateKey), + X25519(libcrux_kem::PrivateKey), + McEliece(classic_mceliece_rust::SecretKey<'a>), +} +impl<'a> EncapsulationKey<'a> { + pub(crate) fn decode(kem: KEM, bytes: &[u8]) -> Result { + match kem { + KEM::McEliece => { + if bytes.len() != classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES { + Err(KKTError::KEMError { + info: "Received McEliece Encapsulation Key with Invalid Length", + }) + } else { + let mut public_key_bytes = + Box::new([0u8; classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES]); + // Size must be correct due to KKTFrame::from_bytes(message_bytes)? + public_key_bytes.clone_from_slice(bytes); + Ok(EncapsulationKey::McEliece( + classic_mceliece_rust::PublicKey::from(public_key_bytes), + )) + } + } + KEM::X25519 => Ok(EncapsulationKey::X25519(libcrux_kem::PublicKey::decode( + map_kem_to_libcrux_kem(kem), + bytes, + )?)), + KEM::MlKem768 => Ok(EncapsulationKey::MlKem768(libcrux_kem::PublicKey::decode( + map_kem_to_libcrux_kem(kem), + bytes, + )?)), + KEM::XWing => Ok(EncapsulationKey::XWing(libcrux_kem::PublicKey::decode( + map_kem_to_libcrux_kem(kem), + bytes, + )?)), + } + } + + pub fn encode(&self) -> Vec { + match self { + EncapsulationKey::XWing(public_key) + | EncapsulationKey::MlKem768(public_key) + | EncapsulationKey::X25519(public_key) => public_key.encode(), + EncapsulationKey::McEliece(public_key) => Vec::from(public_key.as_array()), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub enum SignatureScheme { + Ed25519, +} +impl Display for SignatureScheme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SignatureScheme::Ed25519 => "Ed25519", + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub enum KEM { + MlKem768, + XWing, + X25519, + McEliece, +} + +impl Display for KEM { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + KEM::MlKem768 => "MlKem768", + KEM::XWing => "XWing", + KEM::X25519 => "x25519", + KEM::McEliece => "McEliece", + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Ciphersuite { + hash_function: HashFunction, + signature_scheme: SignatureScheme, + kem: KEM, + hash_length: u8, + encapsulation_key_length: usize, + signing_key_length: usize, + verification_key_length: usize, + signature_length: usize, +} + +impl Ciphersuite { + pub fn kem_key_len(&self) -> usize { + self.encapsulation_key_length + } + + pub fn signature_len(&self) -> usize { + self.signature_length + } + pub fn signing_key_len(&self) -> usize { + self.signing_key_length + } + pub fn verification_key_len(&self) -> usize { + self.verification_key_length + } + pub fn hash_function(&self) -> HashFunction { + self.hash_function + } + pub fn kem(&self) -> KEM { + self.kem + } + pub fn signature_scheme(&self) -> SignatureScheme { + self.signature_scheme + } + pub fn hash_len(&self) -> usize { + self.hash_length as usize + } + + pub fn resolve_ciphersuite( + kem: KEM, + hash_function: HashFunction, + signature_scheme: SignatureScheme, + // This should be None 99.9999% of the time + custom_hash_length: Option, + ) -> Result { + let hash_len = match custom_hash_length { + Some(l) => { + if l < 16 { + return Err(KKTError::InsecureHashLen); + } else { + l + } + } + None => HASH_LEN_256, + }; + Ok(Self { + hash_function, + signature_scheme, + kem, + hash_length: hash_len, + encapsulation_key_length: match kem { + // 1184 bytes + KEM::MlKem768 => MlKem768PublicKey::len(), + // 1216 bytes = 1184 + 32 + KEM::XWing => MlKem768PublicKey::len() + CURVE25519_KEY_LEN, + // 32 bytes + KEM::X25519 => CURVE25519_KEY_LEN, + // 524160 bytes + KEM::McEliece => classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES, + }, + signing_key_length: match signature_scheme { + // 32 bytes + SignatureScheme::Ed25519 => ed25519::SECRET_KEY_LENGTH, + }, + verification_key_length: match signature_scheme { + // 32 bytes + SignatureScheme::Ed25519 => ed25519::PUBLIC_KEY_LENGTH, + }, + signature_length: match signature_scheme { + // 64 bytes + SignatureScheme::Ed25519 => ed25519::SIGNATURE_LENGTH, + }, + }) + } + pub fn encode(&self) -> [u8; 4] { + // [kem, hash, hashlen, sig] + [ + match self.kem { + KEM::XWing => 0, + KEM::MlKem768 => 1, + KEM::McEliece => 2, + KEM::X25519 => 255, + }, + match self.hash_function { + HashFunction::Blake3 => 0, + HashFunction::SHAKE256 => 1, + HashFunction::SHAKE128 => 2, + HashFunction::SHA256 => 3, + }, + match self.hash_length { + HASH_LEN_256 => 0, + _ => self.hash_length, + }, + match self.signature_scheme { + SignatureScheme::Ed25519 => 0, + }, + ] + } + pub fn decode(encoding: &[u8]) -> Result { + if encoding.len() == 4 { + let kem = match encoding[0] { + 0 => KEM::XWing, + 1 => KEM::MlKem768, + 2 => KEM::McEliece, + 255 => KEM::X25519, + _ => { + return Err(KKTError::CiphersuiteDecodingError { + info: format!("Undefined KEM: {}", encoding[0]), + }); + } + }; + let hash_function = match encoding[1] { + 0 => HashFunction::Blake3, + 1 => HashFunction::SHAKE256, + 2 => HashFunction::SHAKE128, + 3 => HashFunction::SHA256, + _ => { + return Err(KKTError::CiphersuiteDecodingError { + info: format!("Undefined Hash Function: {}", encoding[1]), + }); + } + }; + + let custom_hash_length = match encoding[2] { + 0 => None, + _ => Some(encoding[2]), + }; + + let signature_scheme = match encoding[3] { + 0 => SignatureScheme::Ed25519, + _ => { + return Err(KKTError::CiphersuiteDecodingError { + info: format!("Undefined Signature Scheme: {}", encoding[3]), + }); + } + }; + + Self::resolve_ciphersuite(kem, hash_function, signature_scheme, custom_hash_length) + } else { + Err(KKTError::CiphersuiteDecodingError { + info: format!( + "Incorrect Encoding Length: actual: {} != expected: {}", + encoding.len(), + CIPHERSUITE_ENCODING_LEN + ), + }) + } + } +} + +impl Display for Ciphersuite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + &format!( + "{}_{}({})_{}", + self.kem, self.hash_function, self.hash_length, self.signature_scheme + ) + .to_ascii_lowercase(), + ) + } +} + +pub const fn map_kem_to_libcrux_kem(kem: KEM) -> Algorithm { + match kem { + KEM::MlKem768 => Algorithm::MlKem768, + KEM::XWing => Algorithm::XWingKemDraft06, + KEM::X25519 => Algorithm::X25519, + KEM::McEliece => panic!("McEliece is not supported in libcrux_kem"), + } +} diff --git a/common/nym-kkt/src/context.rs b/common/nym-kkt/src/context.rs new file mode 100644 index 0000000000..da66bd3ae6 --- /dev/null +++ b/common/nym-kkt/src/context.rs @@ -0,0 +1,258 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use crate::{KKT_VERSION, ciphersuite::Ciphersuite, error::KKTError, frame::KKT_SESSION_ID_LEN}; + +pub const KKT_CONTEXT_LEN: usize = 7; + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KKTStatus { + Ok, + InvalidRequestFormat, + InvalidResponseFormat, + InvalidSignature, + UnsupportedCiphersuite, + UnsupportedKKTVersion, + InvalidKey, + Timeout, +} + +impl Display for KKTStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + KKTStatus::Ok => "Ok", + KKTStatus::InvalidRequestFormat => "Invalid Request Format", + KKTStatus::InvalidResponseFormat => "Invalid Response Format", + KKTStatus::InvalidSignature => "Invalid Signature", + KKTStatus::UnsupportedCiphersuite => "Unsupported Ciphersuite", + KKTStatus::UnsupportedKKTVersion => "Unsupported KKT Version", + KKTStatus::InvalidKey => "Invalid Key", + KKTStatus::Timeout => "Timeout", + }) + } +} +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KKTRole { + Initiator, + AnonymousInitiator, + Responder, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum KKTMode { + OneWay, + Mutual, +} + +#[derive(Copy, Clone, Debug)] +pub struct KKTContext { + version: u8, + message_sequence: u8, + status: KKTStatus, + mode: KKTMode, + role: KKTRole, + ciphersuite: Ciphersuite, +} +impl KKTContext { + pub fn new(role: KKTRole, mode: KKTMode, ciphersuite: Ciphersuite) -> Result { + if role == KKTRole::AnonymousInitiator && mode != KKTMode::OneWay { + return Err(KKTError::IncompatibilityError { + info: "Anonymous Initiator can only use OneWay mode", + }); + } + Ok(Self { + version: KKT_VERSION, + message_sequence: 0, + status: KKTStatus::Ok, + mode, + role, + ciphersuite, + }) + } + + pub fn derive_responder_header(&self) -> Result { + let mut responder_header = *self; + + responder_header.increment_message_sequence_count()?; + responder_header.role = KKTRole::Responder; + + Ok(responder_header) + } + + pub fn increment_message_sequence_count(&mut self) -> Result<(), KKTError> { + if self.message_sequence + 1 < (1 << 4) { + self.message_sequence += 1; + Ok(()) + } else { + Err(KKTError::MessageCountLimitReached) + } + } + + pub fn update_status(&mut self, status: KKTStatus) { + self.status = status; + } + pub fn version(&self) -> u8 { + self.version + } + pub fn status(&self) -> KKTStatus { + self.status + } + pub fn ciphersuite(&self) -> Ciphersuite { + self.ciphersuite + } + pub fn role(&self) -> KKTRole { + self.role + } + pub fn mode(&self) -> KKTMode { + self.mode + } + + pub fn body_len(&self) -> usize { + if self.status != KKTStatus::Ok + || (self.mode == KKTMode::OneWay + && (self.role == KKTRole::Initiator || self.role == KKTRole::AnonymousInitiator)) + { + 0 + } else { + self.ciphersuite.kem_key_len() + } + } + + pub fn signature_len(&self) -> usize { + match self.role { + KKTRole::Initiator | KKTRole::Responder => self.ciphersuite.signature_len(), + KKTRole::AnonymousInitiator => 0, + } + } + + pub fn header_len(&self) -> usize { + KKT_CONTEXT_LEN + } + + pub fn session_id_len(&self) -> usize { + // match self.role { + // KKTRole::Initiator | KKTRole::Responder => SESSION_ID_LENGTH, + // It doesn't make sense to send a session_id if we send messages in the clear + // KKTRole::AnonymousInitiator => 0, + // } + KKT_SESSION_ID_LEN + } + + pub fn full_message_len(&self) -> usize { + self.body_len() + self.signature_len() + self.header_len() + self.session_id_len() + } + + pub fn encode(&self) -> Result, KKTError> { + let mut header_bytes: Vec = Vec::with_capacity(KKT_CONTEXT_LEN); + if self.message_sequence >= 1 << 4 { + return Err(KKTError::MessageCountLimitReached); + } + + header_bytes.push((KKT_VERSION << 4) + self.message_sequence); + + header_bytes.push( + match self.status { + KKTStatus::Ok => 0, + KKTStatus::InvalidRequestFormat => 0b0010_0000, + KKTStatus::InvalidResponseFormat => 0b0100_0000, + KKTStatus::InvalidSignature => 0b0110_0000, + KKTStatus::UnsupportedCiphersuite => 0b1000_0000, + KKTStatus::UnsupportedKKTVersion => 0b1010_0000, + KKTStatus::InvalidKey => 0b1100_0000, + KKTStatus::Timeout => 0b1110_0000, + } + match self.mode { + KKTMode::OneWay => 0, + KKTMode::Mutual => 0b0000_0100, + } + match self.role { + KKTRole::Initiator => 0, + KKTRole::Responder => 1, + KKTRole::AnonymousInitiator => 2, + }, + ); + + header_bytes.extend_from_slice(&self.ciphersuite.encode()); + header_bytes.push(0); + Ok(header_bytes) + } + + pub fn try_decode(header_bytes: &[u8]) -> Result { + if header_bytes.len() == KKT_CONTEXT_LEN { + let kkt_version = header_bytes[0] & 0b1111_0000; + + let message_sequence_counter = header_bytes[0] & 0b0000_1111; + + // We only check if stuff is valid here, not necessarily if it's compatible + + if (kkt_version >> 4) > KKT_VERSION { + return Err(KKTError::FrameDecodingError { + info: format!("Header - Invalid KKT Version: {}", kkt_version >> 4), + }); + } + + let status = match header_bytes[1] & 0b1110_0000 { + 0 => KKTStatus::Ok, + 0b0010_0000 => KKTStatus::InvalidRequestFormat, + 0b0100_0000 => KKTStatus::InvalidResponseFormat, + 0b0110_0000 => KKTStatus::InvalidSignature, + 0b1000_0000 => KKTStatus::UnsupportedCiphersuite, + 0b1010_0000 => KKTStatus::UnsupportedKKTVersion, + 0b1100_0000 => KKTStatus::InvalidKey, + 0b1110_0000 => KKTStatus::Timeout, + _ => { + return Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid KKT Status: {}", + header_bytes[1] & 0b1110_0000 + ), + }); + } + }; + + let role = match header_bytes[1] & 0b0000_0011 { + 0 => KKTRole::Initiator, + 1 => KKTRole::Responder, + 2 => KKTRole::AnonymousInitiator, + _ => { + return Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid KKT Role: {}", + header_bytes[1] & 0b0000_0011 + ), + }); + } + }; + + let mode = match (header_bytes[1] & 0b0001_1100) >> 2 { + 0 => KKTMode::OneWay, + 1 => KKTMode::Mutual, + _ => { + return Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid KKT Mode: {}", + (header_bytes[1] & 0b0001_1100) >> 2 + ), + }); + } + }; + + Ok(KKTContext { + version: kkt_version, + status, + mode, + role, + ciphersuite: Ciphersuite::decode(&header_bytes[2..6])?, + message_sequence: message_sequence_counter, + }) + } else { + Err(KKTError::FrameDecodingError { + info: format!( + "Header - Invalid Header Length: actual: {} != expected: {}", + header_bytes.len(), + KKT_CONTEXT_LEN + ), + }) + } + } +} diff --git a/common/nym-kkt/src/encryption.rs b/common/nym-kkt/src/encryption.rs new file mode 100644 index 0000000000..65ac46f0ac --- /dev/null +++ b/common/nym-kkt/src/encryption.rs @@ -0,0 +1,95 @@ +use core::hash; + +use blake3::{Hash, Hasher}; +use curve25519_dalek::digest::DynDigest; +use libcrux_psq::traits::Ciphertext; +use nym_crypto::symmetric::aead::{AeadKey, Nonce}; +use nym_crypto::{ + aes::Aes256, + asymmetric::x25519::{self, PrivateKey, PublicKey}, + generic_array::GenericArray, + Aes256GcmSiv, +}; +// use rand::{CryptoRng, RngCore}; +use zeroize::Zeroize; + +use nym_crypto::aes::cipher::crypto_common::rand_core::{CryptoRng, RngCore}; + +use crate::error::KKTError; + +fn generate_round_trip_symmetric_key( + rng: &mut R, + remote_public_key: &PublicKey, +) -> ([u8; 64], [u8; 32]) +where + R: CryptoRng + RngCore, +{ + let mut s = x25519::PrivateKey::new(rng); + let gs = s.public_key(); + + let mut gbs = s.diffie_hellman(remote_public_key); + s.zeroize(); + + let mut message: [u8; 64] = [0u8; 64]; + message[0..32].clone_from_slice(gs.as_bytes()); + + let mut hasher = Hasher::new(); + + hasher.update(&gbs); + gbs.zeroize(); + let key: [u8; 32] = hasher.finalize().as_bytes().to_owned(); + + hasher.update(remote_public_key.as_bytes()); + hasher.update(gs.as_bytes()); + + hasher.finalize_into_reset(&mut message[32..64]); + + (message, key) +} + +fn extract_shared_secret(b: &PrivateKey, message: &[u8; 64]) -> Result<[u8; 32], KKTError> { + let gs = PublicKey::from_bytes(&message[0..32])?; + + let mut gsb = b.diffie_hellman(&gs); + + let mut hasher = Hasher::new(); + hasher.update(&gsb); + gsb.zeroize(); + let key: [u8; 32] = hasher.finalize().as_bytes().to_owned(); + + hasher.update(b.public_key().as_bytes()); + hasher.update(gs.as_bytes()); + + // This runs in constant time + if hasher.finalize() == message[32..64] { + Ok(key) + } else { + Err(KKTError::X25519Error { + info: format!("Symmetric Key Hash Validation Error"), + }) + } +} + +fn encrypt(mut key: [u8; 32], message: &[u8]) -> Result, KKTError> { + // The empty nonce is fine since we use the key once. + let nonce = Nonce::::from_slice(&[]); + + let ciphertext = + nym_crypto::symmetric::aead::encrypt::(&key.into(), nonce, message)?; + + key.zeroize(); + + Ok(ciphertext) +} + +fn decrypt(key: [u8; 32], ciphertext: Vec) -> Vec { + // The empty nonce is fine since we use the key once. + let nonce = Nonce::::from_slice(&[]); + + let ciphertext = + nym_crypto::symmetric::aead::encrypt::(&key.into(), nonce, message)?; + + key.zeroize(); + + Ok(ciphertext) +} diff --git a/common/nym-kkt/src/error.rs b/common/nym-kkt/src/error.rs new file mode 100644 index 0000000000..3e148d03e1 --- /dev/null +++ b/common/nym-kkt/src/error.rs @@ -0,0 +1,85 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use thiserror::Error; + +use crate::context::KKTStatus; + +#[derive(Error, Debug)] +pub enum KKTError { + #[error("Signature constructor error")] + SigConstructorError, + #[error("Signature verification error")] + SigVerifError, + #[error("Ciphersuite Decoding Error: {}", info)] + CiphersuiteDecodingError { info: String }, + #[error("Insecure Encapsulation Key Hash Length")] + InsecureHashLen, + + #[error("KKT Frame Decoding Error: {}", info)] + FrameDecodingError { info: String }, + + #[error("KKT Frame Encoding Error: {}", info)] + FrameEncodingError { info: String }, + + #[error("KKT Incompatibility Error: {}", info)] + IncompatibilityError { info: &'static str }, + + #[error("KKT Responder Flagged Error: {}", status)] + ResponderFlaggedError { status: KKTStatus }, + + #[error("KKT Message Count Limit Reached")] + MessageCountLimitReached, + + #[error("PSQ KEM Error: {}", info)] + KEMError { info: &'static str }, + + #[error("Local Function Input Error: {}", info)] + FunctionInputError { info: &'static str }, + + #[error("{}", info)] + X25519Error { info: &'static str }, + + #[error("Generic libcrux error")] + LibcruxError, +} + +impl From for KKTError { + fn from(err: libcrux_kem::Error) -> Self { + match err { + libcrux_kem::Error::EcDhError(_) => KKTError::KEMError { info: "ECDH Error" }, + libcrux_kem::Error::KeyGen => KKTError::KEMError { + info: "Key Generation Error", + }, + libcrux_kem::Error::Encapsulate => KKTError::KEMError { + info: "Encapsulation Error", + }, + libcrux_kem::Error::Decapsulate => KKTError::KEMError { + info: "Decapsulation Error", + }, + libcrux_kem::Error::UnsupportedAlgorithm => KKTError::KEMError { + info: "libcrux Unsupported Algorithm", + }, + libcrux_kem::Error::InvalidPrivateKey => KKTError::KEMError { + info: "Invalid Private Key", + }, + + libcrux_kem::Error::InvalidPublicKey => KKTError::KEMError { + info: "Invalid Public Key", + }, + libcrux_kem::Error::InvalidCiphertext => KKTError::KEMError { + info: "Invalid Ciphertext", + }, + } + } +} +impl From for KKTError { + fn from(err: libcrux_ecdh::Error) -> Self { + match err { + libcrux_ecdh::Error::InvalidPoint => KKTError::KEMError { + info: "Invalid Remote Public Key", + }, + _ => KKTError::LibcruxError, + } + } +} diff --git a/common/nym-kkt/src/frame.rs b/common/nym-kkt/src/frame.rs new file mode 100644 index 0000000000..1745b997f1 --- /dev/null +++ b/common/nym-kkt/src/frame.rs @@ -0,0 +1,129 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// | 0 | 1 | 2, 3, 4, 5 | 6 | 7 +// [0] => KKT version (4 bits) + Message Sequence Count (4 bits) +// [1] => Status (3 bits) + Mode (3 bits) + Role (2 bits) +// [2..=5] => Ciphersuite +// [6] => Reserved + +use crate::{ + context::{KKT_CONTEXT_LEN, KKTContext}, + error::KKTError, +}; + +pub const KKT_SESSION_ID_LEN: usize = 16; + +pub struct KKTFrame { + context: Vec, + session_id: Vec, + body: Vec, + signature: Vec, +} + +// if oneway and message coming from initiator => body is empty, signature contains signature of context + session id (64 bytes). +// if message coming from anonymous initiator => body is empty, there is no signature. +// if mutual and message coming from initiator => body has the initiator's kem public key and the signature is over the context + body + session_id. +// if coming from responder => body has the responder's kem public key and the signature is over the context + body + session_id. + +impl KKTFrame { + pub fn new(context: &[u8], body: &[u8], session_id: &[u8], signature: &[u8]) -> Self { + Self { + context: Vec::from(context), + body: Vec::from(body), + session_id: Vec::from(session_id), + signature: Vec::from(signature), + } + } + pub fn context_ref(&self) -> &[u8] { + &self.context + } + pub fn signature_ref(&self) -> &[u8] { + &self.signature + } + pub fn body_ref(&self) -> &[u8] { + &self.body + } + + pub fn session_id_ref(&self) -> &[u8] { + &self.session_id + } + pub fn signature_mut(&mut self) -> &mut [u8] { + &mut self.signature + } + pub fn body_mut(&mut self) -> &mut [u8] { + &mut self.body + } + + pub fn session_id_mut(&mut self) -> &mut [u8] { + &mut self.session_id + } + + pub fn frame_length(&self) -> usize { + self.context.len() + self.session_id.len() + self.body.len() + self.signature.len() + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(self.frame_length()); + bytes.extend_from_slice(&self.context); + bytes.extend_from_slice(&self.body); + bytes.extend_from_slice(&self.session_id); + bytes.extend_from_slice(&self.signature); + bytes + } + + pub fn from_bytes(bytes: &[u8]) -> Result<(Self, KKTContext), KKTError> { + if bytes.len() < KKT_CONTEXT_LEN { + Err(KKTError::FrameDecodingError { + info: format!( + "Frame is shorter than expected context length: actual {} != expected {}", + bytes.len(), + KKT_CONTEXT_LEN + ), + }) + } else { + let context_bytes = Vec::from(&bytes[0..KKT_CONTEXT_LEN]); + + let context = KKTContext::try_decode(&context_bytes)?; + + let (mut session_id, mut body, mut signature): (Vec, Vec, Vec) = + (vec![], vec![], vec![]); + + if bytes.len() == context.full_message_len() { + if context.body_len() > 0 { + body.extend_from_slice( + &bytes[KKT_CONTEXT_LEN..KKT_CONTEXT_LEN + context.body_len()], + ); + } + if context.session_id_len() > 0 { + session_id.extend_from_slice( + &bytes[KKT_CONTEXT_LEN + context.body_len() + ..KKT_CONTEXT_LEN + context.body_len() + context.session_id_len()], + ); + } + if context.signature_len() > 0 { + signature.extend_from_slice( + &bytes[KKT_CONTEXT_LEN + context.body_len() + context.session_id_len() + ..KKT_CONTEXT_LEN + + context.body_len() + + context.session_id_len() + + context.signature_len()], + ); + } + + Ok(( + KKTFrame::new(&context_bytes, &body, &session_id, &signature), + context, + )) + } else { + Err(KKTError::FrameDecodingError { + info: format!( + "Frame is shorter than expected: actual {} != expected {}", + bytes.len(), + context.full_message_len() + ), + }) + } + } + } +} diff --git a/common/nym-kkt/src/key_utils.rs b/common/nym-kkt/src/key_utils.rs new file mode 100644 index 0000000000..1ab18934e0 --- /dev/null +++ b/common/nym-kkt/src/key_utils.rs @@ -0,0 +1,107 @@ +use crate::{ + ciphersuite::{HashFunction, KEM}, + error::KKTError, +}; + +use classic_mceliece_rust::keypair_boxed; +use libcrux_kem::{Algorithm, key_gen}; + +use libcrux_sha3; +use rand::{CryptoRng, RngCore}; + +// (decapsulation_key, encapsulation_key) +pub fn generate_keypair_libcrux( + rng: &mut R, + kem: KEM, +) -> Result<(libcrux_kem::PrivateKey, libcrux_kem::PublicKey), KKTError> +where + R: RngCore + CryptoRng, +{ + match kem { + KEM::MlKem768 => Ok(key_gen(Algorithm::MlKem768, rng)?), + KEM::XWing => Ok(key_gen(Algorithm::XWingKemDraft06, rng)?), + KEM::X25519 => Ok(key_gen(Algorithm::X25519, rng)?), + _ => Err(KKTError::KEMError { + info: "Key Generation Error: Unsupported Libcrux Algorithm", + }), + } +} +// (decapsulation_key, encapsulation_key) +pub fn generate_keypair_mceliece<'a, R>( + rng: &mut R, +) -> ( + classic_mceliece_rust::SecretKey<'a>, + classic_mceliece_rust::PublicKey<'a>, +) +where + // this is annoying because mceliece lib uses rand 0.8.5... + R: RngCore + CryptoRng, +{ + let (encapsulation_key, decapsulation_key) = keypair_boxed(rng); + (decapsulation_key, encapsulation_key) +} + +pub fn hash_key_bytes( + hash_function: &HashFunction, + hash_length: usize, + key_bytes: &[u8], +) -> Vec { + let mut hashed_key: Vec = vec![0u8; hash_length]; + match hash_function { + HashFunction::Blake3 => { + let mut hasher = blake3::Hasher::new(); + hasher.update(key_bytes); + hasher.finalize_xof().fill(&mut hashed_key); + hasher.reset(); + } + HashFunction::SHAKE256 => { + libcrux_sha3::shake256_ema(&mut hashed_key, key_bytes); + } + HashFunction::SHAKE128 => { + libcrux_sha3::shake128_ema(&mut hashed_key, key_bytes); + } + HashFunction::SHA256 => { + libcrux_sha3::sha256_ema(&mut hashed_key, key_bytes); + } + } + + hashed_key +} + +/// This does NOT run in constant time. +// It's fine for KKT since we are comparing hashes. +fn compare_hashes(a: &[u8], b: &[u8]) -> bool { + a == b +} + +pub fn validate_encapsulation_key( + hash_function: &HashFunction, + hash_length: usize, + encapsulation_key: &[u8], + expected_hash_bytes: &[u8], +) -> bool { + compare_hashes( + &hash_encapsulation_key(hash_function, hash_length, encapsulation_key), + expected_hash_bytes, + ) +} + +pub fn validate_key_bytes( + hash_function: &HashFunction, + hash_length: usize, + key_bytes: &[u8], + expected_hash_bytes: &[u8], +) -> bool { + compare_hashes( + &hash_key_bytes(hash_function, hash_length, key_bytes), + expected_hash_bytes, + ) +} + +pub fn hash_encapsulation_key( + hash_function: &HashFunction, + hash_length: usize, + encapsulation_key: &[u8], +) -> Vec { + hash_key_bytes(hash_function, hash_length, encapsulation_key) +} diff --git a/common/nym-kkt/src/kkt.rs b/common/nym-kkt/src/kkt.rs new file mode 100644 index 0000000000..7fcef8d3e3 --- /dev/null +++ b/common/nym-kkt/src/kkt.rs @@ -0,0 +1,355 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Convenience wrappers around KKT protocol functions for easier integration. +//! +//! This module provides simplified APIs for the common use case of exchanging +//! KEM public keys between a client (initiator) and gateway (responder). +//! +//! The underlying KKT protocol is implemented in the `session` module. + +use nym_crypto::asymmetric::ed25519; +use rand::{CryptoRng, RngCore}; + +use crate::{ + ciphersuite::{Ciphersuite, EncapsulationKey}, + context::{KKTContext, KKTMode}, + error::KKTError, + frame::KKTFrame, +}; + +// Re-export core session functions for advanced use cases +pub use crate::session::{ + anonymous_initiator_process, initiator_ingest_response, initiator_process, + responder_ingest_message, responder_process, +}; + +/// Request a KEM public key from a responder (OneWay mode). +/// +/// This is the client-side operation that initiates a KKT exchange. +/// The request will be signed with the provided signing key. +/// +/// # Arguments +/// * `rng` - Random number generator +/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms) +/// * `signing_key` - Client's Ed25519 signing key for authentication +/// +/// # Returns +/// * `KKTContext` - Context to use when validating the response +/// * `KKTFrame` - Signed request frame to send to responder +/// +/// # Example +/// ```ignore +/// let (context, request_frame) = request_kem_key( +/// &mut rng, +/// ciphersuite, +/// client_signing_key, +/// )?; +/// // Send request_frame to gateway +/// ``` +pub fn request_kem_key( + rng: &mut R, + ciphersuite: Ciphersuite, + signing_key: &ed25519::PrivateKey, +) -> Result<(KKTContext, KKTFrame), KKTError> { + // OneWay mode: client only wants responder's KEM key + // None: client doesn't send their own KEM key + initiator_process(rng, KKTMode::OneWay, ciphersuite, signing_key, None) +} + +/// Validate a KKT response and extract the responder's KEM public key. +/// +/// This is the client-side operation that processes the gateway's response. +/// It verifies the signature and validates the key hash against the expected value +/// (typically retrieved from a directory service). +/// +/// # Arguments +/// * `context` - Context from the initial request +/// * `responder_vk` - Responder's Ed25519 verification key (from directory) +/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory) +/// * `response_bytes` - Serialized response frame from responder +/// +/// # Returns +/// * `EncapsulationKey` - Authenticated KEM public key of the responder +/// +/// # Example +/// ```ignore +/// let gateway_kem_key = validate_kem_response( +/// &mut context, +/// gateway_verification_key, +/// &expected_hash_from_directory, +/// &response_bytes, +/// )?; +/// // Use gateway_kem_key for PSQ +/// ``` +pub fn validate_kem_response<'a>( + context: &mut KKTContext, + responder_vk: &ed25519::PublicKey, + expected_key_hash: &[u8], + response_bytes: &[u8], +) -> Result, KKTError> { + initiator_ingest_response(context, responder_vk, expected_key_hash, response_bytes) +} + +/// Handle a KKT request and generate a signed response with the responder's KEM key. +/// +/// This is the gateway-side operation that processes a client's KKT request. +/// It validates the request signature (if authenticated) and responds with +/// the gateway's KEM public key, signed for authenticity. +/// +/// # Arguments +/// * `request_frame` - Request frame received from initiator +/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous) +/// * `responder_signing_key` - Gateway's Ed25519 signing key +/// * `responder_kem_key` - Gateway's KEM public key to send +/// +/// # Returns +/// * `KKTFrame` - Signed response frame containing the KEM public key +/// +/// # Example +/// ```ignore +/// let response_frame = handle_kem_request( +/// &request_frame, +/// Some(client_verification_key), // or None for anonymous +/// gateway_signing_key, +/// &gateway_kem_public_key, +/// )?; +/// // Send response_frame back to client +/// ``` +pub fn handle_kem_request<'a>( + request_frame: &KKTFrame, + initiator_vk: Option<&ed25519::PublicKey>, + responder_signing_key: &ed25519::PrivateKey, + responder_kem_key: &EncapsulationKey<'a>, +) -> Result { + // Parse context from the request frame + let request_bytes = request_frame.to_bytes(); + let (_, request_context) = KKTFrame::from_bytes(&request_bytes)?; + + // Validate the request (verifies signature if initiator_vk provided) + let (mut response_context, _) = responder_ingest_message( + &request_context, + initiator_vk, + None, // Not checking initiator's KEM key in OneWay mode + request_frame, + )?; + + // Generate signed response with our KEM public key + responder_process( + &mut response_context, + request_frame.session_id_ref(), + responder_signing_key, + responder_kem_key, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ciphersuite::{HashFunction, KEM, SignatureScheme}, + key_utils::{generate_keypair_libcrux, hash_encapsulation_key}, + }; + + #[test] + fn test_kkt_wrappers_oneway_authenticated() { + let mut rng = rand::rng(); + + // Generate Ed25519 keypairs for both parties + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Generate responder's KEM keypair (X25519 for testing) + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + // Create ciphersuite + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Hash the KEM key (simulating directory storage) + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Client: Request KEM key + let (mut context, request_frame) = + request_kem_key(&mut rng, ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway: Handle request + let response_frame = handle_kem_request( + &request_frame, + Some(initiator_keypair.public_key()), // Authenticated + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client: Validate response + let obtained_key = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &key_hash, + &response_frame.to_bytes(), + ) + .unwrap(); + + // Verify we got the correct KEM key + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_kkt_wrappers_anonymous() { + let mut rng = rand::rng(); + + // Only responder has keys + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Anonymous initiator + let (mut context, request_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + + // Gateway: Handle anonymous request + let response_frame = handle_kem_request( + &request_frame, + None, // Anonymous - no verification key + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Initiator: Validate response + let obtained_key = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &key_hash, + &response_frame.to_bytes(), + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_invalid_signature_rejected() { + let mut rng = rand::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Different keypair for wrong signature + let mut wrong_secret = [0u8; 32]; + rng.fill_bytes(&mut wrong_secret); + let wrong_keypair = ed25519::KeyPair::from_secret(wrong_secret, 2); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let (_context, request_frame) = + request_kem_key(&mut rng, ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway handles request but we provide WRONG verification key + let result = handle_kem_request( + &request_frame, + Some(wrong_keypair.public_key()), // Wrong key! + responder_keypair.private_key(), + &responder_kem_key, + ); + + // Should fail signature verification + assert!(result.is_err()); + } + + #[test] + fn test_hash_mismatch_rejected() { + let mut rng = rand::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Use WRONG hash + let wrong_hash = [0u8; 32]; + + let (mut context, request_frame) = + request_kem_key(&mut rng, ciphersuite, initiator_keypair.private_key()).unwrap(); + + let response_frame = handle_kem_request( + &request_frame, + Some(initiator_keypair.public_key()), + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client validates with WRONG hash + let result = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &wrong_hash, // Wrong! + &response_frame.to_bytes(), + ); + + // Should fail hash validation + assert!(result.is_err()); + } +} diff --git a/common/nym-kkt/src/lib.rs b/common/nym-kkt/src/lib.rs new file mode 100644 index 0000000000..348e8fb01c --- /dev/null +++ b/common/nym-kkt/src/lib.rs @@ -0,0 +1,232 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod ciphersuite; +pub mod context; +// pub mod encryption; +pub mod error; +pub mod frame; +pub mod key_utils; +pub mod kkt; +pub mod session; + +// pub mod psq; + +// This must be less than 4 bits +pub const KKT_VERSION: u8 = 1; +const _: () = assert!(KKT_VERSION < 1 << 4); + +#[cfg(test)] +mod test { + use nym_crypto::asymmetric::ed25519; + use rand::prelude::*; + + use crate::{ + ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM}, + frame::KKTFrame, + key_utils::{generate_keypair_libcrux, generate_keypair_mceliece, hash_encapsulation_key}, + session::{ + anonymous_initiator_process, initiator_ingest_response, initiator_process, + responder_ingest_message, responder_process, + }, + }; + + #[test] + fn test_kkt_psq_e2e_clear() { + let mut rng = rand::rng(); + + // generate ed25519 keys + let mut secret_initiator: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_initiator); + let initiator_ed25519_keypair = ed25519::KeyPair::from_secret(secret_initiator, 0); + + let mut secret_responder: [u8; 32] = [0u8; 32]; + rng.fill_bytes(&mut secret_responder); + let responder_ed25519_keypair = ed25519::KeyPair::from_secret(secret_responder, 1); + for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] { + for hash_function in [ + HashFunction::Blake3, + HashFunction::SHA256, + HashFunction::SHAKE128, + HashFunction::SHAKE256, + ] { + let ciphersuite = Ciphersuite::resolve_ciphersuite( + kem, + hash_function, + crate::ciphersuite::SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // generate kem public keys + + let (responder_kem_public_key, initiator_kem_public_key) = match kem { + KEM::MlKem768 => ( + EncapsulationKey::MlKem768( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + EncapsulationKey::MlKem768( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + ), + KEM::XWing => ( + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1), + ), + KEM::X25519 => ( + EncapsulationKey::X25519( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + EncapsulationKey::X25519( + generate_keypair_libcrux(&mut rng, kem).unwrap().1, + ), + ), + KEM::McEliece => ( + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1), + ), + }; + + let i_kem_key_bytes = initiator_kem_public_key.encode(); + + let r_kem_key_bytes = responder_kem_public_key.encode(); + + let i_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &i_kem_key_bytes, + ); + + let r_dir_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &r_kem_key_bytes, + ); + + // Anonymous Initiator, OneWay + { + let (mut i_context, i_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + + let i_frame_bytes = i_frame.to_bytes(); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + let (mut r_context, _) = + responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap(); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + let r_bytes = r_frame.to_bytes(); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + // Initiator, OneWay + { + let (mut i_context, i_frame) = initiator_process( + &mut rng, + crate::context::KKTMode::OneWay, + ciphersuite, + initiator_ed25519_keypair.private_key(), + None, + ) + .unwrap(); + + let i_frame_bytes = i_frame.to_bytes(); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + None, + &i_frame_r, + ) + .unwrap(); + + assert!(r_obtained_key.is_none()); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + let r_bytes = r_frame.to_bytes(); + + let i_obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(i_obtained_key.encode(), r_kem_key_bytes) + } + + // Initiator, Mutual + { + let (mut i_context, i_frame) = initiator_process( + &mut rng, + crate::context::KKTMode::Mutual, + ciphersuite, + initiator_ed25519_keypair.private_key(), + Some(&initiator_kem_public_key), + ) + .unwrap(); + + let i_frame_bytes = i_frame.to_bytes(); + + let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap(); + + let (mut r_context, r_obtained_key) = responder_ingest_message( + &r_context, + Some(initiator_ed25519_keypair.public_key()), + Some(&i_dir_hash), + &i_frame_r, + ) + .unwrap(); + + assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes); + + let r_frame = responder_process( + &mut r_context, + i_frame_r.session_id_ref(), + responder_ed25519_keypair.private_key(), + &responder_kem_public_key, + ) + .unwrap(); + + let r_bytes = r_frame.to_bytes(); + + let obtained_key = initiator_ingest_response( + &mut i_context, + responder_ed25519_keypair.public_key(), + &r_dir_hash, + &r_bytes, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), r_kem_key_bytes) + } + } + } + } +} diff --git a/common/nym-kkt/src/session.rs b/common/nym-kkt/src/session.rs new file mode 100644 index 0000000000..75492a6170 --- /dev/null +++ b/common/nym-kkt/src/session.rs @@ -0,0 +1,234 @@ +use nym_crypto::asymmetric::ed25519::{self, Signature}; +use rand::{CryptoRng, RngCore}; + +use crate::{ + ciphersuite::{Ciphersuite, EncapsulationKey}, + context::{KKTContext, KKTMode, KKTRole, KKTStatus}, + error::KKTError, + frame::{KKT_SESSION_ID_LEN, KKTFrame}, + key_utils::validate_encapsulation_key, +}; + +pub fn initiator_process<'a, R>( + rng: &mut R, + mode: KKTMode, + ciphersuite: Ciphersuite, + signing_key: &ed25519::PrivateKey, + own_encapsulation_key: Option<&EncapsulationKey<'a>>, +) -> Result<(KKTContext, KKTFrame), KKTError> +where + R: CryptoRng + RngCore, +{ + let context = KKTContext::new(KKTRole::Initiator, mode, ciphersuite)?; + + let context_bytes = context.encode()?; + + let mut session_id = [0; KKT_SESSION_ID_LEN]; + // Generate Session ID + rng.fill_bytes(&mut session_id); + + let body: &[u8] = match mode { + KKTMode::OneWay => &[], + KKTMode::Mutual => match own_encapsulation_key { + Some(encaps_key) => &encaps_key.encode(), + + // Missing key + None => { + return Err(KKTError::FunctionInputError { + info: "KEM Key Not Provided", + }); + } + }, + }; + + let mut bytes_to_sign = + Vec::with_capacity(context.full_message_len() - context.signature_len()); + bytes_to_sign.extend_from_slice(&context_bytes); + bytes_to_sign.extend_from_slice(body); + bytes_to_sign.extend_from_slice(&session_id); + + let signature = signing_key.sign(bytes_to_sign).to_bytes(); + + Ok(( + context, + KKTFrame::new(&context_bytes, body, &session_id, &signature), + )) +} + +pub fn anonymous_initiator_process( + rng: &mut R, + ciphersuite: Ciphersuite, +) -> Result<(KKTContext, KKTFrame), KKTError> +where + R: CryptoRng + RngCore, +{ + let context = KKTContext::new(KKTRole::AnonymousInitiator, KKTMode::OneWay, ciphersuite)?; + let context_bytes = context.encode()?; + + let mut session_id = [0u8; KKT_SESSION_ID_LEN]; + rng.fill_bytes(&mut session_id); + + Ok(( + context, + KKTFrame::new(&context_bytes, &[], &session_id, &[]), + )) +} + +pub fn initiator_ingest_response<'a>( + own_context: &mut KKTContext, + remote_verification_key: &ed25519::PublicKey, + expected_hash: &[u8], + message_bytes: &[u8], +) -> Result, KKTError> { + // sizes have to be correct + let (frame, remote_context) = KKTFrame::from_bytes(message_bytes)?; + + check_compatibility(own_context, &remote_context)?; + match remote_context.status() { + KKTStatus::Ok => { + let mut bytes_to_verify: Vec = Vec::with_capacity( + remote_context.full_message_len() - remote_context.signature_len(), + ); + bytes_to_verify.extend_from_slice(&remote_context.encode()?); + bytes_to_verify.extend_from_slice(frame.body_ref()); + bytes_to_verify.extend_from_slice(frame.session_id_ref()); + + match Signature::from_bytes(frame.signature_ref()) { + Ok(sig) => match remote_verification_key.verify(bytes_to_verify, &sig) { + Ok(()) => { + let received_encapsulation_key = EncapsulationKey::decode( + own_context.ciphersuite().kem(), + frame.body_ref(), + )?; + + match validate_encapsulation_key( + &own_context.ciphersuite().hash_function(), + own_context.ciphersuite().hash_len(), + frame.body_ref(), + expected_hash, + ) { + true => Ok(received_encapsulation_key), + + // The key does not match the hash obtained from the directory + false => Err(KKTError::KEMError { + info: "Hash of received encapsulation key does not match the value stored on the directory.", + }), + } + } + Err(_) => Err(KKTError::SigVerifError), + }, + Err(_) => Err(KKTError::SigConstructorError), + } + } + _ => Err(KKTError::ResponderFlaggedError { + status: remote_context.status(), + }), + } +} + +// todo: figure out how to handle errors using status codes + +pub fn responder_ingest_message<'a>( + remote_context: &KKTContext, + remote_verification_key: Option<&ed25519::PublicKey>, + expected_hash: Option<&[u8]>, + remote_frame: &KKTFrame, +) -> Result<(KKTContext, Option>), KKTError> { + let own_context = remote_context.derive_responder_header()?; + + match remote_context.role() { + KKTRole::AnonymousInitiator => Ok((own_context, None)), + + KKTRole::Initiator => { + match remote_verification_key { + Some(remote_verif_key) => { + let mut bytes_to_verify: Vec = Vec::with_capacity( + own_context.full_message_len() - own_context.signature_len(), + ); + bytes_to_verify.extend_from_slice(remote_frame.context_ref()); + bytes_to_verify.extend_from_slice(remote_frame.body_ref()); + bytes_to_verify.extend_from_slice(remote_frame.session_id_ref()); + + match Signature::from_bytes(remote_frame.signature_ref()) { + Ok(sig) => match remote_verif_key.verify(bytes_to_verify, &sig) { + Ok(()) => { + // using own_context here because maybe for whatever reason we want to ignore the remote kem key + match own_context.mode() { + KKTMode::OneWay => Ok((own_context, None)), + KKTMode::Mutual => { + match expected_hash { + Some(expected_hash) => { + let received_encapsulation_key = + EncapsulationKey::decode( + own_context.ciphersuite().kem(), + remote_frame.body_ref(), + )?; + if validate_encapsulation_key( + &own_context.ciphersuite().hash_function(), + own_context.ciphersuite().hash_len(), + remote_frame.body_ref(), + expected_hash, + ) { + Ok(( + own_context, + Some(received_encapsulation_key), + )) + } + // The key does not match the hash obtained from the directory + else { + Err(KKTError::KEMError { + info: "Hash of received encapsulation key does not match the value stored on the directory.", + }) + } + } + None => Err(KKTError::FunctionInputError { + info: "Expected hash of the remote encapsulation key is not provided.", + }), + } + } + } + } + Err(_) => Err(KKTError::SigVerifError), + }, + Err(_) => Err(KKTError::SigConstructorError), + } + } + None => Err(KKTError::FunctionInputError { + info: "Remote Signature Verification Key Not Provided", + }), + } + } + KKTRole::Responder => Err(KKTError::IncompatibilityError { + info: "Responder received a request from another responder.", + }), + } +} + +pub fn responder_process<'a>( + own_context: &mut KKTContext, + session_id: &[u8], + signing_key: &ed25519::PrivateKey, + encapsulation_key: &EncapsulationKey<'a>, +) -> Result { + let body = encapsulation_key.encode(); + + let context_bytes = own_context.encode()?; + + let mut bytes_to_sign = + Vec::with_capacity(own_context.full_message_len() - own_context.signature_len()); + bytes_to_sign.extend_from_slice(&own_context.encode()?); + bytes_to_sign.extend_from_slice(&body); + bytes_to_sign.extend_from_slice(session_id); + + let signature = signing_key.sign(bytes_to_sign).to_bytes(); + + Ok(KKTFrame::new(&context_bytes, &body, session_id, &signature)) +} + +fn check_compatibility( + _own_context: &KKTContext, + _remote_context: &KKTContext, +) -> Result<(), KKTError> { + // todo: check ciphersuite/context compatibility + Ok(()) +} diff --git a/common/nym-lp-common/Cargo.toml b/common/nym-lp-common/Cargo.toml index f3f23b8fdb..b70550c82b 100644 --- a/common/nym-lp-common/Cargo.toml +++ b/common/nym-lp-common/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "nym-lp-common" version = "0.1.0" -edition = "2021" +edition = { workspace = true } +license = { workspace = true } [dependencies] diff --git a/common/nym-lp/Cargo.toml b/common/nym-lp/Cargo.toml index 13b7bfd13e..ea88885e81 100644 --- a/common/nym-lp/Cargo.toml +++ b/common/nym-lp/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "nym-lp" version = "0.1.0" -edition = "2021" +edition = { workspace = true } +license = { workspace = true } [dependencies] bincode = { workspace = true } @@ -14,13 +15,26 @@ bytes = { workspace = true } dashmap = { workspace = true } sha2 = { workspace = true } ansi_term = { workspace = true } +tracing = { workspace = true } utoipa = { workspace = true, features = ["macros", "non_strict_integers"] } rand = { workspace = true } +# rand 0.9 for KKT integration (nym-kkt uses rand 0.9) +rand09 = { package = "rand", version = "0.9.2" } nym-crypto = { path = "../crypto", features = ["hashing", "asymmetric"] } +nym-kkt = { path = "../nym-kkt" } nym-lp-common = { path = "../nym-lp-common" } nym-sphinx = { path = "../nymsphinx" } +# libcrux dependencies for PSQ (Post-Quantum PSK derivation) +libcrux-psq = { git = "https://github.com/cryspen/libcrux", features = [ + "test-utils", +] } +libcrux-kem = { git = "https://github.com/cryspen/libcrux" } +libcrux-traits = { git = "https://github.com/cryspen/libcrux" } +tls_codec = { workspace = true } +num_enum = { workspace = true } + [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } rand_chacha = "0.3" diff --git a/common/nym-lp/benches/replay_protection.rs b/common/nym-lp/benches/replay_protection.rs index 72b44fbc78..562982e527 100644 --- a/common/nym-lp/benches/replay_protection.rs +++ b/common/nym-lp/benches/replay_protection.rs @@ -1,4 +1,4 @@ -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use nym_lp::replay::ReceivingKeyCounterValidator; use parking_lot::Mutex; use rand::{Rng, SeedableRng}; diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index 6154cdba50..260d344ec6 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -1,9 +1,12 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::message::{ClientHelloData, EncryptedDataPayload, HandshakeData, LpMessage, MessageType}; -use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use crate::LpError; +use crate::message::{ + ClientHelloData, EncryptedDataPayload, HandshakeData, KKTRequestData, KKTResponseData, + LpMessage, MessageType, +}; +use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use bytes::BytesMut; /// Parses a complete Lewes Protocol packet from a byte slice (e.g., a UDP datagram payload). @@ -43,7 +46,7 @@ pub fn parse_lp_packet(src: &[u8]) -> Result { if message_size != 0 { return Err(LpError::InvalidPayloadSize { expected: 0, - actual: message_size, + actual: message_size, }); } LpMessage::Busy @@ -63,6 +66,14 @@ pub fn parse_lp_packet(src: &[u8]) -> Result { .map_err(|e| LpError::DeserializationError(e.to_string()))?; LpMessage::ClientHello(data) } + MessageType::KKTRequest => { + // KKT request contains serialized KKTFrame bytes + LpMessage::KKTRequest(KKTRequestData(payload_slice.to_vec())) + } + MessageType::KKTResponse => { + // KKT response contains serialized KKTFrame bytes + LpMessage::KKTResponse(KKTResponseData(payload_slice.to_vec())) + } }; // Extract trailer @@ -105,9 +116,9 @@ mod tests { // Import standalone functions use super::{parse_lp_packet, serialize_lp_packet}; // Keep necessary imports + use crate::LpError; use crate::message::{EncryptedDataPayload, HandshakeData, LpMessage, MessageType}; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; - use crate::LpError; use bytes::BytesMut; // === Updated Encode/Decode Tests === @@ -280,7 +291,7 @@ mod tests { buf_too_short.extend_from_slice(&42u32.to_le_bytes()); // Sender index buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // Counter buf_too_short.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // Handshake type - // No payload, no trailer. Length = 16+2=18. Min size = 34. + // No payload, no trailer. Length = 16+2=18. Min size = 34. let result_too_short = parse_lp_packet(&buf_too_short); assert!(result_too_short.is_err()); assert!(matches!( @@ -317,7 +328,7 @@ mod tests { buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // Counter buf_too_short.extend_from_slice(&MessageType::Busy.to_u16().to_le_bytes()); // Type buf_too_short.extend_from_slice(&[0; TRAILER_LEN - 1]); // Missing last byte of trailer - // Length = 16 + 2 + 15 = 33. Min Size = 34. + // Length = 16 + 2 + 15 = 33. Min Size = 34. let result_too_short = parse_lp_packet(&buf_too_short); assert!( result_too_short.is_err(), @@ -337,7 +348,7 @@ mod tests { buf.extend_from_slice(&42u32.to_le_bytes()); // Sender index buf.extend_from_slice(&123u64.to_le_bytes()); // Counter buf.extend_from_slice(&255u16.to_le_bytes()); // Invalid message type - // Need payload and trailer to meet min_size requirement + // Need payload and trailer to meet min_size requirement let payload_size = 10; // Arbitrary buf.extend_from_slice(&vec![0u8; payload_size]); // Some data buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer @@ -390,9 +401,11 @@ mod tests { // Create ClientHelloData let client_key = [42u8; 32]; + let client_ed25519_key = [43u8; 32]; let salt = [99u8; 32]; let hello_data = ClientHelloData { client_lp_public_key: client_key, + client_ed25519_public_key: client_ed25519_key, salt, }; @@ -438,7 +451,8 @@ mod tests { // Create ClientHelloData with fresh salt let client_key = [7u8; 32]; - let hello_data = ClientHelloData::new_with_fresh_salt(client_key); + let client_ed25519_key = [8u8; 32]; + let hello_data = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); // Create a ClientHello message packet let packet = LpPacket { @@ -532,6 +546,7 @@ mod tests { let hello_data = ClientHelloData { client_lp_public_key: [version; 32], + client_ed25519_public_key: [version.wrapping_add(2); 32], salt: [version.wrapping_add(1); 32], }; diff --git a/common/nym-lp/src/error.rs b/common/nym-lp/src/error.rs index ecb8389323..456bc72154 100644 --- a/common/nym-lp/src/error.rs +++ b/common/nym-lp/src/error.rs @@ -2,6 +2,7 @@ // 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)] @@ -48,6 +49,9 @@ pub enum LpError { #[error("Deserialization error: {0}")] DeserializationError(String), + #[error("KKT protocol error: {0}")] + KKTError(String), + #[error(transparent)] InvalidBase58String(#[from] bs58::decode::Error), @@ -70,4 +74,8 @@ pub enum LpError { /// State machine not found. #[error("State machine not found for lp_id: {lp_id}")] StateMachineNotFound { lp_id: u32 }, + + /// Ed25519 to X25519 conversion error. + #[error("Ed25519 key conversion error: {0}")] + Ed25519RecoveryError(#[from] Ed25519RecoveryError), } diff --git a/common/nym-lp/src/keypair.rs b/common/nym-lp/src/keypair.rs index abcc14f24a..6f9546ba52 100644 --- a/common/nym-lp/src/keypair.rs +++ b/common/nym-lp/src/keypair.rs @@ -8,8 +8,15 @@ use utoipa::ToSchema; use crate::LpError; +#[derive(Clone)] pub struct PrivateKey(SphinxPrivateKey); +impl fmt::Debug for PrivateKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("PrivateKey").field(&"[REDACTED]").finish() + } +} + impl Deref for PrivateKey { type Target = SphinxPrivateKey; @@ -49,8 +56,17 @@ impl PrivateKey { } } +#[derive(Clone)] pub struct PublicKey(SphinxPublicKey); +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("PublicKey") + .field(&self.to_base58_string()) + .finish() + } +} + impl Deref for PublicKey { type Target = SphinxPublicKey; diff --git a/common/nym-lp/src/kkt_orchestrator.rs b/common/nym-lp/src/kkt_orchestrator.rs new file mode 100644 index 0000000000..5999a9929f --- /dev/null +++ b/common/nym-lp/src/kkt_orchestrator.rs @@ -0,0 +1,468 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! KKT (Key Encapsulation Transport) orchestration for nym-lp sessions. +//! +//! This module provides functions to perform KKT key exchange before establishing +//! an nym-lp session. The KKT protocol allows secure distribution of post-quantum +//! KEM public keys, which are then used with PSQ to derive a strong pre-shared key +//! for the Noise protocol. +//! +//! # Protocol Flow +//! +//! 1. **Client (Initiator)**: +//! - Calls `create_request()` to generate a KKT request +//! - Sends `LpMessage::KKTRequest` to gateway +//! - Receives `LpMessage::KKTResponse` from gateway +//! - Calls `process_response()` to validate and extract gateway's KEM key +//! +//! 2. **Gateway (Responder)**: +//! - Receives `LpMessage::KKTRequest` from client +//! - Calls `handle_request()` to validate request and generate response +//! - Sends `LpMessage::KKTResponse` to client +//! +//! # Example +//! +//! ```ignore +//! use nym_lp::kkt_orchestrator::{create_request, process_response, handle_request}; +//! use nym_lp::message::{KKTRequestData, KKTResponseData}; +//! use nym-kkt::ciphersuite::{Ciphersuite, KEM, HashFunction, SignatureScheme, EncapsulationKey}; +//! +//! // Setup ciphersuite +//! let ciphersuite = Ciphersuite::resolve_ciphersuite( +//! KEM::X25519, +//! HashFunction::Blake3, +//! SignatureScheme::Ed25519, +//! None, +//! ).unwrap(); +//! +//! // Client: Create request +//! let (client_context, request_data) = create_request( +//! ciphersuite, +//! &client_signing_key, +//! ).unwrap(); +//! +//! // Gateway: Handle request +//! let response_data = handle_request( +//! &request_data, +//! Some(&client_verification_key), +//! &gateway_signing_key, +//! &gateway_kem_public_key, +//! ).unwrap(); +//! +//! // Client: Process response +//! let gateway_kem_key = process_response( +//! client_context, +//! &gateway_verification_key, +//! &expected_key_hash, +//! &response_data, +//! ).unwrap(); +//! ``` + +use crate::LpError; +use crate::message::{KKTRequestData, KKTResponseData}; +use nym_crypto::asymmetric::ed25519; +use nym_kkt::ciphersuite::{Ciphersuite, EncapsulationKey}; +use nym_kkt::context::KKTContext; +use nym_kkt::frame::KKTFrame; +use nym_kkt::kkt::{handle_kem_request, request_kem_key, validate_kem_response}; + +/// Creates a KKT request to obtain the responder's KEM public key. +/// +/// This is called by the **client (initiator)** to begin the KKT exchange. +/// The returned context must be used when processing the response. +/// +/// # Arguments +/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms) +/// * `signing_key` - Client's Ed25519 signing key for authentication +/// +/// # Returns +/// * `KKTContext` - Context to use when validating the response +/// * `KKTRequestData` - Serialized KKT request frame to send to gateway +/// +/// # Errors +/// Returns `LpError::KKTError` if KKT request generation fails. +pub fn create_request( + ciphersuite: Ciphersuite, + signing_key: &ed25519::PrivateKey, +) -> Result<(KKTContext, KKTRequestData), LpError> { + // Note: Uses rand 0.9's thread_rng() to match nym-kkt's rand version + let mut rng = rand09::rng(); + let (context, frame) = request_kem_key(&mut rng, ciphersuite, signing_key) + .map_err(|e| LpError::KKTError(e.to_string()))?; + + let request_bytes = frame.to_bytes(); + Ok((context, KKTRequestData(request_bytes))) +} + +/// Processes a KKT response and extracts the responder's KEM public key. +/// +/// This is called by the **client (initiator)** after receiving a KKT response +/// from the gateway. It verifies the signature and validates the key hash. +/// +/// # Arguments +/// * `context` - Context from the initial `create_request()` call +/// * `responder_vk` - Responder's Ed25519 verification key (from directory) +/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory) +/// * `response_data` - Serialized KKT response frame from responder +/// +/// # Returns +/// * `EncapsulationKey` - Authenticated KEM public key of the responder +/// +/// # Errors +/// Returns `LpError::KKTError` if: +/// - Response deserialization fails +/// - Signature verification fails +/// - Key hash doesn't match expected value +pub fn process_response<'a>( + mut context: KKTContext, + responder_vk: &ed25519::PublicKey, + expected_key_hash: &[u8], + response_data: &KKTResponseData, +) -> Result, LpError> { + validate_kem_response( + &mut context, + responder_vk, + expected_key_hash, + &response_data.0, + ) + .map_err(|e| LpError::KKTError(e.to_string())) +} + +/// Handles a KKT request and generates a signed response with the responder's KEM key. +/// +/// This is called by the **gateway (responder)** when receiving a KKT request +/// from a client. It validates the request signature (if authenticated) and +/// responds with the gateway's KEM public key, signed for authenticity. +/// +/// # Arguments +/// * `request_data` - Serialized KKT request frame from initiator +/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous) +/// * `responder_signing_key` - Gateway's Ed25519 signing key +/// * `responder_kem_key` - Gateway's KEM public key to send +/// +/// # Returns +/// * `KKTResponseData` - Signed response frame containing the KEM public key +/// +/// # Errors +/// Returns `LpError::KKTError` if: +/// - Request deserialization fails +/// - Signature verification fails (if authenticated) +/// - Response generation fails +pub fn handle_request<'a>( + request_data: &KKTRequestData, + initiator_vk: Option<&ed25519::PublicKey>, + responder_signing_key: &ed25519::PrivateKey, + responder_kem_key: &EncapsulationKey<'a>, +) -> Result { + // Deserialize request frame + let (request_frame, _) = KKTFrame::from_bytes(&request_data.0) + .map_err(|e| LpError::KKTError(format!("Failed to parse KKT request: {}", e)))?; + + // Handle the request and generate response + let response_frame = handle_kem_request( + &request_frame, + initiator_vk, + responder_signing_key, + responder_kem_key, + ) + .map_err(|e| LpError::KKTError(e.to_string()))?; + + let response_bytes = response_frame.to_bytes(); + Ok(KKTResponseData(response_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_kkt::ciphersuite::{HashFunction, KEM, SignatureScheme}; + use nym_kkt::key_utils::{generate_keypair_libcrux, hash_encapsulation_key}; + use rand09::RngCore; + + #[test] + fn test_kkt_roundtrip_authenticated() { + let mut rng = rand09::rng(); + + // Generate Ed25519 keypairs for both parties + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Generate responder's KEM keypair (X25519 for testing) + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + // Create ciphersuite + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Hash the KEM key (simulating directory storage) + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Client: Create request + let (context, request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway: Handle request + let response_data = handle_request( + &request_data, + Some(initiator_keypair.public_key()), + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client: Process response + let obtained_key = process_response( + context, + responder_keypair.public_key(), + &key_hash, + &response_data, + ) + .unwrap(); + + // Verify we got the correct KEM key + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_kkt_roundtrip_anonymous() { + let mut rng = rand09::rng(); + + // Only responder has keys (anonymous initiator) + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let key_hash = hash_encapsulation_key( + &ciphersuite.hash_function(), + ciphersuite.hash_len(), + &responder_kem_key.encode(), + ); + + // Anonymous initiator - use anonymous_initiator_process directly + use nym_kkt::kkt::anonymous_initiator_process; + let (mut context, request_frame) = + anonymous_initiator_process(&mut rng, ciphersuite).unwrap(); + let request_data = KKTRequestData(request_frame.to_bytes()); + + // Gateway: Handle anonymous request + let response_data = handle_request( + &request_data, + None, // Anonymous - no verification key + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Initiator: Validate response + let obtained_key = validate_kem_response( + &mut context, + responder_keypair.public_key(), + &key_hash, + &response_data.0, + ) + .unwrap(); + + assert_eq!(obtained_key.encode(), responder_kem_key.encode()); + } + + #[test] + fn test_invalid_signature_rejected() { + let mut rng = rand09::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + // Different keypair for wrong signature + let mut wrong_secret = [0u8; 32]; + rng.fill_bytes(&mut wrong_secret); + let wrong_keypair = ed25519::KeyPair::from_secret(wrong_secret, 2); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let (_context, request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Gateway handles request but we provide WRONG verification key + let result = handle_request( + &request_data, + Some(wrong_keypair.public_key()), // Wrong key! + responder_keypair.private_key(), + &responder_kem_key, + ); + + // Should fail signature verification + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } + + #[test] + fn test_hash_mismatch_rejected() { + let mut rng = rand09::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + // Use WRONG hash + let wrong_hash = [0u8; 32]; + + let (context, request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + let response_data = handle_request( + &request_data, + Some(initiator_keypair.public_key()), + responder_keypair.private_key(), + &responder_kem_key, + ) + .unwrap(); + + // Client validates with WRONG hash + let result = process_response( + context, + responder_keypair.public_key(), + &wrong_hash, // Wrong! + &response_data, + ); + + // Should fail hash validation + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } + + #[test] + fn test_malformed_request_rejected() { + let mut rng = rand09::rng(); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk); + + // Create malformed request data (invalid bytes) + let malformed_request = KKTRequestData(vec![0xFF; 100]); + + let result = handle_request( + &malformed_request, + None, + responder_keypair.private_key(), + &responder_kem_key, + ); + + // Should fail to parse + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } + + #[test] + fn test_malformed_response_rejected() { + let mut rng = rand09::rng(); + + let mut initiator_secret = [0u8; 32]; + rng.fill_bytes(&mut initiator_secret); + let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0); + + let mut responder_secret = [0u8; 32]; + rng.fill_bytes(&mut responder_secret); + let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1); + + let ciphersuite = Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) + .unwrap(); + + let (context, _request_data) = + create_request(ciphersuite, initiator_keypair.private_key()).unwrap(); + + // Create malformed response data + let malformed_response = KKTResponseData(vec![0xFF; 100]); + let key_hash = [0u8; 32]; + + let result = process_response( + context, + responder_keypair.public_key(), + &key_hash, + &malformed_response, + ); + + // Should fail to parse + assert!(result.is_err()); + if let Err(LpError::KKTError(_)) = result { + // Expected + } else { + panic!("Expected KKTError"); + } + } +} diff --git a/common/nym-lp/src/lib.rs b/common/nym-lp/src/lib.rs index 243b238493..c23fec5341 100644 --- a/common/nym-lp/src/lib.rs +++ b/common/nym-lp/src/lib.rs @@ -4,6 +4,7 @@ pub mod codec; pub mod error; pub mod keypair; +pub mod kkt_orchestrator; pub mod message; pub mod noise_protocol; pub mod packet; @@ -19,9 +20,8 @@ pub use error::LpError; use keypair::PublicKey; pub use message::{ClientHelloData, LpMessage}; pub use packet::LpPacket; -pub use psk::derive_psk; pub use replay::{ReceivingKeyCounterValidator, ReplayError}; -pub use session::LpSession; +pub use session::{LpSession, generate_fresh_salt}; pub use session_manager::SessionManager; // Add the new state machine module @@ -34,35 +34,47 @@ pub const NOISE_PSK_INDEX: u8 = 3; #[cfg(test)] pub fn sessions_for_tests() -> (LpSession, LpSession) { use crate::{keypair::Keypair, make_lp_id}; + use nym_crypto::asymmetric::ed25519; + // X25519 keypairs for Noise protocol let keypair_1 = Keypair::default(); let keypair_2 = Keypair::default(); let id = make_lp_id(keypair_1.public_key(), keypair_2.public_key()); + // Ed25519 keypairs for PSQ authentication (placeholders for testing) + let ed25519_keypair_1 = ed25519::KeyPair::from_secret([1u8; 32], 0); + let ed25519_keypair_2 = ed25519::KeyPair::from_secret([2u8; 32], 1); + // Use consistent salt for deterministic tests let salt = [1u8; 32]; - // Initiator derives PSK from their perspective - let initiator_psk = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt); + // PSQ will always derive the PSK during handshake using X25519 as DHKEM let initiator_session = LpSession::new( id, true, - &keypair_1.private_key().to_bytes(), - &keypair_2.public_key().to_bytes(), - &initiator_psk, + ( + ed25519_keypair_1.private_key(), + ed25519_keypair_1.public_key(), + ), + keypair_1.private_key(), + ed25519_keypair_2.public_key(), + keypair_2.public_key(), + &salt, ) .expect("Test session creation failed"); - // Responder derives same PSK from their perspective - let responder_psk = derive_psk(keypair_2.private_key(), keypair_1.public_key(), &salt); - let responder_session = LpSession::new( id, false, - &keypair_2.private_key().to_bytes(), - &keypair_1.public_key().to_bytes(), - &responder_psk, + ( + ed25519_keypair_2.private_key(), + ed25519_keypair_2.public_key(), + ), + keypair_2.private_key(), + ed25519_keypair_1.public_key(), + keypair_1.public_key(), + &salt, ) .expect("Test session creation failed"); @@ -105,11 +117,11 @@ pub fn make_conv_id(src: &[u8], dst: &[u8]) -> u32 { #[cfg(test)] mod tests { - use crate::keypair::Keypair; + use crate::keypair::PublicKey; use crate::message::LpMessage; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use crate::session_manager::SessionManager; - use crate::{make_lp_id, sessions_for_tests, LpError}; + use crate::{LpError, make_lp_id, sessions_for_tests}; use bytes::BytesMut; // Import the new standalone functions @@ -216,28 +228,60 @@ mod tests { #[test] fn test_session_manager_integration() { + use nym_crypto::asymmetric::ed25519; + // Create session manager let local_manager = SessionManager::new(); let remote_manager = SessionManager::new(); - let local_keypair = Keypair::default(); - let remote_keypair = Keypair::default(); - let lp_id = make_lp_id(local_keypair.public_key(), remote_keypair.public_key()); + + // Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_local = ed25519::KeyPair::from_secret([8u8; 32], 0); + let ed25519_keypair_remote = ed25519::KeyPair::from_secret([9u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_local = ed25519_keypair_local + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_remote = ed25519_keypair_remote + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_local = PublicKey::from_bytes(x25519_pub_local.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_remote = PublicKey::from_bytes(x25519_pub_remote.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_local, &lp_pub_remote); + + // Test salt + let salt = [46u8; 32]; + // Create a session via manager let _ = local_manager .create_session_state_machine( - &local_keypair, - remote_keypair.public_key(), + ( + ed25519_keypair_local.private_key(), + ed25519_keypair_local.public_key(), + ), + ed25519_keypair_remote.public_key(), true, - &[2u8; 32], + &salt, ) .unwrap(); let _ = remote_manager .create_session_state_machine( - &remote_keypair, - local_keypair.public_key(), + ( + ed25519_keypair_remote.private_key(), + ed25519_keypair_remote.public_key(), + ), + ed25519_keypair_local.public_key(), false, - &[2u8; 32], + &salt, ) .unwrap(); // === Packet 1 (Counter 0 - Should succeed) === diff --git a/common/nym-lp/src/message.rs b/common/nym-lp/src/message.rs index d308d353f2..bbc0ea9904 100644 --- a/common/nym-lp/src/message.rs +++ b/common/nym-lp/src/message.rs @@ -3,13 +3,16 @@ use std::fmt::{self, Display}; // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use bytes::{BufMut, BytesMut}; +use num_enum::{IntoPrimitive, TryFromPrimitive}; use serde::{Deserialize, Serialize}; /// Data structure for the ClientHello message #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientHelloData { - /// Client's LP x25519 public key (32 bytes) + /// Client's LP x25519 public key (32 bytes) - derived from Ed25519 key pub client_lp_public_key: [u8; 32], + /// Client's Ed25519 public key (32 bytes) - for PSQ authentication + pub client_ed25519_public_key: [u8; 32], /// Salt for PSK derivation (32 bytes: 8-byte timestamp + 24-byte nonce) pub salt: [u8; 32], } @@ -20,9 +23,12 @@ impl ClientHelloData { /// Salt format: 8 bytes timestamp (u64 LE) + 24 bytes random nonce /// /// # Arguments - /// * `client_lp_public_key` - Client's x25519 public key - /// * `protocol_version` - Protocol version number - pub fn new_with_fresh_salt(client_lp_public_key: [u8; 32]) -> Self { + /// * `client_lp_public_key` - Client's x25519 public key (derived from Ed25519) + /// * `client_ed25519_public_key` - Client's Ed25519 public key (for PSQ authentication) + pub fn new_with_fresh_salt( + client_lp_public_key: [u8; 32], + client_ed25519_public_key: [u8; 32], + ) -> Self { use std::time::{SystemTime, UNIX_EPOCH}; // Generate salt: timestamp + nonce @@ -41,6 +47,7 @@ impl ClientHelloData { Self { client_lp_public_key, + client_ed25519_public_key, salt, } } @@ -56,33 +63,24 @@ impl ClientHelloData { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, IntoPrimitive, TryFromPrimitive)] #[repr(u16)] pub enum MessageType { Busy = 0x0000, Handshake = 0x0001, EncryptedData = 0x0002, ClientHello = 0x0003, + KKTRequest = 0x0004, + KKTResponse = 0x0005, } impl MessageType { pub(crate) fn from_u16(value: u16) -> Option { - match value { - 0x0000 => Some(MessageType::Busy), - 0x0001 => Some(MessageType::Handshake), - 0x0002 => Some(MessageType::EncryptedData), - 0x0003 => Some(MessageType::ClientHello), - _ => None, - } + MessageType::try_from(value).ok() } pub fn to_u16(&self) -> u16 { - match self { - MessageType::Busy => 0x0000, - MessageType::Handshake => 0x0001, - MessageType::EncryptedData => 0x0002, - MessageType::ClientHello => 0x0003, - } + u16::from(*self) } } @@ -92,12 +90,22 @@ pub struct HandshakeData(pub Vec); #[derive(Debug, Clone, PartialEq, Eq)] pub struct EncryptedDataPayload(pub Vec); +/// KKT request frame data (serialized KKTFrame bytes) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KKTRequestData(pub Vec); + +/// KKT response frame data (serialized KKTFrame bytes) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KKTResponseData(pub Vec); + #[derive(Debug, Clone)] pub enum LpMessage { Busy, Handshake(HandshakeData), EncryptedData(EncryptedDataPayload), ClientHello(ClientHelloData), + KKTRequest(KKTRequestData), + KKTResponse(KKTResponseData), } impl Display for LpMessage { @@ -107,6 +115,8 @@ impl Display for LpMessage { 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"), } } } @@ -118,6 +128,8 @@ impl LpMessage { LpMessage::Handshake(payload) => payload.0.as_slice(), LpMessage::EncryptedData(payload) => payload.0.as_slice(), LpMessage::ClientHello(_) => unimplemented!(), // Structured data, serialized in encode_content + LpMessage::KKTRequest(payload) => payload.0.as_slice(), + LpMessage::KKTResponse(payload) => payload.0.as_slice(), } } @@ -127,6 +139,8 @@ impl LpMessage { 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(), } } @@ -135,7 +149,9 @@ impl LpMessage { LpMessage::Busy => 0, LpMessage::Handshake(payload) => payload.0.len(), LpMessage::EncryptedData(payload) => payload.0.len(), - LpMessage::ClientHello(_) => 65, // 32 bytes key + 1 byte version + 32 bytes salt + LpMessage::ClientHello(_) => 97, // 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt + 1 byte bincode overhead + LpMessage::KKTRequest(payload) => payload.0.len(), + LpMessage::KKTResponse(payload) => payload.0.len(), } } @@ -145,6 +161,8 @@ impl LpMessage { LpMessage::Handshake(_) => MessageType::Handshake, LpMessage::EncryptedData(_) => MessageType::EncryptedData, LpMessage::ClientHello(_) => MessageType::ClientHello, + LpMessage::KKTRequest(_) => MessageType::KKTRequest, + LpMessage::KKTResponse(_) => MessageType::KKTResponse, } } @@ -163,6 +181,12 @@ impl LpMessage { bincode::serialize(data).expect("Failed to serialize ClientHelloData"); dst.put_slice(&serialized); } + LpMessage::KKTRequest(payload) => { + dst.put_slice(&payload.0); + } + LpMessage::KKTResponse(payload) => { + dst.put_slice(&payload.0); + } } } } @@ -170,8 +194,8 @@ impl LpMessage { #[cfg(test)] mod tests { use super::*; - use crate::packet::{LpHeader, TRAILER_LEN}; use crate::LpPacket; + use crate::packet::{LpHeader, TRAILER_LEN}; #[test] fn encoding() { @@ -208,8 +232,9 @@ mod tests { #[test] fn test_client_hello_salt_generation() { let client_key = [1u8; 32]; - let hello1 = ClientHelloData::new_with_fresh_salt(client_key); - let hello2 = ClientHelloData::new_with_fresh_salt(client_key); + let client_ed25519_key = [2u8; 32]; + let hello1 = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); + let hello2 = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); // Different salts should be generated assert_ne!(hello1.salt, hello2.salt); @@ -223,7 +248,8 @@ mod tests { #[test] fn test_client_hello_timestamp_extraction() { let client_key = [2u8; 32]; - let hello = ClientHelloData::new_with_fresh_salt(client_key); + let client_ed25519_key = [3u8; 32]; + let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); let timestamp = hello.extract_timestamp(); let now = std::time::SystemTime::now() @@ -238,7 +264,8 @@ mod tests { #[test] fn test_client_hello_salt_format() { let client_key = [3u8; 32]; - let hello = ClientHelloData::new_with_fresh_salt(client_key); + let client_ed25519_key = [4u8; 32]; + let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); // First 8 bytes should be non-zero timestamp let timestamp_bytes = &hello.salt[..8]; diff --git a/common/nym-lp/src/noise_protocol.rs b/common/nym-lp/src/noise_protocol.rs index 06ec8b1346..42b5e0308f 100644 --- a/common/nym-lp/src/noise_protocol.rs +++ b/common/nym-lp/src/noise_protocol.rs @@ -3,7 +3,7 @@ //! Sans-IO Noise protocol state machine, adapted from noise-psq. -use snow::{params::NoiseParams, TransportState}; +use snow::{TransportState, params::NoiseParams}; use thiserror::Error; // --- Error Definition --- @@ -20,6 +20,9 @@ pub enum NoiseError { #[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), } @@ -255,6 +258,32 @@ impl NoiseProtocol { 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( diff --git a/common/nym-lp/src/packet.rs b/common/nym-lp/src/packet.rs index 98570154b1..193fa0704d 100644 --- a/common/nym-lp/src/packet.rs +++ b/common/nym-lp/src/packet.rs @@ -1,9 +1,9 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::LpError; use crate::message::LpMessage; use crate::replay::ReceivingKeyCounterValidator; -use crate::LpError; use bytes::{BufMut, BytesMut}; use nym_lp_common::format_debug_bytes; use parking_lot::Mutex; diff --git a/common/nym-lp/src/psk.rs b/common/nym-lp/src/psk.rs index 676eb0e80a..f4f416b3a7 100644 --- a/common/nym-lp/src/psk.rs +++ b/common/nym-lp/src/psk.rs @@ -4,57 +4,403 @@ //! PSK (Pre-Shared Key) derivation for LP sessions using Blake3 KDF. //! //! This module implements identity-bound PSK derivation where both client and gateway -//! derive the same PSK from their LP keypairs using ECDH + Blake3 KDF. +//! derive the same PSK from their LP keypairs. +//! +//! Two approaches are supported: +//! - **Legacy ECDH-only** (`derive_psk`) - Simple but no post-quantum security +//! - **PSQ-enhanced** (`derive_psk_with_psq_*`) - Combines ECDH with post-quantum KEM +//! +//! ## Error Handling Strategy +//! +//! **PSQ failures always abort the handshake cleanly with no retry or fallback.** +//! +//! ### Rationale +//! +//! PSQ errors indicate: +//! - **Authentication failures** (CredError) - Potential attack or misconfiguration +//! - **Timing failures** (TimestampElapsed) - Replay attacks or clock skew +//! - **Crypto failures** (CryptoError) - Library bugs or hardware faults +//! - **Serialization failures** (Serialization) - Protocol violations or corruption +//! +//! None of these are transient errors that benefit from retry. Falling back to +//! ECDH-only PSK would silently degrade post-quantum security. +//! +//! ### Error Recovery Behavior +//! +//! On any PSQ error: +//! 1. Function returns `Err(LpError)` immediately +//! 2. Session state remains unchanged (dummy PSK, clean Noise state) +//! 3. Handshake aborts - caller must start fresh connection +//! 4. Error is logged with diagnostic context +//! +//! ### State Guarantees on Error +//! +//! - **`psq_state`**: Remains in `NotStarted` (initiator) or `ResponderWaiting` (responder) +//! - **Noise `HandshakeState`**: PSK slot 3 = dummy `[0u8; 32]` (not modified on error) +//! - **No partial data**: All allocations are stack-local to failed function +//! - **No cleanup needed**: No state was mutated +use crate::LpError; use crate::keypair::{PrivateKey, PublicKey}; +use libcrux_psq::v1::cred::{Authenticator, Ed25519}; +use libcrux_psq::v1::impls::X25519 as PsqX25519; +use libcrux_psq::v1::psk_registration::{Initiator, InitiatorMsg, Responder}; +use libcrux_psq::v1::traits::{Ciphertext as PsqCiphertext, PSQ}; +use nym_crypto::asymmetric::ed25519; +use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey}; +use std::time::Duration; +use tls_codec::{Deserialize as TlsDeserializeTrait, Serialize as TlsSerializeTrait}; -/// Context string for Blake3 KDF domain separation. -const PSK_CONTEXT: &str = "nym-lp-psk-v1"; +/// Context string for Blake3 KDF domain separation (PSQ-enhanced). +const PSK_PSQ_CONTEXT: &str = "nym-lp-psk-psq-v1"; -/// Derives a PSK using Blake3 KDF from local private key, remote public key, and salt. +/// Session context for PSQ protocol. +const PSQ_SESSION_CONTEXT: &[u8] = b"nym-lp-psq-session"; + +/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Initiator side. +/// +/// This function combines classical ECDH with post-quantum KEM to provide forward secrecy +/// and HNDL (Harvest-Now, Decrypt-Later) resistance. /// /// # Formula /// ```text -/// shared_secret = ECDH(local_private, remote_public) -/// psk = Blake3_derive_key(context="nym-lp-psk-v1", input=shared_secret || salt) +/// 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 +/// ) /// ``` /// -/// # Properties -/// - **Identity-bound**: PSK is tied to the LP keypairs of both parties -/// - **Session-specific**: Different salts produce different PSKs -/// - **Symmetric**: Both sides derive the same PSK from their respective keys -/// /// # Arguments -/// * `local_private` - This side's LP private key -/// * `remote_public` - Peer's LP public key -/// * `salt` - 32-byte salt (timestamp + nonce from ClientHello) +/// * `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 -/// 32-byte PSK suitable for Noise protocol +/// * `Ok((psk, ciphertext))` - PSK and ciphertext to send to responder +/// * `Err(LpError)` - If PSQ encapsulation fails /// /// # Example /// ```ignore -/// // Client side -/// let client_private = client_keypair.private_key(); -/// let gateway_public = gateway_keypair.public_key(); -/// let salt = ClientHelloData::new_with_fresh_salt(...).salt; -/// let psk = derive_psk(&client_private, &gateway_public, &salt); -/// -/// // Gateway side (derives same PSK) -/// let gateway_private = gateway_keypair.private_key(); -/// let client_public = /* from ClientHello */; -/// let psk = derive_psk(&gateway_private, &client_public, &salt); +/// // 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( - local_private: &PrivateKey, - remote_public: &PublicKey, +pub fn derive_psk_with_psq_initiator( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + remote_kem_public: &EncapsulationKey, salt: &[u8; 32], -) -> [u8; 32] { - // Perform ECDH to get shared secret - let shared_secret = local_private.diffie_hellman(remote_public); +) -> Result<([u8; 32], Vec), LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); - // Derive PSK using Blake3 KDF with domain separation - nym_crypto::kdf::derive_key_blake3(PSK_CONTEXT, shared_secret.as_bytes(), salt) + // Step 2: PSQ encapsulation for post-quantum security + // Extract X25519 public key from EncapsulationKey + let kem_pk = match remote_kem_public { + EncapsulationKey::X25519(pk) => pk, + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + let mut rng = rand09::rng(); + let (psq_psk, ciphertext) = + PsqX25519::encapsulate_psq(kem_pk, PSQ_SESSION_CONTEXT, &mut rng) + .map_err(|e| LpError::Internal(format!("PSQ encapsulation failed: {:?}", e)))?; + + // Step 3: Combine ECDH + PSQ via Blake3 KDF + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need & + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + // Serialize ciphertext using TLS encoding for transport + let ct_bytes = ciphertext + .tls_serialize_detached() + .map_err(|e| LpError::Internal(format!("Ciphertext serialization failed: {:?}", e)))?; + + Ok((final_psk, ct_bytes)) +} + +/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Responder side. +/// +/// This function decapsulates the ciphertext from the initiator and combines it with +/// ECDH to derive the same PSK. +/// +/// # Formula +/// ```text +/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public) +/// psq_psk = PSQ_Decapsulate(local_kem_keypair, ciphertext, session_context) +/// psk = Blake3_derive_key( +/// context="nym-lp-psk-psq-v1", +/// input=ecdh_secret || psq_psk || salt +/// ) +/// ``` +/// +/// # Arguments +/// * `local_x25519_private` - Responder's X25519 private key (for Noise) +/// * `remote_x25519_public` - Initiator's X25519 public key (for Noise) +/// * `local_kem_keypair` - Responder's KEM keypair (decapsulation key, public key) +/// * `ciphertext` - PSQ ciphertext from initiator +/// * `salt` - 32-byte salt for session binding +/// +/// # Returns +/// * `Ok(psk)` - Derived PSK +/// * `Err(LpError)` - If PSQ decapsulation fails +/// +/// # Example +/// ```ignore +/// // Gateway side (after receiving ciphertext) +/// let psk = derive_psk_with_psq_responder( +/// gateway_x25519_private, +/// client_x25519_public, +/// (&gateway_kem_sk, &gateway_kem_pk), +/// &ciphertext, // from client +/// &salt +/// )?; +/// ``` +pub fn derive_psk_with_psq_responder( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + local_kem_keypair: (&DecapsulationKey, &EncapsulationKey), + ciphertext: &[u8], + salt: &[u8; 32], +) -> Result<[u8; 32], LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey + let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) { + (DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk), + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + // Step 3: Deserialize ciphertext using TLS decoding + let ct = PsqCiphertext::::tls_deserialize(&mut &ciphertext[..]) + .map_err(|e| LpError::Internal(format!("Ciphertext deserialization failed: {:?}", e)))?; + + // Step 4: PSQ decapsulation for post-quantum security + let psq_psk = PsqX25519::decapsulate_psq(kem_sk, kem_pk, &ct, PSQ_SESSION_CONTEXT) + .map_err(|e| LpError::Internal(format!("PSQ decapsulation failed: {:?}", e)))?; + + // Step 5: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator) + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need & + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + Ok(final_psk) +} + +/// PSQ protocol wrapper for initiator (client) side. +/// +/// Creates a PSQ initiator message with Ed25519 authentication, following the protocol: +/// 1. Encapsulate PSK using responder's KEM key +/// 2. Derive PSK and AEAD keys from K_pq +/// 3. Sign the encapsulation with Ed25519 +/// 4. AEAD encrypt (timestamp || signature || public_key) +/// +/// Returns (PSK, serialized_payload) where payload includes enc_pq and encrypted auth data. +/// +/// # Arguments +/// * `local_x25519_private` - Client's X25519 private key (for hybrid ECDH) +/// * `remote_x25519_public` - Gateway's X25519 public key (for hybrid ECDH) +/// * `remote_kem_public` - Gateway's PQ KEM public key (from KKT) +/// * `client_ed25519_sk` - Client's Ed25519 signing key +/// * `client_ed25519_pk` - Client's Ed25519 public key (credential) +/// * `salt` - Session salt +/// * `session_context` - Context bytes for PSQ (e.g., b"nym-lp-psq-session") +/// +/// # Returns +/// `(psk, psq_payload_bytes)` - PSK for Noise and serialized PSQ payload to embed +pub fn psq_initiator_create_message( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + remote_kem_public: &EncapsulationKey, + client_ed25519_sk: &ed25519::PrivateKey, + client_ed25519_pk: &ed25519::PublicKey, + salt: &[u8; 32], + session_context: &[u8], +) -> Result<([u8; 32], Vec), LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: PSQ v1 with Ed25519 authentication + // Extract X25519 KEM key from EncapsulationKey + let kem_pk = match remote_kem_public { + EncapsulationKey::X25519(pk) => pk, + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + // Convert nym Ed25519 keys to libcrux format + type Ed25519VerificationKey = ::VerificationKey; + let ed25519_sk_bytes = client_ed25519_sk.to_bytes(); + let ed25519_pk_bytes = client_ed25519_pk.to_bytes(); + let ed25519_verification_key = Ed25519VerificationKey::from_bytes(ed25519_pk_bytes); + + // Use PSQ v1 API with Ed25519 authentication + let mut rng = rand09::rng(); + let (state, initiator_msg) = Initiator::send_initial_message::( + session_context, + Duration::from_secs(3600), // 1 hour expiry + kem_pk, + &ed25519_sk_bytes, + &ed25519_verification_key, + &mut rng, + ) + .map_err(|e| { + tracing::error!( + "PSQ initiator failed - KEM encapsulation or signing error: {:?}", + e + ); + LpError::Internal(format!("PSQ v1 send_initial_message failed: {:?}", e)) + })?; + + // Extract PSQ shared secret (unregistered PSK) + let psq_psk = state.unregistered_psk(); + + // Step 3: Combine ECDH + PSQ via Blake3 KDF + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(psq_psk); // psq_psk is already a &[u8; 32] + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + // Serialize InitiatorMsg with TLS encoding for transport + let msg_bytes = initiator_msg + .tls_serialize_detached() + .map_err(|e| LpError::Internal(format!("InitiatorMsg serialization failed: {:?}", e)))?; + + Ok((final_psk, msg_bytes)) +} + +/// PSQ protocol wrapper for responder (gateway) side. +/// +/// Processes a PSQ initiator message, verifies authentication, and derives PSK. +/// Follows the protocol: +/// 1. Decapsulate to get K_pq +/// 2. Derive AEAD keys and verify encrypted auth data +/// 3. Verify Ed25519 signature +/// 4. Check timestamp validity +/// 5. Derive PSK +/// +/// # Arguments +/// * `local_x25519_private` - Gateway's X25519 private key (for hybrid ECDH) +/// * `remote_x25519_public` - Client's X25519 public key (for hybrid ECDH) +/// * `local_kem_keypair` - Gateway's PQ KEM keypair +/// * `initiator_ed25519_pk` - Client's Ed25519 public key (for signature verification) +/// * `psq_payload` - Serialized PSQ payload from initiator +/// * `salt` - Session salt (must match initiator's) +/// * `session_context` - Context bytes for PSQ +/// +/// # Returns +/// `psk` - Derived PSK for Noise +/// Processes a PSQ initiator message and generates a PSK with encrypted handle. +/// +/// Returns a tuple of (derived_psk, responder_msg_bytes) where responder_msg_bytes +/// contains the encrypted PSK handle (ctxt_B) that should be sent to the initiator. +pub fn psq_responder_process_message( + local_x25519_private: &PrivateKey, + remote_x25519_public: &PublicKey, + local_kem_keypair: (&DecapsulationKey, &EncapsulationKey), + initiator_ed25519_pk: &ed25519::PublicKey, + psq_payload: &[u8], + salt: &[u8; 32], + session_context: &[u8], +) -> Result<([u8; 32], Vec), LpError> { + // Step 1: Classical ECDH for baseline security + let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); + + // Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey + let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) { + (DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk), + _ => { + return Err(LpError::KKTError( + "Only X25519 KEM is currently supported for PSQ".to_string(), + )); + } + }; + + // Step 3: Deserialize InitiatorMsg using TLS decoding + let initiator_msg = InitiatorMsg::::tls_deserialize(&mut &psq_payload[..]) + .map_err(|e| LpError::Internal(format!("InitiatorMsg deserialization failed: {:?}", e)))?; + + // Step 4: Convert nym Ed25519 public key to libcrux VerificationKey format + type Ed25519VerificationKey = ::VerificationKey; + let initiator_ed25519_pk_bytes = initiator_ed25519_pk.to_bytes(); + let initiator_verification_key = Ed25519VerificationKey::from_bytes(initiator_ed25519_pk_bytes); + + // Step 5: PSQ v1 responder processing with Ed25519 verification + let (registered_psk, responder_msg) = Responder::send::( + b"nym-lp-handle", // PSK storage handle + Duration::from_secs(3600), // 1 hour expiry (must match initiator) + session_context, // Must match initiator's session_context + kem_pk, // Responder's public key + kem_sk, // Responder's secret key + &initiator_verification_key, // Initiator's Ed25519 public key for verification + &initiator_msg, // InitiatorMsg to verify and process + ) + .map_err(|e| { + use libcrux_psq::v1::Error as PsqError; + match e { + PsqError::CredError => { + tracing::warn!( + "PSQ responder auth failure - invalid Ed25519 signature (potential attack)" + ); + } + PsqError::TimestampElapsed | PsqError::RegistrationError => { + tracing::warn!( + "PSQ responder timing failure - TTL expired (potential replay attack)" + ); + } + _ => { + tracing::error!("PSQ responder failed - {:?}", e); + } + } + LpError::Internal(format!("PSQ v1 responder send failed: {:?}", e)) + })?; + + // Extract the PSQ PSK from the registered PSK + let psq_psk = registered_psk.psk; + + // Step 6: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator) + let mut combined = Vec::with_capacity(64 + psq_psk.len()); + combined.extend_from_slice(ecdh_secret.as_bytes()); + combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need & + combined.extend_from_slice(salt); + + let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]); + + // Step 7: Serialize ResponderMsg (contains ctxt_B - encrypted PSK handle) + use tls_codec::Serialize; + let responder_msg_bytes = responder_msg + .tls_serialize_detached() + .map_err(|e| LpError::Internal(format!("ResponderMsg serialization failed: {:?}", e)))?; + + Ok((final_psk, responder_msg_bytes)) } #[cfg(test)] @@ -62,30 +408,35 @@ mod tests { use super::*; use crate::keypair::Keypair; - #[test] - fn test_psk_derivation_is_deterministic() { - let keypair_1 = Keypair::default(); - let keypair_2 = Keypair::default(); - let salt = [1u8; 32]; - - // Derive PSK twice with same inputs - let psk1 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt); - let psk2 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt); - - assert_eq!(psk1, psk2, "Same inputs should produce same PSK"); - } - #[test] fn test_psk_derivation_is_symmetric() { let keypair_1 = Keypair::default(); let keypair_2 = Keypair::default(); let salt = [2u8; 32]; + 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 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt); + 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(keypair_2.private_key(), keypair_1.public_key(), &salt); + 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, @@ -100,9 +451,24 @@ mod tests { 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(keypair_1.private_key(), keypair_2.public_key(), &salt1); - let psk2 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt2); + 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"); } @@ -114,8 +480,24 @@ mod tests { let keypair_3 = Keypair::default(); let salt = [3u8; 32]; - let psk1 = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt); - let psk2 = derive_psk(keypair_1.private_key(), keypair_3.public_key(), &salt); + 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, @@ -123,14 +505,198 @@ mod tests { ); } + // PSQ-enhanced PSK tests + use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey, KEM}; + use nym_kkt::key_utils::generate_keypair_libcrux; + #[test] - fn test_psk_output_length() { - let keypair_1 = Keypair::default(); - let keypair_2 = Keypair::default(); + fn test_psq_derivation_deterministic() { + let mut rng = rand09::rng(); + + // Generate X25519 keypairs for Noise + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + // Generate KEM keypair for PSQ + let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + let dec_key = DecapsulationKey::X25519(kem_sk); + + let salt = [1u8; 32]; + + // Derive PSK twice with same inputs (initiator side) + let (_psk1, ct1) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + let (_psk2, _ct2) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + // PSKs will be different due to randomness in PSQ, but ciphertexts too + // This test verifies the function is deterministic given the SAME ciphertext + let psk_responder1 = derive_psk_with_psq_responder( + gateway_keypair.private_key(), + client_keypair.public_key(), + (&dec_key, &enc_key), + &ct1, + &salt, + ) + .unwrap(); + + let psk_responder2 = derive_psk_with_psq_responder( + gateway_keypair.private_key(), + client_keypair.public_key(), + (&dec_key, &enc_key), + &ct1, // Same ciphertext + &salt, + ) + .unwrap(); + + assert_eq!( + psk_responder1, psk_responder2, + "Same ciphertext should produce same PSK" + ); + } + + #[test] + fn test_psq_derivation_symmetric() { + let mut rng = rand09::rng(); + + // Generate X25519 keypairs for Noise + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + // Generate KEM keypair for PSQ + let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + let dec_key = DecapsulationKey::X25519(kem_sk); + + let salt = [2u8; 32]; + + // Client derives PSK (initiator) + let (client_psk, ciphertext) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); + + // Gateway derives PSK from ciphertext (responder) + let gateway_psk = derive_psk_with_psq_responder( + gateway_keypair.private_key(), + client_keypair.public_key(), + (&dec_key, &enc_key), + &ciphertext, + &salt, + ) + .unwrap(); + + assert_eq!( + client_psk, gateway_psk, + "Both sides should derive identical PSK via PSQ" + ); + } + + #[test] + fn test_different_kem_keys_different_psk() { + let mut rng = rand09::rng(); + + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + // Two different KEM keypairs + let (_, kem_pk1) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let (_, kem_pk2) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + + let enc_key1 = EncapsulationKey::X25519(kem_pk1); + let enc_key2 = EncapsulationKey::X25519(kem_pk2); + + let salt = [3u8; 32]; + + let (psk1, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key1, + &salt, + ) + .unwrap(); + + let (psk2, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key2, + &salt, + ) + .unwrap(); + + assert_ne!( + psk1, psk2, + "Different KEM keys should produce different PSKs" + ); + } + + #[test] + fn test_psq_psk_output_length() { + let mut rng = rand09::rng(); + + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + let salt = [4u8; 32]; - let psk = derive_psk(keypair_1.private_key(), keypair_2.public_key(), &salt); + let (psk, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt, + ) + .unwrap(); - assert_eq!(psk.len(), 32, "PSK should be exactly 32 bytes"); + assert_eq!(psk.len(), 32, "PSQ PSK should be exactly 32 bytes"); + } + + #[test] + fn test_psq_different_salts_different_psks() { + let mut rng = rand09::rng(); + + let client_keypair = Keypair::default(); + let gateway_keypair = Keypair::default(); + + let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap(); + let enc_key = EncapsulationKey::X25519(kem_pk); + + let salt1 = [1u8; 32]; + let salt2 = [2u8; 32]; + + let (psk1, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt1, + ) + .unwrap(); + + let (psk2, _) = derive_psk_with_psq_initiator( + client_keypair.private_key(), + gateway_keypair.public_key(), + &enc_key, + &salt2, + ) + .unwrap(); + + assert_ne!(psk1, psk2, "Different salts should produce different PSKs"); } } diff --git a/common/nym-lp/src/replay/error.rs b/common/nym-lp/src/replay/error.rs index 2a5affb986..6422eb8613 100644 --- a/common/nym-lp/src/replay/error.rs +++ b/common/nym-lp/src/replay/error.rs @@ -56,13 +56,9 @@ mod tests { #[test] fn test_replay_result() { let ok_result: ReplayResult<()> = Ok(()); - let err_result: ReplayResult<()> = Err(ReplayError::InvalidCounter); + let err = ReplayError::InvalidCounter; assert!(ok_result.is_ok()); - assert!(err_result.is_err()); - assert!(matches!( - err_result.unwrap_err(), - ReplayError::InvalidCounter - )); + assert!(matches!(err, ReplayError::InvalidCounter)); } } diff --git a/common/nym-lp/src/replay/simd/arm.rs b/common/nym-lp/src/replay/simd/arm.rs index 3d881396ab..cdf0302d6c 100644 --- a/common/nym-lp/src/replay/simd/arm.rs +++ b/common/nym-lp/src/replay/simd/arm.rs @@ -210,17 +210,20 @@ pub mod atomic { if first_full_word <= last_full_word { // Use NEON to set words faster // Safety: vdupq_n_u64 is safe to call with any u64 value - let ones_vec = vdupq_n_u64(u64::MAX); + let 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 - 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); + 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; } diff --git a/common/nym-lp/src/replay/validator.rs b/common/nym-lp/src/replay/validator.rs index e3e1a8fe79..1d842b4913 100644 --- a/common/nym-lp/src/replay/validator.rs +++ b/common/nym-lp/src/replay/validator.rs @@ -467,9 +467,11 @@ mod tests { 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 + 72 + 125) + .is_ok() + ); assert!(validator.mark_did_receive_branchless(1000 + 63).is_ok()); // Check duplicates @@ -757,8 +759,8 @@ mod tests { ); // Verify minimum memory needed for different window sizes - for window_size in [64, 128, 256, 512, 1024, 2048] { - let words_needed = (window_size + WORD_SIZE - 1) / WORD_SIZE; // Ceiling division + for window_size in [64usize, 128, 256, 512, 1024, 2048] { + let words_needed = window_size.div_ceil(WORD_SIZE); let memory_needed = size_of::() * 2 + size_of::() * words_needed; println!( "Window size {}: {} bytes minimum", @@ -847,10 +849,11 @@ mod tests { #[test] fn test_clear_window_overflow() { - let mut validator = ReceivingKeyCounterValidator::default(); - // Set a very large next value, close to u64::MAX - validator.next = u64::MAX - 1000; + 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 diff --git a/common/nym-lp/src/session.rs b/common/nym-lp/src/session.rs index 3dddfdce88..e58a2200f1 100644 --- a/common/nym-lp/src/session.rs +++ b/common/nym-lp/src/session.rs @@ -6,19 +6,118 @@ //! This module implements session management functionality, including replay protection //! and Noise protocol state handling. +use crate::keypair::{PrivateKey, PublicKey}; use crate::message::{EncryptedDataPayload, HandshakeData}; use crate::noise_protocol::{NoiseError, NoiseProtocol, ReadResult}; use crate::packet::LpHeader; +use crate::psk::{psq_initiator_create_message, psq_responder_process_message}; use crate::replay::ReceivingKeyCounterValidator; use crate::{LpError, LpMessage, LpPacket}; +use nym_crypto::asymmetric::ed25519; +use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey}; use parking_lot::Mutex; use snow::Builder; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +/// KKT (KEM Key Transfer) exchange state. +/// +/// Tracks the KKT protocol for obtaining the responder's KEM public key +/// before PSQ can begin. This allows post-quantum KEM algorithms to be +/// used even when keys are not pre-published. +/// +/// # State Transitions +/// +/// **Initiator path:** +/// ```text +/// NotStarted → InitiatorWaiting → Completed +/// ``` +/// +/// **Responder path:** +/// ```text +/// NotStarted → ResponderProcessed +/// ``` +pub enum KKTState { + /// KKT exchange not started. + NotStarted, + + /// Initiator sent KKT request and is waiting for responder's KEM key. + InitiatorWaiting { + /// KKT context for verifying the response + context: nym_kkt::context::KKTContext, + }, + + /// KKT exchange completed (initiator received and validated KEM key). + Completed { + /// Responder's KEM public key for PSQ encapsulation + kem_pk: Box>, + }, + + /// Responder processed a KKT request and sent response. + /// Responder uses their own KEM keypair, not the one from KKT. + ResponderProcessed, +} + +impl std::fmt::Debug for KKTState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotStarted => write!(f, "KKTState::NotStarted"), + Self::InitiatorWaiting { context } => f + .debug_struct("KKTState::InitiatorWaiting") + .field("context", context) + .finish(), + Self::Completed { .. } => write!(f, "KKTState::Completed {{ kem_pk: }}"), + Self::ResponderProcessed => write!(f, "KKTState::ResponderProcessed"), + } + } +} + +/// PSQ (Post-Quantum Secure PSK) handshake state. +/// +/// Tracks the PSQ protocol state machine through the session lifecycle. +/// +/// # State Transitions +/// +/// **Initiator path:** +/// ```text +/// NotStarted → InitiatorWaiting → Completed +/// ``` +/// +/// **Responder path:** +/// ```text +/// NotStarted → ResponderWaiting → Completed +/// ``` +#[derive(Debug)] +pub enum PSQState { + /// PSQ handshake not yet started. + NotStarted, + + /// Initiator has sent PSQ ciphertext and is waiting for confirmation. + /// Stores the ciphertext that was sent. + InitiatorWaiting { ciphertext: Vec }, + + /// Responder is ready to receive and decapsulate PSQ ciphertext. + ResponderWaiting, + + /// PSQ handshake completed successfully. + /// The PSK has been derived and registered with the Noise protocol. + Completed { + /// The derived post-quantum PSK + psk: [u8; 32], + }, +} /// A session in the Lewes Protocol, handling connection state with Noise. /// /// Sessions manage connection state, including LP replay protection and Noise cryptography. /// Each session has a unique receiving index and sending index for connection identification. +/// +/// ## PSK Injection Lifecycle +/// +/// 1. Session created with dummy PSK `[0u8; 32]` in Noise HandshakeState +/// 2. During handshake, PSQ runs and derives real post-quantum PSK +/// 3. Real PSK injected via `set_psk()` - `psk_injected` flag set to `true` +/// 4. Handshake completes, transport mode available +/// 5. Transport operations (`encrypt_data`/`decrypt_data`) check `psk_injected` flag for safety #[derive(Debug)] pub struct LpSession { id: u32, @@ -29,11 +128,71 @@ pub struct LpSession { /// Noise protocol state machine noise_state: Mutex, + /// KKT (KEM Key Transfer) exchange state + kkt_state: Mutex, + + /// PSQ (Post-Quantum Secure PSK) handshake state + psq_state: Mutex, + + /// PSK handle from responder (ctxt_B) for future re-registration + psk_handle: Mutex>>, + /// Counter for outgoing packets sending_counter: AtomicU64, /// Validator for incoming packet counters to prevent replay attacks receiving_counter: Mutex, + + /// Safety flag: `true` if real PSK was injected via PSQ, `false` if still using dummy PSK. + /// This prevents transport mode operations from running with the insecure dummy `[0u8; 32]` PSK. + psk_injected: AtomicBool, + + // PSQ-related keys stored for handshake + /// Local Ed25519 private key for PSQ authentication + local_ed25519_private: ed25519::PrivateKey, + + /// Local Ed25519 public key for PSQ authentication + local_ed25519_public: ed25519::PublicKey, + + /// Remote Ed25519 public key for PSQ authentication + remote_ed25519_public: ed25519::PublicKey, + + /// Local X25519 private key (Noise static key) + local_x25519_private: PrivateKey, + + /// Remote X25519 public key (Noise static key) + remote_x25519_public: PublicKey, + + /// Salt for PSK derivation + salt: [u8; 32], +} + +/// Generates a fresh salt for PSK derivation. +/// +/// Salt format: 8 bytes timestamp (u64 LE) + 24 bytes random nonce +/// +/// This ensures each session derives a unique PSK, even with the same key pairs. +/// The timestamp provides temporal uniqueness while the random nonce prevents collisions. +/// +/// # Returns +/// A 32-byte array containing fresh salt material +pub fn generate_fresh_salt() -> [u8; 32] { + use rand::RngCore; + use std::time::{SystemTime, UNIX_EPOCH}; + + let mut salt = [0u8; 32]; + + // First 8 bytes: current timestamp as u64 little-endian + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time before UNIX epoch") + .as_secs(); + salt[..8].copy_from_slice(×tamp.to_le_bytes()); + + // Last 24 bytes: random nonce + rand::thread_rng().fill_bytes(&mut salt[8..]); + + salt } impl LpSession { @@ -50,24 +209,36 @@ impl LpSession { self.is_initiator } + /// Returns the local X25519 public key derived from the private key. + /// + /// This is used for KKT protocol when the responder needs to send their + /// KEM public key in the KKT response. + pub fn local_x25519_public(&self) -> PublicKey { + self.local_x25519_private.public_key() + } + /// Creates a new session and initializes the Noise protocol state. /// + /// PSQ always runs during the handshake to derive the real PSK from X25519 DHKEM. + /// The Noise protocol is initialized with a dummy PSK that gets replaced during handshake. + /// /// # Arguments /// - /// * `receiving_index` - Index used for receiving packets (becomes session ID). - /// * `sending_index` - Index used for sending packets to the peer. + /// * `id` - Session identifier /// * `is_initiator` - True if this side initiates the Noise handshake. - /// * `local_static_key` - This side's static private key (e.g., X25519). - /// * `remote_static_key` - The peer's static public key (required for initiator in some patterns like XK). - /// * `psk` - The pre-shared key established out-of-band. - /// * `pattern_name` - The Noise protocol pattern string (e.g., "Noise_XKpsk3_25519_ChaChaPoly_SHA256"). - /// * `psk_index` - The index/position where the PSK is mixed in according to the pattern. + /// * `local_ed25519_keypair` - This side's Ed25519 keypair for PSQ authentication + /// * `local_x25519_key` - This side's X25519 private key for Noise protocol and DHKEM + /// * `remote_ed25519_key` - Peer's Ed25519 public key for PSQ authentication + /// * `remote_x25519_key` - Peer's X25519 public key for Noise protocol and DHKEM + /// * `salt` - Salt for PSK derivation pub fn new( id: u32, is_initiator: bool, - local_private_key: &[u8], - remote_public_key: &[u8], - psk: &[u8], + local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), + local_x25519_key: &PrivateKey, + remote_ed25519_key: &ed25519::PublicKey, + remote_x25519_key: &PublicKey, + salt: &[u8; 32], ) -> Result { // XKpsk3 pattern requires remote static key known upfront (XK) // and PSK mixed at position 3. This provides forward secrecy with PSK authentication. @@ -77,11 +248,16 @@ impl LpSession { let params = pattern_name.parse()?; let builder = Builder::new(params); - let builder = builder.local_private_key(local_private_key); + let local_key_bytes = local_x25519_key.to_bytes(); + let builder = builder.local_private_key(&local_key_bytes); - let builder = builder.remote_public_key(remote_public_key); + let remote_key_bytes = remote_x25519_key.to_bytes(); + let builder = builder.remote_public_key(&remote_key_bytes); - let builder = builder.psk(psk_index, psk); + // Initialize with dummy PSK - real PSK will be injected via set_psk() during handshake + // when PSQ runs using X25519 as DHKEM + let dummy_psk = [0u8; 32]; + let builder = builder.psk(psk_index, &dummy_psk); let initial_state = if is_initiator { builder.build_initiator().map_err(LpError::SnowKeyError)? @@ -91,12 +267,40 @@ impl LpSession { let noise_protocol = NoiseProtocol::new(initial_state); + // Initialize KKT state - both roles start at NotStarted + let kkt_state = KKTState::NotStarted; + + // Initialize PSQ state based on role + let psq_state = if is_initiator { + PSQState::NotStarted + } else { + PSQState::ResponderWaiting + }; + Ok(Self { id, is_initiator, noise_state: Mutex::new(noise_protocol), + kkt_state: Mutex::new(kkt_state), + psq_state: Mutex::new(psq_state), + psk_handle: Mutex::new(None), sending_counter: AtomicU64::new(0), receiving_counter: Mutex::new(ReceivingKeyCounterValidator::default()), + psk_injected: AtomicBool::new(false), + // Ed25519 keys don't impl Clone, so convert to bytes and reconstruct + local_ed25519_private: ed25519::PrivateKey::from_bytes( + &local_ed25519_keypair.0.to_bytes(), + ) + .expect("Valid ed25519 private key"), + local_ed25519_public: ed25519::PublicKey::from_bytes( + &local_ed25519_keypair.1.to_bytes(), + ) + .expect("Valid ed25519 public key"), + remote_ed25519_public: ed25519::PublicKey::from_bytes(&remote_ed25519_key.to_bytes()) + .expect("Valid ed25519 public key"), + local_x25519_private: local_x25519_key.clone(), + remote_x25519_public: remote_x25519_key.clone(), + salt: *salt, }) } @@ -166,20 +370,326 @@ impl LpSession { counter_validator.current_packet_cnt() } + /// Returns the stored PSK handle (ctxt_B) if available. + /// + /// The PSK handle is received from the responder during handshake and can be + /// used for future PSK re-registration without running KEM encapsulation again. + /// + /// # Returns + /// + /// * `Some(Vec)` - The encrypted PSK handle from the responder + /// * `None` - PSK handle not yet received or session is initiator before handshake completion + pub fn get_psk_handle(&self) -> Option> { + self.psk_handle.lock().clone() + } + + /// Prepares a KKT (KEM Key Transfer) request message. + /// + /// This should be called by the initiator before starting the Noise handshake + /// to obtain the responder's KEM public key. The KKT protocol authenticates + /// the exchange using Ed25519 signatures. + /// + /// **Protocol Flow:** + /// 1. Initiator creates KKT request with Ed25519 signature + /// 2. Responder validates signature and responds with KEM public key + signature + /// 3. Initiator validates response and stores KEM key for PSQ + /// + /// # Returns + /// + /// * `Some(Ok(LpMessage::KKTRequest))` - KKT request ready to send + /// * `Some(Err(LpError))` - Error creating KKT request + /// * `None` - KKT not applicable (responder, or already completed) + pub fn prepare_kkt_request(&self) -> Option> { + use nym_kkt::{ + ciphersuite::{Ciphersuite, HashFunction, KEM, SignatureScheme}, + kkt::request_kem_key, + }; + + let mut kkt_state = self.kkt_state.lock(); + + // Only initiator creates KKT requests, and only when not started + if !self.is_initiator || !matches!(*kkt_state, KKTState::NotStarted) { + return None; + } + + // Use X25519 as KEM for now (can extend to ML-KEM-768 later) + let ciphersuite = match Ciphersuite::resolve_ciphersuite( + KEM::X25519, + HashFunction::Blake3, + SignatureScheme::Ed25519, + None, + ) { + Ok(cs) => cs, + Err(e) => { + return Some(Err(LpError::Internal(format!( + "KKT ciphersuite error: {:?}", + e + )))); + } + }; + + let mut rng = rand09::rng(); + match request_kem_key(&mut rng, ciphersuite, &self.local_ed25519_private) { + Ok((context, request_frame)) => { + // Store context for response validation + *kkt_state = KKTState::InitiatorWaiting { context }; + + // Serialize KKT frame to bytes + let request_bytes = request_frame.to_bytes(); + Some(Ok(LpMessage::KKTRequest(crate::message::KKTRequestData( + request_bytes, + )))) + } + Err(e) => Some(Err(LpError::Internal(format!( + "KKT request creation failed: {:?}", + e + )))), + } + } + + /// Processes a KKT response from the responder. + /// + /// Validates the responder's signature and stores the authenticated KEM public key + /// for use in PSQ encapsulation. + /// + /// # Arguments + /// + /// * `response_bytes` - Raw KKT response message from responder + /// * `expected_key_hash` - Optional expected hash of responder's KEM key. + /// - `Some(hash)`: Full KKT validation (signature + hash) - use when directory service available + /// - `None`: Signature-only validation (hash computed from received key) - temporary mode + /// + /// # Returns + /// + /// * `Ok(())` - KKT exchange completed, KEM key stored + /// * `Err(LpError)` - Signature verification failed, hash mismatch, or invalid state + /// + /// # Note + /// + /// When None is passed, the function computes the hash from the received key and validates against + /// that (effectively signature-only mode). This allows easy upgrade: just pass Some(directory_hash) + /// when directory service becomes available. The full KKT protocol with hash pinning provides + /// protection against key substitution attacks. + pub fn process_kkt_response( + &self, + response_bytes: &[u8], + expected_key_hash: Option<&[u8]>, + ) -> Result<(), LpError> { + use nym_kkt::key_utils::hash_encapsulation_key; + use nym_kkt::kkt::validate_kem_response; + + let mut kkt_state = self.kkt_state.lock(); + + // Extract context from waiting state + let mut context = match &*kkt_state { + KKTState::InitiatorWaiting { context } => *context, + _ => { + return Err(LpError::Internal( + "KKT response received in invalid state".to_string(), + )); + } + }; + + // Determine hash to validate against + let hash_for_validation: Vec; + let hash_ref = match expected_key_hash { + Some(hash) => hash, + None => { + // Signature-only mode: extract key from response and compute its hash + // This effectively bypasses hash validation while keeping signature validation + use nym_kkt::frame::KKTFrame; + + let (frame, _) = KKTFrame::from_bytes(response_bytes).map_err(|e| { + LpError::Internal(format!("Failed to parse KKT response: {:?}", e)) + })?; + + hash_for_validation = hash_encapsulation_key( + &context.ciphersuite().hash_function(), + context.ciphersuite().hash_len(), + frame.body_ref(), + ); + &hash_for_validation + } + }; + + // Validate response and extract KEM key + let kem_pk = validate_kem_response( + &mut context, + &self.remote_ed25519_public, + hash_ref, + response_bytes, + ) + .map_err(|e| LpError::Internal(format!("KKT response validation failed: {:?}", e)))?; + + // Store the authenticated KEM key + *kkt_state = KKTState::Completed { + kem_pk: Box::new(kem_pk), + }; + + Ok(()) + } + + /// Processes a KKT request from the initiator and prepares a signed response. + /// + /// Validates the initiator's signature and creates a response containing this + /// responder's KEM public key, signed with Ed25519. + /// + /// # Arguments + /// + /// * `request_bytes` - Raw KKT request message from initiator + /// * `responder_kem_pk` - This responder's KEM public key to send + /// + /// # Returns + /// + /// * `Ok(LpMessage::KKTResponse)` - Signed KKT response ready to send + /// * `Err(LpError)` - Signature verification failed or invalid request + pub fn process_kkt_request( + &self, + request_bytes: &[u8], + responder_kem_pk: &EncapsulationKey, + ) -> Result { + use nym_kkt::{frame::KKTFrame, kkt::handle_kem_request}; + + let mut kkt_state = self.kkt_state.lock(); + + // Deserialize request frame + let (request_frame, _) = KKTFrame::from_bytes(request_bytes).map_err(|e| { + LpError::Internal(format!("KKT request deserialization failed: {:?}", e)) + })?; + + // Handle request and create signed response + let response_frame = handle_kem_request( + &request_frame, + Some(&self.remote_ed25519_public), // Verify initiator signature + &self.local_ed25519_private, // Sign response + responder_kem_pk, + ) + .map_err(|e| LpError::Internal(format!("KKT request handling failed: {:?}", e)))?; + + // Mark KKT as processed + // Responder doesn't store the kem_pk since they already have their own KEM keypair + *kkt_state = KKTState::ResponderProcessed; + + // Serialize response frame + let response_bytes = response_frame.to_bytes(); + + Ok(LpMessage::KKTResponse(crate::message::KKTResponseData( + response_bytes, + ))) + } + /// Prepares the next handshake message to be sent, if any. /// /// This should be called by the driver/IO layer to check if the Noise protocol /// state machine requires a message to be sent to the peer. /// + /// For initiators, PSQ always runs on the first message: + /// 1. Converts X25519 keys to DHKEM format + /// 2. Generates PSQ payload and derives PSK + /// 3. Injects PSK into Noise HandshakeState + /// 4. Embeds PSQ payload in first handshake message as: [u16 len][psq_payload][noise_msg] + /// /// # Returns /// /// * `Ok(None)` if no message needs to be sent currently (e.g., waiting for peer, or handshake complete). - /// * `Err(NoiseError)` if there's an error within the Noise protocol state. + /// * `Err(LpError)` if there's an error within the Noise protocol or PSQ. pub fn prepare_handshake_message(&self) -> Option> { let mut noise_state = self.noise_state.lock(); + + // PSQ always runs for initiator on first message + let mut psq_state = self.psq_state.lock(); + + if self.is_initiator && matches!(*psq_state, PSQState::NotStarted) { + // Extract KEM public key from completed KKT exchange + // PSQ requires the authenticated KEM key obtained via KKT protocol + let kkt_state = self.kkt_state.lock(); + let remote_kem = match &*kkt_state { + KKTState::Completed { kem_pk } => kem_pk, + _ => { + return Some(Err(LpError::KKTError( + "PSQ handshake requires completed KKT exchange".to_string(), + ))); + } + }; + + // Generate PSQ payload and PSK using KKT-authenticated KEM key + let session_context = self.id.to_le_bytes(); + + let (psk, psq_payload) = match psq_initiator_create_message( + &self.local_x25519_private, + &self.remote_x25519_public, + remote_kem, + &self.local_ed25519_private, + &self.local_ed25519_public, + &self.salt, + &session_context, + ) { + Ok(result) => result, + Err(e) => { + tracing::error!("PSQ handshake preparation failed, aborting: {:?}", e); + return Some(Err(e)); + } + }; + + // Inject PSK into Noise HandshakeState + if let Err(e) = noise_state.set_psk(3, &psk) { + return Some(Err(LpError::NoiseError(e))); + } + // Mark PSK as injected for safety checks in transport mode + self.psk_injected.store(true, Ordering::Release); + + // Get the Noise handshake message + let noise_msg = match noise_state.get_bytes_to_send() { + Some(Ok(msg)) => msg, + Some(Err(e)) => return Some(Err(LpError::NoiseError(e))), + None => return None, // Should not happen if is_my_turn, but handle gracefully + }; + + // Combine: [u16 psq_len][psq_payload][noise_msg] + let psq_len = psq_payload.len() as u16; + let mut combined = Vec::with_capacity(2 + psq_payload.len() + noise_msg.len()); + combined.extend_from_slice(&psq_len.to_le_bytes()); + combined.extend_from_slice(&psq_payload); + combined.extend_from_slice(&noise_msg); + + // Update PSQ state to InitiatorWaiting + *psq_state = PSQState::InitiatorWaiting { + ciphertext: psq_payload, + }; + + return Some(Ok(LpMessage::Handshake(HandshakeData(combined)))); + } + + // Normal flow (no PSQ, or PSQ already completed) + drop(psq_state); // Release lock + if let Some(message) = noise_state.get_bytes_to_send() { match message { - Ok(message) => Some(Ok(LpMessage::Handshake(HandshakeData(message)))), + Ok(noise_msg) => { + // Check if we have a PSK handle (ctxt_B) to embed (responder message 2 only) + // Only the responder should embed the handle, never the initiator + if !self.is_initiator { + let mut psk_handle_guard = self.psk_handle.lock(); + if let Some(handle_bytes) = psk_handle_guard.take() { + // Embed PSK handle in message: [u16 handle_len][handle_bytes][noise_msg] + let handle_len = handle_bytes.len() as u16; + let mut combined = + Vec::with_capacity(2 + handle_bytes.len() + noise_msg.len()); + combined.extend_from_slice(&handle_len.to_le_bytes()); + combined.extend_from_slice(&handle_bytes); + combined.extend_from_slice(&noise_msg); + + tracing::debug!( + "Embedding PSK handle ({} bytes) in handshake message 2", + handle_bytes.len() + ); + + return Some(Ok(LpMessage::Handshake(HandshakeData(combined)))); + } + } + // No PSK handle to embed, send noise message as-is + Some(Ok(LpMessage::Handshake(HandshakeData(noise_msg)))) + } Err(e) => Some(Err(LpError::NoiseError(e))), } } else { @@ -192,23 +702,154 @@ impl LpSession { /// This should be called by the driver/IO layer after receiving a potential /// handshake message payload from an LP packet. /// + /// For responders, PSQ always runs on the first message: + /// 1. Extracts PSQ payload from the first handshake message: [u16 len][psq_payload][noise_msg] + /// 2. Converts X25519 keys to DHKEM format + /// 3. Decapsulates PSK from PSQ payload + /// 4. Injects PSK into Noise HandshakeState + /// 5. Processes the remaining Noise handshake message + /// /// # Arguments /// - /// * `noise_payload` - The raw bytes received from the peer, purported to be a Noise handshake message. + /// * `message` - The LP message received from the peer, expected to be a Handshake message. /// /// # Returns /// /// * `Ok(ReadResult)` detailing the outcome (e.g., handshake complete, no-op). - /// * `Err(NoiseError)` if the message is invalid or causes a Noise protocol error. - pub fn process_handshake_message(&self, message: &LpMessage) -> Result { + /// * `Err(LpError)` if the message is invalid or causes a Noise/PSQ protocol error. + pub fn process_handshake_message(&self, message: &LpMessage) -> Result { let mut noise_state = self.noise_state.lock(); + let mut psq_state = self.psq_state.lock(); match message { LpMessage::Handshake(HandshakeData(payload)) => { + // PSQ always runs for responder on first message + if !self.is_initiator && matches!(*psq_state, PSQState::ResponderWaiting) { + // Extract PSQ payload: [u16 psq_len][psq_payload][noise_msg] + if payload.len() < 2 { + return Err(LpError::NoiseError(NoiseError::Other( + "Payload too short for PSQ extraction".to_string(), + ))); + } + + let psq_len = u16::from_le_bytes([payload[0], payload[1]]) as usize; + + if payload.len() < 2 + psq_len { + return Err(LpError::NoiseError(NoiseError::Other( + "Payload length mismatch for PSQ extraction".to_string(), + ))); + } + + let psq_payload = &payload[2..2 + psq_len]; + let noise_payload = &payload[2 + psq_len..]; + + // Convert X25519 local keys to DecapsulationKey/EncapsulationKey (DHKEM) + let local_private_bytes = &self.local_x25519_private.to_bytes(); + let libcrux_private_key = libcrux_kem::PrivateKey::decode( + libcrux_kem::Algorithm::X25519, + local_private_bytes, + ) + .map_err(|e| { + LpError::KKTError(format!( + "Failed to convert X25519 private key to libcrux PrivateKey: {:?}", + e + )) + })?; + let dec_key = DecapsulationKey::X25519(libcrux_private_key); + + let local_public_key = self.local_x25519_private.public_key(); + let local_public_bytes = local_public_key.as_bytes(); + let libcrux_public_key = libcrux_kem::PublicKey::decode( + libcrux_kem::Algorithm::X25519, + local_public_bytes, + ) + .map_err(|e| { + LpError::KKTError(format!( + "Failed to convert X25519 public key to libcrux PublicKey: {:?}", + e + )) + })?; + let enc_key = EncapsulationKey::X25519(libcrux_public_key); + + // Decapsulate PSK from PSQ payload using X25519 as DHKEM + let session_context = self.id.to_le_bytes(); + + let (psk, responder_msg_bytes) = match psq_responder_process_message( + &self.local_x25519_private, + &self.remote_x25519_public, + (&dec_key, &enc_key), + &self.remote_ed25519_public, + psq_payload, + &self.salt, + &session_context, + ) { + Ok(result) => result, + Err(e) => { + tracing::error!("PSQ handshake processing failed, aborting: {:?}", e); + return Err(e); + } + }; + + // Store the PSK handle (ctxt_B) for transmission in next message + { + let mut psk_handle = self.psk_handle.lock(); + *psk_handle = Some(responder_msg_bytes); + } + + // Inject PSK into Noise HandshakeState + noise_state.set_psk(3, &psk)?; + // Mark PSK as injected for safety checks in transport mode + self.psk_injected.store(true, Ordering::Release); + + // Update PSQ state to Completed + *psq_state = PSQState::Completed { psk }; + + // Process the Noise handshake message (without PSQ prefix) + drop(psq_state); // Release lock before processing + return noise_state + .read_message(noise_payload) + .map_err(LpError::NoiseError); + } + + // Check if initiator should extract PSK handle from message 2 + if self.is_initiator && matches!(*psq_state, PSQState::InitiatorWaiting { .. }) { + // Extract PSK handle: [u16 handle_len][handle_bytes][noise_msg] + if payload.len() >= 2 { + let handle_len = u16::from_le_bytes([payload[0], payload[1]]) as usize; + + if handle_len > 0 && payload.len() >= 2 + handle_len { + // Extract and store the PSK handle + let handle_bytes = &payload[2..2 + handle_len]; + let noise_payload = &payload[2 + handle_len..]; + + tracing::debug!( + "Extracted PSK handle ({} bytes) from message 2", + handle_len + ); + + { + let mut psk_handle = self.psk_handle.lock(); + *psk_handle = Some(handle_bytes.to_vec()); + } + + // Release psq_state lock before processing + drop(psq_state); + + // Process only the Noise message part + return noise_state + .read_message(noise_payload) + .map_err(LpError::NoiseError); + } + } + // If no valid handle found, fall through to normal processing + } + // The sans-io NoiseProtocol::read_message expects only the payload. - noise_state.read_message(payload) + noise_state + .read_message(payload) + .map_err(LpError::NoiseError) } - _ => Err(NoiseError::IncorrectStateError), + _ => Err(LpError::NoiseError(NoiseError::IncorrectStateError)), } } @@ -231,6 +872,10 @@ impl LpSession { /// * `Err(NoiseError)` if the session is not in transport mode or encryption fails. pub fn encrypt_data(&self, payload: &[u8]) -> Result { let mut noise_state = self.noise_state.lock(); + // Safety: Prevent transport mode with dummy PSK + if !self.psk_injected.load(Ordering::Acquire) { + return Err(NoiseError::PskNotInjected); + } // Explicitly check if handshake is finished before trying to write if !noise_state.is_handshake_finished() { return Err(NoiseError::IncorrectStateError); @@ -254,6 +899,10 @@ impl LpSession { /// * `Err(NoiseError)` if the session is not in transport mode, decryption fails, or the message is not data. pub fn decrypt_data(&self, noise_ciphertext: &LpMessage) -> Result, NoiseError> { let mut noise_state = self.noise_state.lock(); + // Safety: Prevent transport mode with dummy PSK + if !self.psk_injected.load(Ordering::Acquire) { + return Err(NoiseError::PskNotInjected); + } // Explicitly check if handshake is finished before trying to read if !noise_state.is_handshake_finished() { return Err(NoiseError::IncorrectStateError); @@ -266,38 +915,76 @@ impl LpSession { _ => Err(NoiseError::IncorrectStateError), } } + + /// Test-only method to set KKT state to Completed with a mock KEM key. + /// This allows tests to bypass KKT exchange and directly test PSQ handshake. + #[cfg(test)] + pub(crate) fn set_kkt_completed_for_test(&self, remote_x25519_pub: &PublicKey) { + // Convert remote X25519 public key to EncapsulationKey for testing + let remote_kem_bytes = remote_x25519_pub.as_bytes(); + let libcrux_public_key = + libcrux_kem::PublicKey::decode(libcrux_kem::Algorithm::X25519, remote_kem_bytes) + .expect("Test KEM key conversion failed"); + let kem_pk = EncapsulationKey::X25519(libcrux_public_key); + + let mut kkt_state = self.kkt_state.lock(); + *kkt_state = KKTState::Completed { + kem_pk: Box::new(kem_pk), + }; + } } #[cfg(test)] mod tests { - use snow::{params::NoiseParams, Keypair}; - use super::*; - use crate::{replay::ReplayError, sessions_for_tests, NOISE_PATTERN}; + use crate::{replay::ReplayError, sessions_for_tests}; // Helper function to generate keypairs for tests - fn generate_keypair() -> Keypair { - let params: NoiseParams = NOISE_PATTERN.parse().unwrap(); - snow::Builder::new(params).generate_keypair().unwrap() + fn generate_keypair() -> crate::keypair::Keypair { + crate::keypair::Keypair::default() } // Helper function to create a session with real keys for handshake tests fn create_handshake_test_session( is_initiator: bool, - local_keys: &Keypair, - remote_pub_key: &[u8], - psk: &[u8], + local_keys: &crate::keypair::Keypair, + remote_pub_key: &crate::keypair::PublicKey, ) -> LpSession { - // Use a dummy ID for testing, the important part is is_initiator - let test_id = if is_initiator { 1 } else { 2 }; - LpSession::new( - test_id, + use nym_crypto::asymmetric::ed25519; + + // Compute the shared lp_id from both keypairs (order-independent) + let lp_id = crate::make_lp_id(local_keys.public_key(), remote_pub_key); + + // Create Ed25519 keypairs that correspond to initiator/responder roles + // Initiator uses [1u8], Responder uses [2u8] + let (local_ed25519_seed, remote_ed25519_seed) = if is_initiator { + ([1u8; 32], [2u8; 32]) + } else { + ([2u8; 32], [1u8; 32]) + }; + + let local_ed25519 = ed25519::KeyPair::from_secret(local_ed25519_seed, 0); + let remote_ed25519 = ed25519::KeyPair::from_secret(remote_ed25519_seed, 1); + + let salt = [0u8; 32]; // Test salt + + // PSQ will derive the PSK during handshake using X25519 as DHKEM + let session = LpSession::new( + lp_id, is_initiator, - &local_keys.private, + (local_ed25519.private_key(), local_ed25519.public_key()), + local_keys.private_key(), + remote_ed25519.public_key(), remote_pub_key, - psk, + &salt, ) - .expect("Test session creation failed") + .expect("Test session creation failed"); + + // Initialize KKT state to Completed for tests (bypasses KKT exchange) + // This simulates having already received the remote party's KEM key via KKT + session.set_kkt_completed_for_test(remote_pub_key); + + session } #[test] @@ -313,6 +1000,21 @@ mod tests { assert_eq!(counter, 1); } + // NOTE: These tests are obsolete after removing optional KEM parameters. + // PSQ now always runs using X25519 keys internally converted to KEM format. + // The new tests at the end of this file (test_psq_*) cover PSQ integration. + /* + #[test] + fn test_session_creation_with_psq_state_initiator() { + // OLD API - REMOVED + } + + #[test] + fn test_session_creation_with_psq_state_responder() { + // OLD API - REMOVED + } + */ + #[test] fn test_replay_protection_sequential() { let session = sessions_for_tests().1; @@ -378,15 +1080,13 @@ mod tests { fn test_prepare_handshake_message_initial_state() { let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); - let psk = [3u8; 32]; let initiator_session = - create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk); + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); let responder_session = create_handshake_test_session( false, &responder_keys, - &initiator_keys.public, // Responder also needs initiator's key for XK - &psk, + initiator_keys.public_key(), // Responder also needs initiator's key for XK ); // Initiator should have a message to send immediately (-> e) @@ -406,12 +1106,11 @@ mod tests { fn test_process_handshake_message_first_step() { let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); - let psk = [4u8; 32]; let initiator_session = - create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk); + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, &initiator_keys.public, &psk); + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); // 1. Initiator prepares the first message (-> e) let initiator_msg_result = initiator_session.prepare_handshake_message(); @@ -444,12 +1143,11 @@ mod tests { fn test_handshake_driver_simulation() { let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); - let psk = [5u8; 32]; let initiator_session = - create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk); + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, &initiator_keys.public, &psk); + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); let mut responder_to_initiator_msg = None; let mut rounds = 0; @@ -532,12 +1230,11 @@ mod tests { // --- Setup Handshake --- let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); - let psk = [6u8; 32]; let initiator_session = - create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk); + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, &initiator_keys.public, &psk); + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); // Drive handshake to completion (simplified loop from previous test) let mut i_msg = initiator_session @@ -594,10 +1291,9 @@ mod tests { fn test_encrypt_decrypt_before_handshake() { let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); - let psk = [7u8; 32]; let initiator_session = - create_handshake_test_session(true, &initiator_keys, &responder_keys.public, &psk); + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); assert!(!initiator_session.is_handshake_complete()); @@ -606,18 +1302,19 @@ mod tests { let result = initiator_session.encrypt_data(plaintext); assert!(result.is_err()); match result.unwrap_err() { - NoiseError::IncorrectStateError => {} // Expected error - e => panic!("Expected IncorrectStateError, got {:?}", e), + NoiseError::PskNotInjected => {} // Expected - PSK check comes before handshake check + e => panic!("Expected PskNotInjected, got {:?}", e), } // Attempt to decrypt before handshake (using dummy ciphertext) let dummy_ciphertext = vec![0u8; 32]; - let result_decrypt = - initiator_session.decrypt_data(&LpMessage::EncryptedData(EncryptedDataPayload(dummy_ciphertext))); + let result_decrypt = initiator_session.decrypt_data(&LpMessage::EncryptedData( + EncryptedDataPayload(dummy_ciphertext), + )); assert!(result_decrypt.is_err()); match result_decrypt.unwrap_err() { - NoiseError::IncorrectStateError => {} // Expected error - e => panic!("Expected IncorrectStateError, got {:?}", e), + NoiseError::PskNotInjected => {} // Expected - PSK check comes before handshake check + e => panic!("Expected PskNotInjected, got {:?}", e), } } @@ -656,4 +1353,546 @@ mod tests { // } } */ + + // ==================================================================== + // PSQ Handshake Integration Tests + // ==================================================================== + + /// Test that PSQ runs during handshake and derives a PSK + #[test] + fn test_psq_handshake_runs_with_psk_injection() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Drive the handshake + let mut i_msg = initiator_session + .prepare_handshake_message() + .expect("Initiator should have message") + .expect("Message prep should succeed"); + + // The first message should contain PSQ payload embedded + // Verify message is not empty and has reasonable size + assert!(!i_msg.is_empty(), "Initiator message should not be empty"); + assert!( + i_msg.len() > 100, + "Message should contain PSQ payload (actual: {})", + i_msg.len() + ); + + // Responder processes message (which includes PSQ decapsulation) + responder_session + .process_handshake_message(&i_msg) + .expect("Responder should process first message"); + + // Continue handshake + let r_msg = responder_session + .prepare_handshake_message() + .expect("Responder should have message") + .expect("Responder message prep should succeed"); + + initiator_session + .process_handshake_message(&r_msg) + .expect("Initiator should process responder message"); + + i_msg = initiator_session + .prepare_handshake_message() + .expect("Initiator should have final message") + .expect("Final message prep should succeed"); + + responder_session + .process_handshake_message(&i_msg) + .expect("Responder should process final message"); + + // Verify handshake completed + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Verify encryption works (implicitly tests PSK was correctly injected) + let plaintext = b"PSQ test message"; + let encrypted = initiator_session + .encrypt_data(plaintext) + .expect("Encryption should work after handshake"); + + let decrypted = responder_session + .decrypt_data(&encrypted) + .expect("Decryption should work with PSQ-derived PSK"); + + assert_eq!(decrypted, plaintext); + } + + /// Test that X25519 keys are correctly converted to KEM format + #[test] + fn test_x25519_to_kem_conversion() { + use nym_kkt::ciphersuite::EncapsulationKey; + + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Verify we can convert X25519 public key to KEM format (as done in session.rs) + let x25519_public_bytes = responder_keys.public_key().as_bytes(); + let libcrux_public_key = + libcrux_kem::PublicKey::decode(libcrux_kem::Algorithm::X25519, x25519_public_bytes) + .expect("X25519 public key should convert to libcrux PublicKey"); + + let _kem_key = EncapsulationKey::X25519(libcrux_public_key); + + // Verify we can convert X25519 private key to KEM format + let x25519_private_bytes = initiator_keys.private_key().to_bytes(); + let _libcrux_private_key = + libcrux_kem::PrivateKey::decode(libcrux_kem::Algorithm::X25519, &x25519_private_bytes) + .expect("X25519 private key should convert to libcrux PrivateKey"); + + // Successful conversion is sufficient - actual encapsulation is tested in psk.rs + // (libcrux_kem::PrivateKey is an enum with no len() method, conversion success is enough) + } + + /// Test that PSQ actually derives a different PSK (not using dummy) + #[test] + fn test_psq_derived_psk_differs_from_dummy() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create sessions - they start with dummy PSK [0u8; 32] + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Prepare first message (initiator runs PSQ and injects PSK) + let i_msg = initiator_session + .prepare_handshake_message() + .expect("Initiator should have message") + .expect("Message prep should succeed"); + + // Verify message is not empty (PSQ runs successfully) + assert!( + !i_msg.is_empty(), + "First message should contain PSQ payload" + ); + + // Complete handshake + responder_session + .process_handshake_message(&i_msg) + .expect("Responder should process message"); + + let r_msg = responder_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + initiator_session.process_handshake_message(&r_msg).unwrap(); + + let final_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + responder_session + .process_handshake_message(&final_msg) + .unwrap(); + + // Test that encryption produces non-trivial ciphertext + // (would fail if using dummy PSK incorrectly) + let plaintext = b"test"; + let encrypted = initiator_session.encrypt_data(plaintext).unwrap(); + + // Decrypt should work + let decrypted = responder_session.decrypt_data(&encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + + // Verify ciphertext is not just plaintext (basic encryption sanity) + if let LpMessage::EncryptedData(payload) = encrypted { + assert_ne!( + &payload.0[..plaintext.len()], + plaintext, + "Ciphertext should differ from plaintext" + ); + } else { + panic!("Expected EncryptedData message"); + } + } + + /// Test full end-to-end handshake with PSQ integration + #[test] + fn test_handshake_with_psq_end_to_end() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Verify initial state + assert!(!initiator_session.is_handshake_complete()); + assert!(!responder_session.is_handshake_complete()); + assert!(initiator_session.is_initiator()); + assert!(!responder_session.is_initiator()); + + // Round 1: Initiator -> Responder (contains PSQ encapsulation) + let msg1 = initiator_session + .prepare_handshake_message() + .expect("Initiator should prepare message") + .expect("Message should succeed"); + + assert!(!msg1.is_empty()); + assert!(!initiator_session.is_handshake_complete()); + + responder_session + .process_handshake_message(&msg1) + .expect("Responder should process PSQ message"); + + assert!(!responder_session.is_handshake_complete()); + + // Round 2: Responder -> Initiator + let msg2 = responder_session + .prepare_handshake_message() + .expect("Responder should prepare message") + .expect("Message should succeed"); + + initiator_session + .process_handshake_message(&msg2) + .expect("Initiator should process message"); + + // Round 3: Initiator -> Responder (final) + let msg3 = initiator_session + .prepare_handshake_message() + .expect("Initiator should prepare final message") + .expect("Message should succeed"); + + responder_session + .process_handshake_message(&msg3) + .expect("Responder should process final message"); + + // Verify both sides completed + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Test bidirectional encrypted communication + let msg_i_to_r = b"Hello from initiator"; + let encrypted_i = initiator_session + .encrypt_data(msg_i_to_r) + .expect("Initiator encryption"); + let decrypted_i = responder_session + .decrypt_data(&encrypted_i) + .expect("Responder decryption"); + assert_eq!(decrypted_i, msg_i_to_r); + + let msg_r_to_i = b"Hello from responder"; + let encrypted_r = responder_session + .encrypt_data(msg_r_to_i) + .expect("Responder encryption"); + let decrypted_r = initiator_session + .decrypt_data(&encrypted_r) + .expect("Initiator decryption"); + assert_eq!(decrypted_r, msg_r_to_i); + + // Successfully completed end-to-end test with PSQ + } + + /// Test that Ed25519 keys are used in PSQ authentication + #[test] + fn test_psq_handshake_uses_ed25519_authentication() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create sessions with explicit Ed25519 keys + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Verify sessions store Ed25519 keys + // (Internal verification - keys are used in PSQ calls) + assert_eq!(initiator_session.id(), responder_session.id()); + + // Complete handshake + let msg1 = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&msg1).unwrap(); + + let msg2 = responder_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + initiator_session.process_handshake_message(&msg2).unwrap(); + + let msg3 = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&msg3).unwrap(); + + // If Ed25519 authentication failed, handshake would not complete + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Verify encrypted communication works (proof of successful PSQ with auth) + let test_data = b"Authentication test"; + let encrypted = initiator_session.encrypt_data(test_data).unwrap(); + let decrypted = responder_session.decrypt_data(&encrypted).unwrap(); + assert_eq!(decrypted, test_data); + } + + #[test] + fn test_psq_deserialization_failure() { + // Test that corrupted PSQ payload causes clean abort + let responder_keys = generate_keypair(); + let initiator_keys = generate_keypair(); + + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Create a handshake message with corrupted PSQ payload + let corrupted_psq_data = vec![0xFF; 128]; // Random garbage + let bad_message = LpMessage::Handshake(HandshakeData(corrupted_psq_data)); + + // Attempt to process corrupted message - should fail + let result = responder_session.process_handshake_message(&bad_message); + + // Should return error (PSQ deserialization will fail) + assert!(result.is_err(), "Expected error for corrupted PSQ payload"); + + // Verify session state is unchanged + // PSQ state should still be ResponderWaiting (not modified) + // Noise PSK should still be dummy [0u8; 32] + assert!(!responder_session.is_handshake_complete()); + } + + #[test] + fn test_handshake_abort_on_psq_failure() { + // Test that Ed25519 auth failure causes handshake abort + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create sessions with MISMATCHED Ed25519 keys + // This simulates authentication failure + let initiator_ed25519 = ed25519::KeyPair::from_secret([1u8; 32], 0); + let wrong_ed25519 = ed25519::KeyPair::from_secret([99u8; 32], 99); // Different key! + + let lp_id = crate::make_lp_id(initiator_keys.public_key(), responder_keys.public_key()); + let salt = [0u8; 32]; + + let initiator_session = LpSession::new( + lp_id, + true, + ( + initiator_ed25519.private_key(), + initiator_ed25519.public_key(), + ), + initiator_keys.private_key(), + wrong_ed25519.public_key(), // Responder expects THIS key + responder_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + initiator_session.set_kkt_completed_for_test(responder_keys.public_key()); + + let responder_ed25519 = ed25519::KeyPair::from_secret([2u8; 32], 1); + + let responder_session = LpSession::new( + lp_id, + false, + ( + responder_ed25519.private_key(), + responder_ed25519.public_key(), + ), + responder_keys.private_key(), + wrong_ed25519.public_key(), // Expects WRONG key (not initiator's) + initiator_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + responder_session.set_kkt_completed_for_test(initiator_keys.public_key()); + + // Initiator prepares message (should succeed - signing works) + let msg1 = initiator_session + .prepare_handshake_message() + .expect("Initiator should prepare message") + .expect("Initiator should have message"); + + // Responder processes message - should FAIL (signature verification fails) + let result = responder_session.process_handshake_message(&msg1); + + // Should return CredError due to Ed25519 signature mismatch + assert!( + result.is_err(), + "Expected error for Ed25519 authentication failure" + ); + + // Verify handshake aborted cleanly + assert!(!initiator_session.is_handshake_complete()); + assert!(!responder_session.is_handshake_complete()); + } + + #[test] + fn test_psq_invalid_signature() { + // Test Ed25519 signature validation specifically + // Setup with matching X25519 keys but mismatched Ed25519 keys + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Initiator uses Ed25519 key [1u8] + let initiator_ed25519 = ed25519::KeyPair::from_secret([1u8; 32], 0); + + // Responder expects Ed25519 key [99u8] (wrong!) + let wrong_ed25519_keypair = ed25519::KeyPair::from_secret([99u8; 32], 99); + let wrong_ed25519_public = wrong_ed25519_keypair.public_key(); + + let lp_id = crate::make_lp_id(initiator_keys.public_key(), responder_keys.public_key()); + let salt = [0u8; 32]; + + let initiator_session = LpSession::new( + lp_id, + true, + ( + initiator_ed25519.private_key(), + initiator_ed25519.public_key(), + ), + initiator_keys.private_key(), + wrong_ed25519_public, // This doesn't matter for initiator + responder_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + initiator_session.set_kkt_completed_for_test(responder_keys.public_key()); + + let responder_ed25519 = ed25519::KeyPair::from_secret([2u8; 32], 1); + + let responder_session = LpSession::new( + lp_id, + false, + ( + responder_ed25519.private_key(), + responder_ed25519.public_key(), + ), + responder_keys.private_key(), + wrong_ed25519_public, // Responder expects WRONG key + initiator_keys.public_key(), + &salt, + ) + .unwrap(); + // Initialize KKT state for test + responder_session.set_kkt_completed_for_test(initiator_keys.public_key()); + + // Initiator creates message with valid signature (signed with [1u8]) + let msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + // Responder tries to verify with wrong public key [99u8] + // This should fail Ed25519 signature verification + let result = responder_session.process_handshake_message(&msg); + + assert!(result.is_err(), "Expected signature verification to fail"); + + // Verify error is related to PSQ/authentication + match result.unwrap_err() { + LpError::Internal(msg) if msg.contains("PSQ") => { + // Expected - PSQ v1 responder send failed due to CredError + } + e => panic!("Unexpected error type: {:?}", e), + } + } + + #[test] + fn test_psq_state_unchanged_on_error() { + // Verify that PSQ errors leave session in clean state + let responder_keys = generate_keypair(); + let initiator_keys = generate_keypair(); + + let responder_session = + create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + + // Capture initial PSQ state (should be ResponderWaiting) + // (We can't directly access psq_state, but we can verify behavior) + + // Send corrupted data + let corrupted_message = LpMessage::Handshake(HandshakeData(vec![0xFF; 100])); + + // Process should fail + let result = responder_session.process_handshake_message(&corrupted_message); + assert!(result.is_err()); + + // After error, session should still be in handshake mode (not complete) + assert!(!responder_session.is_handshake_complete()); + + // Session should still be functional - can process valid messages + // Create a proper initiator to send valid message + let initiator_session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + + let valid_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + + // After the error, responder should still be able to process valid messages + let result2 = responder_session.process_handshake_message(&valid_msg); + + // Should succeed (session state was not corrupted by previous error) + assert!( + result2.is_ok(), + "Session should still be functional after PSQ error" + ); + } + + #[test] + fn test_transport_fails_without_psk_injection() { + // This test verifies the safety mechanism that prevents transport mode operations + // from running with the dummy PSK if PSQ injection fails or is skipped. + + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + // Create session but don't complete handshake (no PSK injection will occur) + let session = + create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + + // Verify session was created successfully + assert!(!session.is_handshake_complete()); + + // Attempt to encrypt data - should fail with PskNotInjected + let plaintext = b"test data"; + let encrypt_result = session.encrypt_data(plaintext); + + assert!( + encrypt_result.is_err(), + "encrypt_data should fail without PSK injection" + ); + match encrypt_result.unwrap_err() { + NoiseError::PskNotInjected => { + // Expected - this is the safety mechanism working + } + e => panic!("Expected PskNotInjected error, got: {:?}", e), + } + + // Create a dummy encrypted message to test decrypt + let dummy_ciphertext = LpMessage::EncryptedData(EncryptedDataPayload(vec![0u8; 48])); + + // Attempt to decrypt data - should also fail with PskNotInjected + let decrypt_result = session.decrypt_data(&dummy_ciphertext); + + assert!( + decrypt_result.is_err(), + "decrypt_data should fail without PSK injection" + ); + match decrypt_result.unwrap_err() { + NoiseError::PskNotInjected => { + // Expected - this is the safety mechanism working + } + e => panic!("Expected PskNotInjected error, got: {:?}", e), + } + } } diff --git a/common/nym-lp/src/session_integration/mod.rs b/common/nym-lp/src/session_integration/mod.rs index dc11607660..4e52a6cbd3 100644 --- a/common/nym-lp/src/session_integration/mod.rs +++ b/common/nym-lp/src/session_integration/mod.rs @@ -1,15 +1,16 @@ #[cfg(test)] mod tests { use crate::codec::{parse_lp_packet, serialize_lp_packet}; - use crate::keypair::Keypair; + use crate::keypair::PublicKey; use crate::make_lp_id; use crate::{ + LpError, message::LpMessage, packet::{LpHeader, LpPacket, TRAILER_LEN}, session_manager::SessionManager, - LpError, }; use bytes::BytesMut; + use nym_crypto::asymmetric::ed25519; // Function to create a test packet - similar to how it's done in codec.rs tests fn create_test_packet( @@ -48,25 +49,70 @@ mod tests { // 1. Initialize session manager let session_manager_1 = SessionManager::new(); let session_manager_2 = SessionManager::new(); - // 2. Generate keys and PSK - let peer_a_keys = Keypair::default(); - let peer_b_keys = Keypair::default(); - let lp_id = make_lp_id(peer_a_keys.public_key(), peer_b_keys.public_key()); - let psk = [1u8; 32]; // Define a pre-shared key for the test + + // 2. Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_a = ed25519::KeyPair::from_secret([1u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([2u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_a = ed25519_keypair_a + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_b = ed25519_keypair_b + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + + // Test salt + let salt = [42u8; 32]; // 4. Create sessions using the pre-built Noise states let peer_a_sm = session_manager_1 - .create_session_state_machine(&peer_a_keys, peer_b_keys.public_key(), true, &psk) + .create_session_state_machine( + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key(), + ), + ed25519_keypair_b.public_key(), + true, + &salt, + ) .expect("Failed to create session A"); let peer_b_sm = session_manager_2 - .create_session_state_machine(&peer_b_keys, peer_a_keys.public_key(), false, &psk) + .create_session_state_machine( + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key(), + ), + ed25519_keypair_a.public_key(), + false, + &salt, + ) .expect("Failed to create session B"); // Verify session count assert_eq!(session_manager_1.session_count(), 1); assert_eq!(session_manager_2.session_count(), 1); + // Initialize KKT state for both sessions (test bypass) + session_manager_1 + .init_kkt_for_test(peer_a_sm, &lp_pub_b) + .expect("Failed to init KKT for peer A"); + session_manager_2 + .init_kkt_for_test(peer_b_sm, &lp_pub_a) + .expect("Failed to init KKT for peer B"); + // 5. Simulate Noise Handshake (Sans-IO) println!("Starting handshake simulation..."); let mut i_msg_payload; @@ -308,7 +354,9 @@ mod tests { 1, lp_id, counter_b, - LpMessage::EncryptedData(crate::message::EncryptedDataPayload(plaintext_b_to_a.to_vec())), // Using plaintext here, but content doesn't matter for replay check + LpMessage::EncryptedData(crate::message::EncryptedDataPayload( + plaintext_b_to_a.to_vec(), + )), // Using plaintext here, but content doesn't matter for replay check ); let mut encoded_data_b_to_a_replay = BytesMut::new(); serialize_lp_packet(&message_b_to_a_replay, &mut encoded_data_b_to_a_replay) @@ -450,19 +498,63 @@ mod tests { let session_manager_1 = SessionManager::new(); let session_manager_2 = SessionManager::new(); - // 2. Setup sessions and complete handshake (similar to test_full_session_flow) - let peer_a_keys = Keypair::default(); - let peer_b_keys = Keypair::default(); - let lp_id = make_lp_id(peer_a_keys.public_key(), peer_b_keys.public_key()); - let psk = [2u8; 32]; + // 2. Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_a = ed25519::KeyPair::from_secret([3u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([4u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_a = ed25519_keypair_a + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_b = ed25519_keypair_b + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + + // Test salt + let salt = [43u8; 32]; let peer_a_sm = session_manager_1 - .create_session_state_machine(&peer_a_keys, peer_b_keys.public_key(), true, &psk) + .create_session_state_machine( + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key(), + ), + ed25519_keypair_b.public_key(), + true, + &salt, + ) .unwrap(); let peer_b_sm = session_manager_2 - .create_session_state_machine(&peer_b_keys, peer_a_keys.public_key(), false, &psk) + .create_session_state_machine( + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key(), + ), + ed25519_keypair_a.public_key(), + false, + &salt, + ) .unwrap(); + // Initialize KKT state for both sessions (test bypass) + session_manager_1 + .init_kkt_for_test(peer_a_sm, &lp_pub_b) + .expect("Failed to init KKT for peer A"); + session_manager_2 + .init_kkt_for_test(peer_b_sm, &lp_pub_a) + .expect("Failed to init KKT for peer B"); + // Drive handshake to completion (simplified) let mut i_msg = session_manager_1 .prepare_handshake_message(peer_a_sm) @@ -615,15 +707,33 @@ mod tests { // 1. Initialize session manager let session_manager = SessionManager::new(); - // Setup for creating real noise state (keys/psk don't matter for this test) - let keys = Keypair::default(); - let psk = [3u8; 32]; + // Generate Ed25519 keypair for PSQ authentication + let ed25519_keypair = ed25519::KeyPair::from_secret([5u8; 32], 0); - let lp_id = make_lp_id(keys.public_key(), keys.public_key()); + // Derive X25519 key from Ed25519 (same as state machine does internally) + let x25519_pub = ed25519_keypair + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair type + let lp_pub = PublicKey::from_bytes(x25519_pub.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (self-connection: both sides use same key) + let lp_id = make_lp_id(&lp_pub, &lp_pub); + + // Test salt + let salt = [44u8; 32]; // 2. Create a session (using real noise state) let _session = session_manager - .create_session_state_machine(&keys, keys.public_key(), true, &psk) + .create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ) .expect("Failed to create session"); // 3. Try to get a non-existent session @@ -639,7 +749,12 @@ mod tests { // 5. Create and immediately remove a session let _temp_session = session_manager - .create_session_state_machine(&keys, keys.public_key(), true, &psk) + .create_session_state_machine( + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), + true, + &salt, + ) .expect("Failed to create temp session"); assert!( @@ -715,19 +830,59 @@ mod tests { let session_manager_1 = SessionManager::new(); let session_manager_2 = SessionManager::new(); - // 2. Generate keys and PSK - let peer_a_keys = Keypair::default(); - let peer_b_keys = Keypair::default(); - let lp_id = make_lp_id(peer_a_keys.public_key(), peer_b_keys.public_key()); - let psk = [1u8; 32]; + // 2. Generate Ed25519 keypairs for PSQ authentication + let ed25519_keypair_a = ed25519::KeyPair::from_secret([6u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([7u8; 32], 1); + + // Derive X25519 keys from Ed25519 (same as state machine does internally) + let x25519_pub_a = ed25519_keypair_a + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + let x25519_pub_b = ed25519_keypair_b + .public_key() + .to_x25519() + .expect("Failed to derive X25519 from Ed25519"); + + // Convert to LP keypair types + let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) + .expect("Failed to create PublicKey from bytes"); + + // Calculate lp_id (matches state machine's internal calculation) + let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + + // Test salt + let salt = [45u8; 32]; // 3. Create sessions state machines - assert!(session_manager_1 - .create_session_state_machine(&peer_a_keys, peer_b_keys.public_key(), true, &psk) // Initiator - .is_ok()); - assert!(session_manager_2 - .create_session_state_machine(&peer_b_keys, peer_a_keys.public_key(), false, &psk) // Responder - .is_ok()); + assert!( + session_manager_1 + .create_session_state_machine( + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key() + ), + ed25519_keypair_b.public_key(), + true, + &salt, + ) // Initiator + .is_ok() + ); + assert!( + session_manager_2 + .create_session_state_machine( + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key() + ), + ed25519_keypair_a.public_key(), + false, + &salt, + ) // Responder + .is_ok() + ); assert_eq!(session_manager_1.session_count(), 1); assert_eq!(session_manager_2.session_count(), 1); @@ -750,7 +905,7 @@ mod tests { let mut packet_a_to_b: Option; let mut packet_b_to_a: Option; let mut rounds = 0; - const MAX_ROUNDS: usize = 5; // XK handshake takes 3 messages + const MAX_ROUNDS: usize = 10; // KKT (2 messages) + XK handshake (3 messages) + PSQ = 6 rounds total // --- Round 1: Initiator Starts --- println!(" Round {}: Initiator starts handshake", rounds); @@ -760,20 +915,21 @@ mod tests { .expect("Initiator StartHandshake failed"); if let LpAction::SendPacket(packet) = action_a1 { - println!(" Initiator produced SendPacket (-> e)"); + println!(" Initiator produced SendPacket (KKT request)"); packet_a_to_b = Some(packet); } else { panic!("Initiator StartHandshake did not produce SendPacket"); } + // After StartHandshake, initiator should be in KKTExchange state (not Handshaking yet) assert_eq!( session_manager_1.get_state(lp_id).unwrap(), - LpStateBare::Handshaking, - "Initiator state wrong after StartHandshake" + LpStateBare::KKTExchange, + "Initiator state wrong after StartHandshake (should be KKTExchange)" ); // *** ADD THIS BLOCK for Responder StartHandshake *** println!( - " Round {}: Responder explicitly enters Handshaking state", + " Round {}: Responder explicitly enters KKTExchange state", rounds ); let action_b_start = session_manager_2.process_input(lp_id, LpInput::StartHandshake); @@ -783,107 +939,209 @@ mod tests { "Responder StartHandshake should produce None action, got {:?}", action_b_start ); - // Verify responder transitions to Handshaking state + // Verify responder transitions to KKTExchange state (not Handshaking yet) assert_eq!( session_manager_2.get_state(lp_id).unwrap(), - LpStateBare::Handshaking, // State should now be Handshaking - "Responder state should be Handshaking after its StartHandshake" + LpStateBare::KKTExchange, // Responder also enters KKTExchange state + "Responder state should be KKTExchange after its StartHandshake" ); // *** END OF ADDED BLOCK *** - // --- Round 2: Responder Receives, Sends Reply --- + // --- Round 2: Responder Receives KKT Request, Sends KKT Response --- rounds += 1; - println!(" Round {}: Responder receives, sends reply", rounds); - let packet_to_process = packet_a_to_b.take().expect("Packet from A was missing"); + println!( + " Round {}: Responder receives KKT request, sends KKT response", + rounds + ); + let packet_to_process = packet_a_to_b + .take() + .expect("KKT request from A was missing"); // Simulate network: serialize -> parse (optional but good practice) let mut buf_a = BytesMut::new(); serialize_lp_packet(&packet_to_process, &mut buf_a).unwrap(); let parsed_packet_a = parse_lp_packet(&buf_a).unwrap(); - // Responder processes (Now starting from Handshaking state) + // Responder processes KKT request let action_b1 = session_manager_2 .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a)) .expect("Responder ReceivePacket should produce an action") .expect("Responder ReceivePacket failed"); if let LpAction::SendPacket(packet) = action_b1 { - println!(" Responder received, produced SendPacket (<- e, es)"); + println!(" Responder received KKT request, produced KKT response"); packet_b_to_a = Some(packet); } else { - panic!("Responder ReceivePacket did not produce SendPacket"); + panic!("Responder ReceivePacket did not produce SendPacket for KKT response"); } - // State should remain Handshaking until the final message is processed + // Responder transitions to Handshaking after KKT completes assert_eq!( session_manager_2.get_state(lp_id).unwrap(), LpStateBare::Handshaking, - "Responder state should remain Handshaking after processing first packet" // Adjusted assertion + "Responder state should be Handshaking after KKT exchange" ); - // --- Round 3: Initiator Receives, Sends Final, Completes --- + // --- Round 3: Initiator Receives KKT Response, Sends First Noise Message (with PSQ) --- rounds += 1; println!( - " Round {}: Initiator receives, sends final, completes", + " Round {}: Initiator receives KKT response, sends first Noise message (with PSQ)", rounds ); - let packet_to_process = packet_b_to_a.take().expect("Packet from B was missing"); + let packet_to_process = packet_b_to_a + .take() + .expect("KKT response from B was missing"); // Simulate network let mut buf_b = BytesMut::new(); serialize_lp_packet(&packet_to_process, &mut buf_b).unwrap(); let parsed_packet_b = parse_lp_packet(&buf_b).unwrap(); - // Initiator processes + // Initiator processes KKT response let action_a2 = session_manager_1 .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_b)) .expect("Initiator ReceivePacket should produce an action") .expect("Initiator ReceivePacket failed"); - if let LpAction::SendPacket(packet) = action_a2 { - println!(" Initiator received, produced SendPacket (-> s, se)"); - packet_a_to_b = Some(packet); - // Initiator might transition to Transport *after* sending this message - assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), - LpStateBare::Transport, - "Initiator state should be Transport after processing second packet" - ); - // Optional: Check for HandshakeComplete action if process_input returns multiple - } else { - panic!("Initiator ReceivePacket did not produce SendPacket"); + match action_a2 { + LpAction::SendPacket(packet) => { + println!( + " Initiator received KKT response, produced first Noise message (-> e)" + ); + packet_a_to_b = Some(packet); + // Initiator transitions to Handshaking after KKT completes + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::Handshaking, + "Initiator state should be Handshaking after receiving KKT response" + ); + } + LpAction::KKTComplete => { + println!( + " Initiator received KKT response, produced KKTComplete (will send Noise in next step)" + ); + // KKT completed, now need to explicitly trigger handshake message + // This might be the case if KKT completion doesn't automatically send the first Noise message + // Let's try to prepare the handshake message + if let Some(msg_result) = session_manager_1.prepare_handshake_message(lp_id) { + let msg = msg_result.expect("Failed to prepare handshake message after KKT"); + // Create a packet from the message + let packet = create_test_packet(1, lp_id, 0, msg); + packet_a_to_b = Some(packet); + println!(" Prepared first Noise message after KKTComplete"); + } else { + panic!("No handshake message available after KKT complete"); + } + } + other => { + panic!( + "Initiator ReceivePacket produced unexpected action after KKT response: {:?}", + other + ); + } } - // --- Round 4: Responder Receives Final, Completes --- + // --- Round 4: Responder Receives First Noise Message, Sends Second --- rounds += 1; - println!(" Round {}: Responder receives final, completes", rounds); + println!( + " Round {}: Responder receives first Noise message, sends second", + rounds + ); let packet_to_process = packet_a_to_b .take() - .expect("Final packet from A was missing"); + .expect("First Noise packet from A was missing"); // Simulate network let mut buf_a2 = BytesMut::new(); serialize_lp_packet(&packet_to_process, &mut buf_a2).unwrap(); let parsed_packet_a2 = parse_lp_packet(&buf_a2).unwrap(); - // Responder processes + // Responder processes first Noise message and sends second Noise message let action_b2 = session_manager_2 .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a2)) + .expect("Responder ReceivePacket should produce an action") + .expect("Responder ReceivePacket failed"); + + if let LpAction::SendPacket(packet) = action_b2 { + println!( + " Responder received first Noise message, produced second Noise message (<- e, ee, s, es)" + ); + packet_b_to_a = Some(packet); + } else { + panic!("Responder did not produce SendPacket for second Noise message"); + } + // Responder still in Handshaking, waiting for final message + assert_eq!( + session_manager_2.get_state(lp_id).unwrap(), + LpStateBare::Handshaking, + "Responder state should still be Handshaking after sending second message" + ); + + // --- Round 5: Initiator Receives Second Noise Message, Sends Third, Completes --- + rounds += 1; + println!( + " Round {}: Initiator receives second Noise message, sends third, completes", + rounds + ); + let packet_to_process = packet_b_to_a + .take() + .expect("Second Noise packet from B was missing"); + + let mut buf_b2 = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_b2).unwrap(); + let parsed_packet_b2 = parse_lp_packet(&buf_b2).unwrap(); + + let action_a3 = session_manager_1 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_b2)) + .expect("Initiator ReceivePacket should produce an action") + .expect("Initiator ReceivePacket failed"); + + if let LpAction::SendPacket(packet) = action_a3 { + println!( + " Initiator received second Noise message, produced third Noise message (-> s, se)" + ); + packet_a_to_b = Some(packet); + } else { + panic!("Initiator did not produce SendPacket for third Noise message"); + } + // Initiator transitions to Transport after sending third message + assert_eq!( + session_manager_1.get_state(lp_id).unwrap(), + LpStateBare::Transport, + "Initiator state should be Transport after sending third message" + ); + + // --- Round 6: Responder Receives Third Noise Message, Completes --- + rounds += 1; + println!( + " Round {}: Responder receives third Noise message, completes", + rounds + ); + let packet_to_process = packet_a_to_b + .take() + .expect("Third Noise packet from A was missing"); + + let mut buf_a3 = BytesMut::new(); + serialize_lp_packet(&packet_to_process, &mut buf_a3).unwrap(); + let parsed_packet_a3 = parse_lp_packet(&buf_a3).unwrap(); + + let action_b3 = session_manager_2 + .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a3)) .expect("Responder final ReceivePacket should produce an action") .expect("Responder final ReceivePacket failed"); - // Check if the primary action is HandshakeComplete - // The state machine might return HandshakeComplete first, or maybe implicit - if let LpAction::HandshakeComplete = action_b2 { - println!(" Responder received final, produced HandshakeComplete"); + // Responder completes handshake + if let LpAction::HandshakeComplete = action_b3 { + println!(" Responder received third Noise message, produced HandshakeComplete"); } else { - // It might just transition state without an explicit HandshakeComplete action - println!(" Responder received final (Action: {:?})", action_b2); - // Optionally, allow NoOp or other actions if the state transition is the main indicator + println!( + " Responder received third Noise message (Action: {:?})", + action_b3 + ); } assert_eq!( session_manager_2.get_state(lp_id).unwrap(), LpStateBare::Transport, - "Responder state should be Transport after processing final packet" + "Responder state should be Transport after processing third message" ); // --- Verification --- diff --git a/common/nym-lp/src/session_manager.rs b/common/nym-lp/src/session_manager.rs index 540dc9cb99..4baa9f5a3f 100644 --- a/common/nym-lp/src/session_manager.rs +++ b/common/nym-lp/src/session_manager.rs @@ -7,8 +7,8 @@ //! creation, retrieval, and storage of sessions. use dashmap::DashMap; +use nym_crypto::asymmetric::ed25519; -use crate::keypair::{Keypair, PublicKey}; use crate::noise_protocol::ReadResult; use crate::state_machine::{LpAction, LpInput, LpState, LpStateBare}; use crate::{LpError, LpMessage, LpSession, LpStateMachine}; @@ -129,9 +129,7 @@ impl SessionManager { lp_id: u32, message: &LpMessage, ) -> Result { - self.with_state_machine(lp_id, |sm| { - Ok(sm.session()?.process_handshake_message(message)?) - })? + self.with_state_machine(lp_id, |sm| sm.session()?.process_handshake_message(message))? } pub fn session_count(&self) -> usize { @@ -168,12 +166,17 @@ impl SessionManager { pub fn create_session_state_machine( &self, - local_keypair: &Keypair, - remote_public_key: &PublicKey, + local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), + remote_ed25519_key: &ed25519::PublicKey, is_initiator: bool, - psk: &[u8], + salt: &[u8; 32], ) -> Result { - let sm = LpStateMachine::new(is_initiator, local_keypair, remote_public_key, psk)?; + let sm = LpStateMachine::new( + is_initiator, + local_ed25519_keypair, + remote_ed25519_key, + salt, + )?; let sm_id = sm.id()?; self.state_machines.insert(sm_id, sm); @@ -186,21 +189,39 @@ impl SessionManager { removed.is_some() } + + /// Test-only method to initialize KKT state to Completed for a session. + /// This allows integration tests to bypass KKT exchange and directly test PSQ/handshake. + #[cfg(test)] + pub fn init_kkt_for_test( + &self, + lp_id: u32, + remote_x25519_pub: &crate::keypair::PublicKey, + ) -> Result<(), LpError> { + self.with_state_machine(lp_id, |sm| { + sm.session()?.set_kkt_completed_for_test(remote_x25519_pub); + Ok(()) + })? + } } #[cfg(test)] mod tests { use super::*; + use nym_crypto::asymmetric::ed25519; #[test] fn test_session_manager_get() { let manager = SessionManager::new(); + let ed25519_keypair = ed25519::KeyPair::from_secret([10u8; 32], 0); + let salt = [47u8; 32]; + let sm_1_id = manager .create_session_state_machine( - &Keypair::default(), - &PublicKey::default(), + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), true, - &[2u8; 32], + &salt, ) .unwrap(); @@ -214,12 +235,15 @@ mod tests { #[test] fn test_session_manager_remove() { let manager = SessionManager::new(); + let ed25519_keypair = ed25519::KeyPair::from_secret([11u8; 32], 0); + let salt = [48u8; 32]; + let sm_1_id = manager .create_session_state_machine( - &Keypair::default(), - &PublicKey::default(), + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), true, - &[2u8; 32], + &salt, ) .unwrap(); @@ -234,31 +258,44 @@ mod tests { #[test] fn test_multiple_sessions() { let manager = SessionManager::new(); + let ed25519_keypair_1 = ed25519::KeyPair::from_secret([12u8; 32], 0); + let ed25519_keypair_2 = ed25519::KeyPair::from_secret([13u8; 32], 1); + let ed25519_keypair_3 = ed25519::KeyPair::from_secret([14u8; 32], 2); + let salt = [49u8; 32]; let sm_1 = manager .create_session_state_machine( - &Keypair::default(), - &PublicKey::default(), + ( + ed25519_keypair_1.private_key(), + ed25519_keypair_1.public_key(), + ), + ed25519_keypair_1.public_key(), true, - &[2u8; 32], + &salt, ) .unwrap(); let sm_2 = manager .create_session_state_machine( - &Keypair::default(), - &PublicKey::default(), + ( + ed25519_keypair_2.private_key(), + ed25519_keypair_2.public_key(), + ), + ed25519_keypair_2.public_key(), true, - &[2u8; 32], + &salt, ) .unwrap(); let sm_3 = manager .create_session_state_machine( - &Keypair::default(), - &PublicKey::default(), + ( + ed25519_keypair_3.private_key(), + ed25519_keypair_3.public_key(), + ), + ed25519_keypair_3.public_key(), true, - &[2u8; 32], + &salt, ) .unwrap(); @@ -276,12 +313,14 @@ mod tests { #[test] fn test_session_manager_create_session() { let manager = SessionManager::new(); + let ed25519_keypair = ed25519::KeyPair::from_secret([15u8; 32], 0); + let salt = [50u8; 32]; let sm = manager.create_session_state_machine( - &Keypair::default(), - &PublicKey::default(), + (ed25519_keypair.private_key(), ed25519_keypair.public_key()), + ed25519_keypair.public_key(), true, - &[2u8; 32], + &salt, ); assert!(sm.is_ok()); diff --git a/common/nym-lp/src/state_machine.rs b/common/nym-lp/src/state_machine.rs index 95eafe2ee3..27cc5896ca 100644 --- a/common/nym-lp/src/state_machine.rs +++ b/common/nym-lp/src/state_machine.rs @@ -4,14 +4,15 @@ //! Lewes Protocol State Machine for managing connection lifecycle. use crate::{ - keypair::{Keypair, PublicKey}, + LpError, + keypair::{Keypair, PrivateKey as LpPrivateKey, PublicKey as LpPublicKey}, make_lp_id, noise_protocol::NoiseError, packet::LpPacket, session::LpSession, - LpError, }; use bytes::BytesMut; +use nym_crypto::asymmetric::ed25519; use std::mem; /// Represents the possible states of the Lewes Protocol connection. @@ -21,6 +22,10 @@ pub enum LpState { /// State machine is created with keys, lp_id is derived, session is ready. ReadyToHandshake { session: LpSession }, + /// Performing KKT (KEM Key Transfer) exchange before Noise handshake. + /// Initiator requests responder's KEM public key, responder provides signed key. + KKTExchange { session: LpSession }, + /// Actively performing the Noise handshake. /// (We might be able to merge this with ReadyToHandshake if the first step always happens) Handshaking { session: LpSession }, // Kept for now, logic might merge later @@ -37,6 +42,7 @@ pub enum LpState { #[derive(Debug, Clone, PartialEq, Eq)] pub enum LpStateBare { ReadyToHandshake, + KKTExchange, Handshaking, Transport, Closed, @@ -47,6 +53,7 @@ impl From<&LpState> for LpStateBare { fn from(state: &LpState) -> Self { match state { LpState::ReadyToHandshake { .. } => LpStateBare::ReadyToHandshake, + LpState::KKTExchange { .. } => LpStateBare::KKTExchange, LpState::Handshaking { .. } => LpStateBare::Handshaking, LpState::Transport { .. } => LpStateBare::Transport, LpState::Closed { .. } => LpStateBare::Closed, @@ -75,6 +82,8 @@ pub enum LpAction { SendPacket(LpPacket), /// Deliver decrypted application data received from the peer. DeliverData(BytesMut), + /// Inform the environment that KKT exchange completed successfully. + KKTComplete, /// Inform the environment that the handshake is complete. HandshakeComplete, /// Inform the environment that the connection is closed. @@ -94,6 +103,7 @@ impl LpStateMachine { pub fn session(&self) -> Result<&LpSession, LpError> { match &self.state { LpState::ReadyToHandshake { session } + | LpState::KKTExchange { session } | LpState::Handshaking { session } | LpState::Transport { session } => Ok(session), LpState::Closed { .. } => Err(LpError::LpSessionClosed), @@ -107,6 +117,7 @@ impl LpStateMachine { pub fn into_session(self) -> Result { match self.state { LpState::ReadyToHandshake { session } + | LpState::KKTExchange { session } | LpState::Handshaking { session } | LpState::Transport { session } => Ok(session), LpState::Closed { .. } => Err(LpError::LpSessionClosed), @@ -118,44 +129,75 @@ impl LpStateMachine { Ok(self.session()?.id()) } - /// Creates a new state machine, calculates the lp_id, creates the session, - /// and sets the initial state to ReadyToHandshake. + /// Creates a new state machine from Ed25519 keys, internally deriving X25519 keys. /// - /// Requires the local *full* keypair to get the public key for lp_id calculation. + /// This is the primary constructor that accepts only Ed25519 keys (identity/signing keys) + /// and internally derives the X25519 keys needed for Noise protocol and DHKEM. + /// This simplifies the API by hiding the X25519 derivation as an implementation detail. + /// + /// # Arguments + /// + /// * `is_initiator` - Whether this side initiates the handshake + /// * `local_ed25519_keypair` - Ed25519 keypair for PSQ authentication and X25519 derivation + /// (from client identity key or gateway signing key) + /// * `remote_ed25519_key` - Peer's Ed25519 public key for PSQ authentication and X25519 derivation + /// * `salt` - Fresh salt for PSK derivation (must be unique per session) + /// + /// # Errors + /// + /// Returns `LpError::Ed25519RecoveryError` if Ed25519→X25519 conversion fails for the remote key. + /// Local private key conversion cannot fail. pub fn new( is_initiator: bool, - local_keypair: &Keypair, // Use Keypair - remote_public_key: &PublicKey, - psk: &[u8], - // session_manager: Arc // Optional + local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), + remote_ed25519_key: &ed25519::PublicKey, + salt: &[u8; 32], ) -> Result { - // Calculate the shared lp_id// Calculate the shared lp_id - let lp_id = make_lp_id(local_keypair.public_key(), remote_public_key); + // We use standard RFC 7748 conversion to derive X25519 keys from Ed25519 identity keys. + // This allows callers to provide only Ed25519 keys (which they already have for signing/identity) + // without needing to manage separate X25519 keypairs. + // + // Security: Ed25519→X25519 conversion is cryptographically sound (RFC 7748). + // The derived X25519 keys are used for: + // - Noise protocol ephemeral DH + // - PSQ ECDH baseline security (pre-quantum) + // - lp_id calculation (session identifier) - let local_private_key = local_keypair.private_key().to_bytes(); - let remote_public_key = remote_public_key.as_bytes(); + // Convert Ed25519 keys to X25519 for Noise protocol + let local_x25519_private = local_ed25519_keypair.0.to_x25519(); + let local_x25519_public = local_ed25519_keypair + .1 + .to_x25519() + .map_err(LpError::Ed25519RecoveryError)?; - // Create the session immediately + let remote_x25519_public = remote_ed25519_key + .to_x25519() + .map_err(LpError::Ed25519RecoveryError)?; + + // Convert nym_crypto X25519 types to nym_lp keypair types + let lp_private = LpPrivateKey::from_bytes(local_x25519_private.as_bytes()); + let lp_public = LpPublicKey::from_bytes(local_x25519_public.as_bytes())?; + let lp_remote_public = LpPublicKey::from_bytes(remote_x25519_public.as_bytes())?; + + // Create X25519 keypair for Noise and lp_id calculation + let local_x25519_keypair = Keypair::from_keys(lp_private, lp_public); + + // Calculate the shared lp_id using derived X25519 keys + let lp_id = make_lp_id(local_x25519_keypair.public_key(), &lp_remote_public); + + // Create the session with both Ed25519 (for PSQ auth) and derived X25519 keys (for Noise) let session = LpSession::new( lp_id, is_initiator, - &local_private_key, - remote_public_key, - psk, + local_ed25519_keypair, + local_x25519_keypair.private_key(), + remote_ed25519_key, + &lp_remote_public, + salt, )?; - // TODO: Register the session with the SessionManager if applicable - // if let Some(manager) = session_manager { - // manager.insert_session(lp_id, session.clone())?; // Assuming insert_session exists - // } - Ok(LpStateMachine { state: LpState::ReadyToHandshake { session }, - // Store necessary info if needed for recreation, otherwise remove - // is_initiator, - // local_private_key: local_private_key.to_vec(), - // remote_public_key: remote_public_key.to_vec(), - // psk: psk.to_vec(), }) } /// Processes an input event and returns a list of actions to perform. @@ -170,22 +212,30 @@ impl LpStateMachine { // --- ReadyToHandshake State --- (LpState::ReadyToHandshake { session }, LpInput::StartHandshake) => { if session.is_initiator() { - // Initiator sends the first message - match self.start_handshake(&session) { - Some(Ok(action)) => { - result_action = Some(Ok(action)); - LpState::Handshaking { session } // Transition state + // Initiator starts by requesting KEM key via KKT + match session.prepare_kkt_request() { + Some(Ok(kkt_message)) => { + match session.next_packet(kkt_message) { + Ok(kkt_packet) => { + result_action = Some(Ok(LpAction::SendPacket(kkt_packet))); + LpState::KKTExchange { session } // Transition to KKTExchange + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } } Some(Err(e)) => { - // Error occurred, move to Closed state let reason = e.to_string(); result_action = Some(Err(e)); LpState::Closed { reason } } None => { - // Should not happen, treat as internal error + // Should not happen for initiator let err = LpError::Internal( - "start_handshake returned None unexpectedly".to_string(), + "prepare_kkt_request returned None for initiator".to_string(), ); let reason = err.to_string(); result_action = Some(Err(err)); @@ -193,12 +243,116 @@ impl LpStateMachine { } } } else { - // Responder waits for the first message, transition to Handshaking to wait. - LpState::Handshaking { session } + // Responder waits for KKT request + LpState::KKTExchange { session } // No action needed yet, result_action remains None. } } + // --- KKTExchange State --- + (LpState::KKTExchange { session }, LpInput::ReceivePacket(packet)) => { + // Check if packet lp_id matches our session + if packet.header.session_id() != session.id() { + result_action = Some(Err(LpError::UnknownSessionId(packet.header.session_id()))); + LpState::KKTExchange { session } + } else { + use crate::message::LpMessage; + + // Packet message is already parsed, match on it directly + match &packet.message { + LpMessage::KKTRequest(kkt_request) if !session.is_initiator() => { + // Responder processes KKT request + // Convert X25519 public key to KEM format for KKT response + use nym_kkt::ciphersuite::EncapsulationKey; + + // Get local X25519 public key by deriving from private key + let local_x25519_public = session.local_x25519_public(); + + // Convert to libcrux KEM public key + match libcrux_kem::PublicKey::decode( + libcrux_kem::Algorithm::X25519, + local_x25519_public.as_bytes(), + ) { + Ok(libcrux_public_key) => { + let responder_kem_pk = EncapsulationKey::X25519(libcrux_public_key); + + match session.process_kkt_request(&kkt_request.0, &responder_kem_pk) { + Ok(kkt_response_message) => { + match session.next_packet(kkt_response_message) { + Ok(response_packet) => { + result_action = Some(Ok(LpAction::SendPacket(response_packet))); + // After KKT exchange, move to Handshaking + LpState::Handshaking { session } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = format!("Failed to convert X25519 to KEM: {:?}", e); + let err = LpError::Internal(reason.clone()); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + } + LpMessage::KKTResponse(kkt_response) if session.is_initiator() => { + // Initiator processes KKT response (signature-only mode with None) + match session.process_kkt_response(&kkt_response.0, None) { + Ok(()) => { + result_action = Some(Ok(LpAction::KKTComplete)); + // After successful KKT, move to Handshaking + LpState::Handshaking { session } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + _ => { + // Wrong message type for KKT state + let err = LpError::InvalidStateTransition { + state: "KKTExchange".to_string(), + input: format!("Unexpected message type: {:?}", packet.message), + }; + let reason = err.to_string(); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + } + } + + // Reject SendData during KKT exchange + (LpState::KKTExchange { session }, LpInput::SendData(_)) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "KKTExchange".to_string(), + input: "SendData".to_string(), + })); + LpState::KKTExchange { session } + } + + // Reject StartHandshake if already in KKT exchange + (LpState::KKTExchange { session }, LpInput::StartHandshake) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "KKTExchange".to_string(), + input: "StartHandshake".to_string(), + })); + LpState::KKTExchange { session } + } + // --- Handshaking State --- (LpState::Handshaking { session }, LpInput::ReceivePacket(packet)) => { // Check if packet lp_id matches our session @@ -270,7 +424,7 @@ impl LpStateMachine { } Err(e) => { // Error from process_handshake_message let reason = e.to_string(); - result_action = Some(Err(e.into())); + result_action = Some(Err(e)); LpState::Closed { reason } } } @@ -360,9 +514,10 @@ impl LpStateMachine { LpState::Transport { session } } - // --- Close Transition (applies to ReadyToHandshake, Handshaking, Transport) --- + // --- Close Transition (applies to ReadyToHandshake, KKTExchange, Handshaking, Transport) --- ( LpState::ReadyToHandshake { .. } // We consume the session here + | LpState::KKTExchange { .. } | LpState::Handshaking { .. } | LpState::Transport { .. }, LpInput::Close, @@ -421,20 +576,6 @@ impl LpStateMachine { result_action // Return the determined action (or None) } - // Helper to start the handshake (sends first message if initiator) - // Kept as it doesn't mutate self.state - fn start_handshake(&self, session: &LpSession) -> Option> { - session - .prepare_handshake_message() - .map(|result| match result { - Ok(message) => match session.next_packet(message) { - Ok(packet) => Ok(LpAction::SendPacket(packet)), - Err(e) => Err(e), - }, - Err(e) => Err(e), - }) - } - // Helper to prepare an outgoing data packet // Kept as it doesn't mutate self.state fn prepare_data_packet( @@ -452,17 +593,27 @@ impl LpStateMachine { #[cfg(test)] mod tests { use super::*; - use crate::keypair::Keypair; use bytes::Bytes; + use nym_crypto::asymmetric::ed25519; #[test] fn test_state_machine_init() { - let init_key = Keypair::new(); - let resp_key = Keypair::new(); - let psk = vec![0u8; 32]; - let remote_pub_key = resp_key.public_key(); + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([16u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([17u8; 32], 1); - let initiator_sm = LpStateMachine::new(true, &init_key, remote_pub_key, &psk); + // Test salt + let salt = [51u8; 32]; + + let initiator_sm = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ); assert!(initiator_sm.is_ok()); let initiator_sm = initiator_sm.unwrap(); assert!(matches!( @@ -472,7 +623,15 @@ mod tests { let init_session = initiator_sm.session().unwrap(); assert!(init_session.is_initiator()); - let responder_sm = LpStateMachine::new(false, &resp_key, init_key.public_key(), &psk); + let responder_sm = LpStateMachine::new( + false, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ); assert!(responder_sm.is_ok()); let responder_sm = responder_sm.unwrap(); assert!(matches!( @@ -482,73 +641,119 @@ mod tests { let resp_session = responder_sm.session().unwrap(); assert!(!resp_session.is_initiator()); - // Check lp_id is the same - let expected_lp_id = make_lp_id(init_key.public_key(), remote_pub_key); - assert_eq!(init_session.id(), expected_lp_id); - assert_eq!(resp_session.id(), expected_lp_id); + // Check lp_id is the same (derived internally from Ed25519 keys) + // Both state machines should have the same lp_id + assert_eq!(init_session.id(), resp_session.id()); } #[test] fn test_state_machine_simplified_flow() { - // Create test keys - let init_key = Keypair::new(); - let resp_key = Keypair::new(); - let psk = vec![0u8; 32]; + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([18u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([19u8; 32], 1); + + // Test salt + let salt = [52u8; 32]; // Create state machines (already in ReadyToHandshake) let mut initiator = LpStateMachine::new( true, // is_initiator - &init_key, - resp_key.public_key(), - &psk.clone(), + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, ) .unwrap(); let mut responder = LpStateMachine::new( false, // is_initiator - &resp_key, - init_key.public_key(), - &psk, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, ) .unwrap(); let lp_id = initiator.id().unwrap(); assert_eq!(lp_id, responder.id().unwrap()); - // --- Start Handshake --- (No index exchange needed) - println!("--- Step 1: Initiator starts handshake ---"); + // --- KKT Exchange --- + println!("--- Step 1: Initiator starts handshake (sends KKT request) ---"); let init_actions_1 = initiator.process_input(LpInput::StartHandshake); - let init_packet_1 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_1 { + let kkt_request_packet = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_1 { packet.clone() } else { - panic!("Initiator should produce 1 action"); + panic!("Initiator should send KKT request"); }; assert!( - matches!(initiator.state, LpState::Handshaking { .. }), - "Initiator should be Handshaking" + matches!(initiator.state, LpState::KKTExchange { .. }), + "Initiator should be in KKTExchange" ); assert_eq!( - init_packet_1.header.session_id(), + kkt_request_packet.header.session_id(), lp_id, - "Packet 1 has wrong lp_id" + "KKT request packet has wrong lp_id" ); - println!("--- Step 2: Responder starts handshake (waits) ---"); + println!("--- Step 2: Responder starts handshake (waits for KKT) ---"); let resp_actions_1 = responder.process_input(LpInput::StartHandshake); assert!( resp_actions_1.is_none(), "Responder should produce 0 actions initially" ); assert!( - matches!(responder.state, LpState::Handshaking { .. }), - "Responder should be Handshaking" + matches!(responder.state, LpState::KKTExchange { .. }), + "Responder should be in KKTExchange" ); - // --- Handshake Message Exchange --- - println!("--- Step 3: Responder receives packet 1, sends packet 2 ---"); - let resp_actions_2 = responder.process_input(LpInput::ReceivePacket(init_packet_1)); - let resp_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_2 { + println!("--- Step 3: Responder receives KKT request, sends KKT response ---"); + let resp_actions_2 = responder.process_input(LpInput::ReceivePacket(kkt_request_packet)); + let kkt_response_packet = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_2 { + packet.clone() + } else { + panic!("Responder should send KKT response"); + }; + assert!( + matches!(responder.state, LpState::Handshaking { .. }), + "Responder should be Handshaking after KKT" + ); + + println!("--- Step 4: Initiator receives KKT response (KKT complete) ---"); + let init_actions_2 = initiator.process_input(LpInput::ReceivePacket(kkt_response_packet)); + assert!( + matches!(init_actions_2, Some(Ok(LpAction::KKTComplete))), + "Initiator should signal KKT complete" + ); + assert!( + matches!(initiator.state, LpState::Handshaking { .. }), + "Initiator should be Handshaking after KKT" + ); + + // --- Noise Handshake Message Exchange --- + println!("--- Step 5: Responder receives Noise msg 1, sends Noise msg 2 ---"); + // Now both sides are in Handshaking, continue with Noise handshake + // Initiator needs to send first Noise message + // (In real flow, this might happen automatically or via another process_input call) + // For this test, we'll simulate the responder receiving the first Noise message + // Actually, let me check if initiator automatically sends the first Noise message... + // Looking at the old test, it seems packet 1 was the first Noise message. + // With KKT, we need the initiator to send the first Noise message now. + + // Initiator prepares and sends first Noise handshake message + let init_noise_msg = initiator.session().unwrap().prepare_handshake_message(); + let init_packet_1 = if let Some(Ok(msg)) = init_noise_msg { + initiator.session().unwrap().next_packet(msg).unwrap() + } else { + panic!("Initiator should have first Noise message"); + }; + + let resp_actions_3 = responder.process_input(LpInput::ReceivePacket(init_packet_1)); + let resp_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_3 { packet.clone() } else { panic!("Responder should send packet 2"); @@ -563,12 +768,12 @@ mod tests { "Packet 2 has wrong lp_id" ); - println!("--- Step 4: Initiator receives packet 2, sends packet 3 ---"); - let init_actions_2 = initiator.process_input(LpInput::ReceivePacket(resp_packet_2)); - let init_packet_3 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_2 { + println!("--- Step 6: Initiator receives Noise msg 2, sends Noise msg 3 ---"); + let init_actions_3 = initiator.process_input(LpInput::ReceivePacket(resp_packet_2)); + let init_packet_3 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_3 { packet.clone() } else { - panic!("Initiator should send packet 3"); + panic!("Initiator should send Noise packet 3"); }; assert!( matches!(initiator.state, LpState::Transport { .. }), @@ -577,13 +782,13 @@ mod tests { assert_eq!( init_packet_3.header.session_id(), lp_id, - "Packet 3 has wrong lp_id" + "Noise packet 3 has wrong lp_id" ); - println!("--- Step 5: Responder receives packet 3, completes handshake ---"); - let resp_actions_3 = responder.process_input(LpInput::ReceivePacket(init_packet_3)); + println!("--- Step 7: Responder receives Noise msg 3, completes handshake ---"); + let resp_actions_4 = responder.process_input(LpInput::ReceivePacket(init_packet_3)); assert!( - matches!(resp_actions_3, Some(Ok(LpAction::HandshakeComplete))), + matches!(resp_actions_4, Some(Ok(LpAction::HandshakeComplete))), "Responder should complete handshake" ); assert!( @@ -592,58 +797,249 @@ mod tests { ); // --- Transport Phase --- - println!("--- Step 6: Initiator sends data ---"); + println!("--- Step 8: Initiator sends data ---"); let data_to_send_1 = b"hello responder"; - let init_actions_3 = initiator.process_input(LpInput::SendData(data_to_send_1.to_vec())); - let data_packet_1 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_3 { + let init_actions_4 = initiator.process_input(LpInput::SendData(data_to_send_1.to_vec())); + let data_packet_1 = if let Some(Ok(LpAction::SendPacket(packet))) = init_actions_4 { packet.clone() } else { panic!("Initiator should send data packet"); }; assert_eq!(data_packet_1.header.session_id(), lp_id); - println!("--- Step 7: Responder receives data ---"); - let resp_actions_4 = responder.process_input(LpInput::ReceivePacket(data_packet_1)); - let resp_data_1 = if let Some(Ok(LpAction::DeliverData(data))) = resp_actions_4 { + println!("--- Step 9: Responder receives data ---"); + let resp_actions_5 = responder.process_input(LpInput::ReceivePacket(data_packet_1)); + let resp_data_1 = if let Some(Ok(LpAction::DeliverData(data))) = resp_actions_5 { data } else { panic!("Responder should deliver data"); }; assert_eq!(resp_data_1, Bytes::copy_from_slice(data_to_send_1)); - println!("--- Step 8: Responder sends data ---"); + println!("--- Step 10: Responder sends data ---"); let data_to_send_2 = b"hello initiator"; - let resp_actions_5 = responder.process_input(LpInput::SendData(data_to_send_2.to_vec())); - let data_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_5 { + let resp_actions_6 = responder.process_input(LpInput::SendData(data_to_send_2.to_vec())); + let data_packet_2 = if let Some(Ok(LpAction::SendPacket(packet))) = resp_actions_6 { packet.clone() } else { panic!("Responder should send data packet"); }; assert_eq!(data_packet_2.header.session_id(), lp_id); - println!("--- Step 9: Initiator receives data ---"); - let init_actions_4 = initiator.process_input(LpInput::ReceivePacket(data_packet_2)); - if let Some(Ok(LpAction::DeliverData(data))) = init_actions_4 { + println!("--- Step 11: Initiator receives data ---"); + let init_actions_5 = initiator.process_input(LpInput::ReceivePacket(data_packet_2)); + if let Some(Ok(LpAction::DeliverData(data))) = init_actions_5 { assert_eq!(data, Bytes::copy_from_slice(data_to_send_2)); } else { panic!("Initiator should deliver data"); } // --- Close --- - println!("--- Step 10: Initiator closes ---"); - let init_actions_5 = initiator.process_input(LpInput::Close); + println!("--- Step 12: Initiator closes ---"); + let init_actions_6 = initiator.process_input(LpInput::Close); assert!(matches!( - init_actions_5, + init_actions_6, Some(Ok(LpAction::ConnectionClosed)) )); assert!(matches!(initiator.state, LpState::Closed { .. })); - println!("--- Step 11: Responder closes ---"); - let resp_actions_6 = responder.process_input(LpInput::Close); + println!("--- Step 13: Responder closes ---"); + let resp_actions_7 = responder.process_input(LpInput::Close); assert!(matches!( - resp_actions_6, + resp_actions_7, Some(Ok(LpAction::ConnectionClosed)) )); assert!(matches!(responder.state, LpState::Closed { .. })); } + + #[test] + fn test_kkt_exchange_initiator_flow() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([20u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([21u8; 32], 1); + + let salt = [53u8; 32]; + + // Create initiator state machine + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + // Verify initial state + assert!(matches!(initiator.state, LpState::ReadyToHandshake { .. })); + + // Step 1: Initiator starts handshake (should send KKT request) + let init_action = initiator.process_input(LpInput::StartHandshake); + assert!(matches!(init_action, Some(Ok(LpAction::SendPacket(_))))); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + } + + #[test] + fn test_kkt_exchange_responder_flow() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([22u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([23u8; 32], 1); + + let salt = [54u8; 32]; + + // Create responder state machine + let mut responder = LpStateMachine::new( + false, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ) + .unwrap(); + + // Verify initial state + assert!(matches!(responder.state, LpState::ReadyToHandshake { .. })); + + // Step 1: Responder starts handshake (should transition to KKTExchange without sending) + let resp_action = responder.process_input(LpInput::StartHandshake); + assert!(resp_action.is_none()); + assert!(matches!(responder.state, LpState::KKTExchange { .. })); + } + + #[test] + fn test_kkt_exchange_full_roundtrip() { + // Ed25519 keypairs for PSQ authentication and X25519 derivation + let ed25519_keypair_init = ed25519::KeyPair::from_secret([24u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([25u8; 32], 1); + + let salt = [55u8; 32]; + + // Create both state machines + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + let mut responder = LpStateMachine::new( + false, + ( + ed25519_keypair_resp.private_key(), + ed25519_keypair_resp.public_key(), + ), + ed25519_keypair_init.public_key(), + &salt, + ) + .unwrap(); + + // Step 1: Initiator starts handshake, sends KKT request + let init_action = initiator.process_input(LpInput::StartHandshake); + let kkt_request_packet = if let Some(Ok(LpAction::SendPacket(packet))) = init_action { + packet.clone() + } else { + panic!("Initiator should send KKT request"); + }; + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + + // Step 2: Responder transitions to KKTExchange + let resp_action = responder.process_input(LpInput::StartHandshake); + assert!(resp_action.is_none()); + assert!(matches!(responder.state, LpState::KKTExchange { .. })); + + // Step 3: Responder receives KKT request, sends KKT response + let resp_action = responder.process_input(LpInput::ReceivePacket(kkt_request_packet)); + let kkt_response_packet = if let Some(Ok(LpAction::SendPacket(packet))) = resp_action { + packet.clone() + } else { + panic!("Responder should send KKT response"); + }; + // After sending KKT response, responder moves to Handshaking + assert!(matches!(responder.state, LpState::Handshaking { .. })); + + // Step 4: Initiator receives KKT response, completes KKT + let init_action = initiator.process_input(LpInput::ReceivePacket(kkt_response_packet)); + assert!(matches!(init_action, Some(Ok(LpAction::KKTComplete)))); + // After KKT complete, initiator moves to Handshaking + assert!(matches!(initiator.state, LpState::Handshaking { .. })); + } + + #[test] + fn test_kkt_exchange_close() { + // Ed25519 keypairs for KKT authentication + let ed25519_keypair_init = ed25519::KeyPair::from_secret([26u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([27u8; 32], 1); + + let salt = [56u8; 32]; + + // Create initiator state machine + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + // Start handshake to enter KKTExchange state + initiator.process_input(LpInput::StartHandshake); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + + // Close during KKT exchange + let close_action = initiator.process_input(LpInput::Close); + assert!(matches!(close_action, Some(Ok(LpAction::ConnectionClosed)))); + assert!(matches!(initiator.state, LpState::Closed { .. })); + } + + #[test] + fn test_kkt_exchange_rejects_invalid_inputs() { + // Ed25519 keypairs for KKT authentication + let ed25519_keypair_init = ed25519::KeyPair::from_secret([28u8; 32], 0); + let ed25519_keypair_resp = ed25519::KeyPair::from_secret([29u8; 32], 1); + + let salt = [57u8; 32]; + + // Create initiator state machine + let mut initiator = LpStateMachine::new( + true, + ( + ed25519_keypair_init.private_key(), + ed25519_keypair_init.public_key(), + ), + ed25519_keypair_resp.public_key(), + &salt, + ) + .unwrap(); + + // Start handshake to enter KKTExchange state + initiator.process_input(LpInput::StartHandshake); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); + + // Try SendData during KKT exchange (should be rejected) + let send_action = initiator.process_input(LpInput::SendData(vec![1, 2, 3])); + assert!(matches!( + send_action, + Some(Err(LpError::InvalidStateTransition { .. })) + )); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); // Still in KKTExchange + + // Try StartHandshake again during KKT exchange (should be rejected) + let start_action = initiator.process_input(LpInput::StartHandshake); + assert!(matches!( + start_action, + Some(Err(LpError::InvalidStateTransition { .. })) + )); + assert!(matches!(initiator.state, LpState::KKTExchange { .. })); // Still in KKTExchange + } } diff --git a/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts b/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts index 0d50d01f55..dcd95efae6 100644 --- a/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts +++ b/common/types/bindings/ts-packages/types/src/types/rust/NymNodeBond.ts @@ -36,4 +36,9 @@ custom_http_port: number | null, /** * Base58-encoded ed25519 EdDSA public key. */ -identity_key: string, }; +identity_key: string, +/** + * Optional LP (Lewes Protocol) listener address for direct gateway connections. + * Format: "host:port", for example "1.1.1.1:41264" or "gateway.example.com:41264" + */ +lp_address: string | null, }; diff --git a/common/wireguard-types/src/lib.rs b/common/wireguard-types/src/lib.rs index 8f73b40419..455eea38dd 100644 --- a/common/wireguard-types/src/lib.rs +++ b/common/wireguard-types/src/lib.rs @@ -12,3 +12,5 @@ pub use error::Error; pub use public_key::PeerPublicKey; pub const DEFAULT_PEER_TIMEOUT_CHECK: Duration = Duration::from_secs(5); // 5 seconds +pub const DEFAULT_IP_CLEANUP_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes +pub const DEFAULT_IP_STALE_AGE: Duration = Duration::from_secs(3600); // 1 hour diff --git a/common/wireguard/Cargo.toml b/common/wireguard/Cargo.toml index 44a15c0a3c..7d887b7394 100644 --- a/common/wireguard/Cargo.toml +++ b/common/wireguard/Cargo.toml @@ -15,6 +15,9 @@ base64 = { workspace = true } defguard_wireguard_rs = { workspace = true } futures = { workspace = true } ip_network = { workspace = true } +ipnetwork = { workspace = true } +log.workspace = true +rand = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util"] } tokio-stream = { workspace = true } @@ -25,6 +28,7 @@ nym-credential-verification = { path = "../credential-verification" } nym-crypto = { path = "../crypto", features = ["asymmetric"] } nym-gateway-storage = { path = "../gateway-storage" } nym-gateway-requests = { path = "../gateway-requests" } +nym-ip-packet-requests = { path = "../ip-packet-requests" } nym-metrics = { path = "../nym-metrics" } nym-network-defaults = { path = "../network-defaults" } nym-task = { path = "../task" } diff --git a/common/wireguard/src/error.rs b/common/wireguard/src/error.rs index d240889d4a..7f5437d630 100644 --- a/common/wireguard/src/error.rs +++ b/common/wireguard/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { #[error("{0}")] SystemTime(#[from] std::time::SystemTimeError), + + #[error("IP pool error: {0}")] + IpPool(String), } pub type Result = std::result::Result; diff --git a/common/wireguard/src/ip_pool.rs b/common/wireguard/src/ip_pool.rs new file mode 100644 index 0000000000..e1c2b0453f --- /dev/null +++ b/common/wireguard/src/ip_pool.rs @@ -0,0 +1,202 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use ipnetwork::IpNetwork; +use nym_ip_packet_requests::IpPair; +use rand::seq::IteratorRandom; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::RwLock; + +/// Represents the state of an IP allocation +#[derive(Debug, Clone, Copy)] +pub enum AllocationState { + /// IP is available for allocation + Free, + /// IP is allocated and in use, with timestamp of allocation + Allocated(SystemTime), +} + +/// Thread-safe IP address pool manager +/// +/// Manages allocation of IPv4/IPv6 address pairs from configured CIDR ranges. +/// Ensures collision-free allocation and supports stale cleanup. +#[derive(Clone)] +pub struct IpPool { + allocations: Arc>>, +} + +impl IpPool { + /// Create a new IP pool from IPv4 and IPv6 CIDR ranges + /// + /// # Arguments + /// * `ipv4_network` - Base IPv4 address for the pool + /// * `ipv4_prefix` - CIDR prefix length for IPv4 (e.g., 16 for /16) + /// * `ipv6_network` - Base IPv6 address for the pool + /// * `ipv6_prefix` - CIDR prefix length for IPv6 (e.g., 112 for /112) + /// + /// # Errors + /// Returns error if CIDR ranges are invalid + pub fn new( + ipv4_network: Ipv4Addr, + ipv4_prefix: u8, + ipv6_network: Ipv6Addr, + ipv6_prefix: u8, + ) -> Result { + let ipv4_net = IpNetwork::new(ipv4_network.into(), ipv4_prefix)?; + let ipv6_net = IpNetwork::new(ipv6_network.into(), ipv6_prefix)?; + + // Build initial pool with all IPs marked as free + let mut allocations = HashMap::new(); + + // Collect IPv4 and IPv6 addresses into vectors for pairing + let ipv4_addrs: Vec = ipv4_net + .iter() + .filter_map(|ip| { + if let IpAddr::V4(v4) = ip { + Some(v4) + } else { + None + } + }) + .collect(); + + let ipv6_addrs: Vec = ipv6_net + .iter() + .filter_map(|ip| { + if let IpAddr::V6(v6) = ip { + Some(v6) + } else { + None + } + }) + .collect(); + + // Create IpPairs by matching IPv4 and IPv6 addresses + // Use the minimum length to avoid index out of bounds + let pair_count = ipv4_addrs.len().min(ipv6_addrs.len()); + for i in 0..pair_count { + let pair = IpPair::new(ipv4_addrs[i], ipv6_addrs[i]); + allocations.insert(pair, AllocationState::Free); + } + + tracing::info!( + "Initialized IP pool with {} address pairs from {}/{} and {}/{}", + allocations.len(), + ipv4_network, + ipv4_prefix, + ipv6_network, + ipv6_prefix + ); + + Ok(IpPool { + allocations: Arc::new(RwLock::new(allocations)), + }) + } + + /// Allocate a free IP pair from the pool + /// + /// Randomly selects an available IP pair and marks it as allocated. + /// + /// # Errors + /// Returns `IpPoolError::NoFreeIp` if no IPs are available + pub async fn allocate(&self) -> Result { + let mut pool = self.allocations.write().await; + + // Find a free IP and allocate it + let free_ip = pool + .iter_mut() + .filter(|(_, state)| matches!(state, AllocationState::Free)) + .choose(&mut rand::thread_rng()) + .ok_or(IpPoolError::NoFreeIp)?; + + let ip_pair = *free_ip.0; + *free_ip.1 = AllocationState::Allocated(SystemTime::now()); + + tracing::debug!("Allocated IP pair: {}", ip_pair); + Ok(ip_pair) + } + + /// Release an IP pair back to the pool + /// + /// Marks the IP as free for future allocations. + pub async fn release(&self, ip_pair: IpPair) { + let mut pool = self.allocations.write().await; + if let Some(state) = pool.get_mut(&ip_pair) { + *state = AllocationState::Free; + tracing::debug!("Released IP pair: {}", ip_pair); + } + } + + /// Mark an IP pair as allocated (used during initialization from database) + /// + /// This is used when restoring state from the database on gateway startup. + pub async fn mark_used(&self, ip_pair: IpPair) { + let mut pool = self.allocations.write().await; + if let Some(state) = pool.get_mut(&ip_pair) { + *state = AllocationState::Allocated(SystemTime::now()); + tracing::debug!("Marked IP pair as used: {}", ip_pair); + } else { + tracing::warn!("Attempted to mark unknown IP pair as used: {}", ip_pair); + } + } + + /// Get the number of free IPs in the pool + pub async fn free_count(&self) -> usize { + let pool = self.allocations.read().await; + pool.iter() + .filter(|(_, state)| matches!(state, AllocationState::Free)) + .count() + } + + /// Get the number of allocated IPs in the pool + pub async fn allocated_count(&self) -> usize { + let pool = self.allocations.read().await; + pool.iter() + .filter(|(_, state)| matches!(state, AllocationState::Allocated(_))) + .count() + } + + /// Get the total pool size + pub async fn total_count(&self) -> usize { + let pool = self.allocations.read().await; + pool.len() + } + + /// Clean up stale allocations older than the specified duration + /// + /// Returns the number of IPs that were freed + pub async fn cleanup_stale(&self, max_age: std::time::Duration) -> usize { + let mut pool = self.allocations.write().await; + let now = SystemTime::now(); + let mut freed = 0; + + for (_ip, state) in pool.iter_mut() { + if let AllocationState::Allocated(allocated_at) = state + && let Ok(age) = now.duration_since(*allocated_at) + && age > max_age + { + *state = AllocationState::Free; + freed += 1; + } + } + + if freed > 0 { + tracing::info!("Cleaned up {} stale IP allocations", freed); + } + + freed + } +} + +/// Errors that can occur during IP pool operations +#[derive(Debug, thiserror::Error)] +pub enum IpPoolError { + #[error("No free IP addresses available in pool")] + NoFreeIp, + + #[error("Invalid IP network configuration: {0}")] + InvalidNetwork(#[from] ipnetwork::IpNetworkError), +} diff --git a/common/wireguard/src/lib.rs b/common/wireguard/src/lib.rs index 455f16c4f6..7fda2e11c9 100644 --- a/common/wireguard/src/lib.rs +++ b/common/wireguard/src/lib.rs @@ -16,16 +16,23 @@ use tracing::error; #[cfg(target_os = "linux")] use nym_credential_verification::ecash::EcashManager; +#[cfg(target_os = "linux")] +use nym_ip_packet_requests::IpPair; +#[cfg(target_os = "linux")] +use std::net::IpAddr; + #[cfg(target_os = "linux")] use nym_network_defaults::constants::WG_TUN_BASE_NAME; pub mod error; +pub mod ip_pool; pub mod peer_controller; pub mod peer_handle; pub mod peer_storage_manager; pub use error::Error; -pub use peer_controller::PeerControlRequest; +pub use ip_pool::{IpPool, IpPoolError}; +pub use peer_controller::{PeerControlRequest, PeerRegistrationData}; pub const CONTROL_CHANNEL_SIZE: usize = 256; @@ -176,14 +183,16 @@ pub async fn start_wireguard( use base64::{Engine, prelude::BASE64_STANDARD}; use defguard_wireguard_rs::{InterfaceConfiguration, WireguardInterfaceApi}; use ip_network::IpNetwork; - use nym_credential_verification::ecash::traits::EcashManager; use peer_controller::PeerController; use std::collections::HashMap; use tokio::sync::RwLock; use tracing::info; let ifname = String::from(WG_TUN_BASE_NAME); - info!("Initializing WireGuard interface '{}' with use_userspace={}", ifname, use_userspace); + info!( + "Initializing WireGuard interface '{}' with use_userspace={}", + ifname, use_userspace + ); let wg_api = defguard_wireguard_rs::WGApi::new(ifname.clone(), use_userspace)?; let mut peer_bandwidth_managers = HashMap::with_capacity(peers.len()); @@ -207,7 +216,7 @@ pub async fn start_wireguard( prvkey: BASE64_STANDARD.encode(wireguard_data.inner.keypair().private_key().to_bytes()), address: wireguard_data.inner.config().private_ipv4.to_string(), port: wireguard_data.inner.config().announced_tunnel_port as u32, - peers, + peers: peers.clone(), // Clone since we need to use peers later to mark IPs as used mtu: None, }; info!( @@ -260,9 +269,38 @@ pub async fn start_wireguard( let host = wg_api.read_interface_data()?; let wg_api = std::sync::Arc::new(WgApiWrapper::new(wg_api)); + // Initialize IP pool from configuration + info!("Initializing IP pool for WireGuard peer allocation"); + let ip_pool = IpPool::new( + wireguard_data.inner.config().private_ipv4, + wireguard_data.inner.config().private_network_prefix_v4, + wireguard_data.inner.config().private_ipv6, + wireguard_data.inner.config().private_network_prefix_v6, + )?; + + // Mark existing peer IPs as used in the pool + for peer in &peers { + for allowed_ip in &peer.allowed_ips { + // Extract IPv4 and IPv6 from peer's allowed_ips + if let IpAddr::V4(ipv4) = allowed_ip.ip { + // Find corresponding IPv6 + if let Some(ipv6_mask) = peer + .allowed_ips + .iter() + .find(|ip| matches!(ip.ip, IpAddr::V6(_))) + { + if let IpAddr::V6(ipv6) = ipv6_mask.ip { + ip_pool.mark_used(IpPair::new(ipv4, ipv6)).await; + } + } + } + } + } + let mut controller = PeerController::new( ecash_manager, metrics, + ip_pool, wg_api.clone(), host, peer_bandwidth_managers, diff --git a/common/wireguard/src/peer_controller.rs b/common/wireguard/src/peer_controller.rs index aa1fd5b2ed..d4fff10042 100644 --- a/common/wireguard/src/peer_controller.rs +++ b/common/wireguard/src/peer_controller.rs @@ -20,22 +20,73 @@ use nym_credential_verification::{ use nym_credentials_interface::CredentialSpendingData; use nym_gateway_requests::models::CredentialSpendingRequest; use nym_gateway_storage::traits::BandwidthGatewayStorage; +use nym_ip_packet_requests::IpPair; use nym_node_metrics::NymNodeMetrics; -use nym_wireguard_types::DEFAULT_PEER_TIMEOUT_CHECK; +use nym_wireguard_types::{ + DEFAULT_IP_CLEANUP_INTERVAL, DEFAULT_IP_STALE_AGE, DEFAULT_PEER_TIMEOUT_CHECK, +}; use std::{collections::HashMap, sync::Arc}; use std::{ - net::IpAddr, + net::{IpAddr, SocketAddr}, time::{Duration, SystemTime}, }; use tokio::sync::{RwLock, mpsc}; use tokio_stream::{StreamExt, wrappers::IntervalStream}; use tracing::{debug, error, info, trace}; +use crate::{ + error::{Error, Result}, + ip_pool::IpPool, + peer_handle::SharedBandwidthStorageManager, +}; +use crate::{peer_handle::PeerHandle, peer_storage_manager::CachedPeerManager}; + +/// Registration data for a new peer (without pre-allocated IPs) +#[derive(Debug, Clone)] +pub struct PeerRegistrationData { + pub public_key: Key, + pub preshared_key: Option, + pub endpoint: Option, + pub persistent_keepalive_interval: Option, +} + +impl PeerRegistrationData { + pub fn new(public_key: Key) -> Self { + Self { + public_key, + preshared_key: None, + endpoint: None, + persistent_keepalive_interval: None, + } + } + + pub fn with_preshared_key(mut self, key: Key) -> Self { + self.preshared_key = Some(key); + self + } + + pub fn with_endpoint(mut self, endpoint: SocketAddr) -> Self { + self.endpoint = Some(endpoint); + self + } + + pub fn with_keepalive(mut self, interval: u16) -> Self { + self.persistent_keepalive_interval = Some(interval); + self + } +} + pub enum PeerControlRequest { + /// Add a peer with pre-allocated IPs (for backwards compatibility) AddPeer { peer: Peer, response_tx: oneshot::Sender, }, + /// Register a new peer and allocate IPs from the pool + RegisterPeer { + registration_data: PeerRegistrationData, + response_tx: oneshot::Sender, + }, RemovePeer { key: Key, response_tx: oneshot::Sender, @@ -65,6 +116,7 @@ pub enum PeerControlRequest { } pub type AddPeerControlResponse = Result<()>; +pub type RegisterPeerControlResponse = Result; pub type RemovePeerControlResponse = Result<()>; pub type QueryPeerControlResponse = Result>; pub type GetClientBandwidthControlResponse = Result; @@ -77,6 +129,9 @@ pub struct PeerController { // so the overhead is minimal metrics: NymNodeMetrics, + // IP address pool for peer allocation + ip_pool: IpPool, + // used to receive commands from individual handles too request_tx: mpsc::Sender, request_rx: mpsc::Receiver, @@ -84,6 +139,7 @@ pub struct PeerController { host_information: Arc>, bw_storage_managers: HashMap, timeout_check_interval: IntervalStream, + ip_cleanup_interval: IntervalStream, /// Flag indicating whether the system is undergoing an upgrade and thus peers shouldn't be getting /// their bandwidth metered. @@ -96,6 +152,7 @@ impl PeerController { pub(crate) fn new( ecash_verifier: Arc, metrics: NymNodeMetrics, + ip_pool: IpPool, wg_api: Arc, initial_host_information: Host, bw_storage_managers: HashMap, @@ -106,6 +163,8 @@ impl PeerController { ) -> Self { let timeout_check_interval = IntervalStream::new(tokio::time::interval(DEFAULT_PEER_TIMEOUT_CHECK)); + let ip_cleanup_interval = + IntervalStream::new(tokio::time::interval(DEFAULT_IP_CLEANUP_INTERVAL)); let host_information = Arc::new(RwLock::new(initial_host_information)); for (public_key, (bandwidth_storage_manager, peer)) in bw_storage_managers.iter() { let cached_peer_manager = CachedPeerManager::new(peer); @@ -131,15 +190,17 @@ impl PeerController { PeerController { ecash_verifier, + metrics, + ip_pool, wg_api, host_information, bw_storage_managers, request_tx, request_rx, timeout_check_interval, + ip_cleanup_interval, upgrade_mode, shutdown_token, - metrics, } } @@ -231,6 +292,29 @@ impl PeerController { Ok(()) } + /// Allocate IP pair from pool for a new peer registration + /// + /// This only allocates IPs - the caller must handle database storage and + /// then call AddPeer with a complete Peer struct. + async fn handle_register_request( + &mut self, + _registration_data: PeerRegistrationData, + ) -> Result { + nym_metrics::inc!("wg_ip_allocation_attempts"); + + // Allocate IP pair from pool + let ip_pair = self + .ip_pool + .allocate() + .await + .map_err(|e| Error::IpPool(e.to_string()))?; + + nym_metrics::inc!("wg_ip_allocation_success"); + tracing::debug!("Allocated IP pair: {}", ip_pair); + + Ok(ip_pair) + } + async fn ip_to_key(&self, ip: IpAddr) -> Result> { Ok(self .bw_storage_managers @@ -408,6 +492,14 @@ impl PeerController { *self.host_information.write().await = host; } + _ = self.ip_cleanup_interval.next() => { + // Periodically cleanup stale IP allocations + let freed = self.ip_pool.cleanup_stale(DEFAULT_IP_STALE_AGE).await; + if freed > 0 { + nym_metrics::inc_by!("wg_stale_ips_cleaned", freed as u64); + log::info!("Cleaned up {} stale IP allocations", freed); + } + } _ = self.shutdown_token.cancelled() => { trace!("PeerController handler: Received shutdown"); break; @@ -417,6 +509,9 @@ impl PeerController { Some(PeerControlRequest::AddPeer { peer, response_tx }) => { response_tx.send(self.handle_add_request(&peer).await).ok(); } + Some(PeerControlRequest::RegisterPeer { registration_data, response_tx }) => { + response_tx.send(self.handle_register_request(registration_data).await).ok(); + } Some(PeerControlRequest::RemovePeer { key, response_tx }) => { response_tx.send(self.remove_peer(&key).await).ok(); } @@ -543,6 +638,7 @@ pub fn start_controller( Arc>, nym_task::ShutdownManager, ) { + use std::net::{Ipv4Addr, Ipv6Addr}; use std::sync::Arc; let storage = Arc::new(RwLock::new( @@ -552,10 +648,21 @@ pub fn start_controller( Box::new(storage.clone()), )); let wg_api = Arc::new(MockWgApi::default()); + + // Create IP pool for testing + let ip_pool = IpPool::new( + Ipv4Addr::new(10, 0, 0, 0), + 24, + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0), + 112, + ) + .expect("Failed to create IP pool for testing"); + let shutdown_manager = nym_task::ShutdownManager::empty_mock(); let mut peer_controller = PeerController::new( ecash_manager, Default::default(), + ip_pool, wg_api, Default::default(), Default::default(), @@ -577,8 +684,7 @@ pub async fn stop_controller(mut shutdown_manager: nym_task::ShutdownManager) { shutdown_manager.run_until_shutdown().await; } -#[cfg(test)] -#[cfg(feature = "mock")] +#[cfg(all(test, feature = "mock"))] mod tests { use super::*; diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 42e5e1352a..85f258b9c1 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1158,10 +1158,12 @@ version = "0.4.0" dependencies = [ "base64 0.22.1", "bs58", + "curve25519-dalek", "ed25519-dalek", "nym-pemstore", "nym-sphinx-types", "rand", + "sha2", "subtle-encoding", "thiserror 2.0.12", "x25519-dalek", @@ -1795,9 +1797,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index 1508ac1548..ed5af774a0 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -967,6 +967,7 @@ pub mod test_helpers { host: "1.2.3.4".to_string(), custom_http_port: None, identity_key, + lp_address: None, }; let msg = nymnode_bonding_sign_payload(self.deps(), sender, node.clone(), stake); let owner_signature = ed25519_sign_message(msg, keypair.private_key()); diff --git a/contracts/performance/src/testing/mod.rs b/contracts/performance/src/testing/mod.rs index 33e90beaf3..4f943eca75 100644 --- a/contracts/performance/src/testing/mod.rs +++ b/contracts/performance/src/testing/mod.rs @@ -446,6 +446,7 @@ pub(crate) trait PerformanceContractTesterExt: host: "1.2.3.4".to_string(), custom_http_port: None, identity_key, + lp_address: None, }; let cost_params = NodeCostParams { profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) diff --git a/docs/LP_PROTOCOL.md b/docs/LP_PROTOCOL.md new file mode 100644 index 0000000000..de3e5f50be --- /dev/null +++ b/docs/LP_PROTOCOL.md @@ -0,0 +1,990 @@ +# Lewes Protocol (LP) - Technical Specification + +## Overview + +The Lewes Protocol (LP) is a direct TCP-based registration protocol for Nym gateways. It provides an alternative to mixnet-based registration with different trade-offs: lower latency at the cost of revealing client IP to the gateway. + +**Design Goals:** +- **Low latency**: Direct TCP connection vs multi-hop mixnet routing +- **High reliability**: KCP protocol provides ordered, reliable delivery with ARQ +- **Strong security**: Noise XKpsk3 provides mutual authentication and forward secrecy +- **Replay protection**: Bitmap-based counter validation prevents replay attacks +- **Observability**: Prometheus metrics for production monitoring + +**Non-Goals:** +- Network-level anonymity (use mixnet registration for that) +- Persistent connections (LP is registration-only, single-use) +- Backward compatibility with legacy protocols + +## Architecture + +### Protocol Stack + +``` +┌─────────────────────────────────────────┐ +│ Application Layer │ +│ - Registration Requests │ +│ - E-cash Credential Verification │ +│ - WireGuard Peer Management │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ LP Layer (Lewes Protocol) │ +│ - Noise XKpsk3 Handshake │ +│ - Replay Protection (1024-pkt window) │ +│ - Counter-based Sequencing │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ KCP Layer (Reliability) │ +│ - Ordered Delivery │ +│ - ARQ with Selective ACK │ +│ - Congestion Control │ +│ - RTT Estimation │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ TCP Layer │ +│ - Connection Establishment │ +│ - Byte Stream Delivery │ +└─────────────────────────────────────────┘ +``` + +### Why This Layering? + +**TCP**: Provides connection-oriented byte stream and handles network-level retransmission. + +**KCP**: Adds application-level reliability optimized for low latency: +- **Fast retransmit**: Triggered after 2 duplicate ACKs (vs TCP's 3) +- **Selective ACK**: Acknowledges specific packets, not just cumulative +- **Configurable RTO**: Minimum RTO of 100ms (configurable) +- **No Nagle**: Immediate sending for low-latency applications + +**LP**: Provides cryptographic security and session management: +- **Noise XKpsk3**: Mutual authentication with pre-shared key +- **Replay protection**: Prevents duplicate packet acceptance +- **Session isolation**: Each session has unique cryptographic state + +**Application**: Business logic for registration and credential verification. + +## Protocol Flow + +### 1. Connection Establishment + +``` +Client Gateway + | | + |--- TCP SYN ---------------------------> | + |<-- TCP SYN-ACK ------------------------ | + |--- TCP ACK ----------------------------> | + | | +``` + +- **Control Port**: 41264 (default, configurable) +- **Data Port**: 51264 (reserved for future use, not currently used) + +### 2. Session Initialization + +Client generates session parameters: + +```rust +// Client-side session setup +let client_lp_keypair = Keypair::generate(); // X25519 keypair +let gateway_lp_public = gateway.lp_public_key; // From gateway descriptor +let salt = [timestamp (8 bytes) || nonce (24 bytes)]; // 32-byte salt + +// Derive PSK using ECDH + Blake3 KDF +let shared_secret = ECDH(client_private, gateway_public); +let psk = Blake3_derive_key( + context = "nym-lp-psk-v1", + input = shared_secret, + salt = salt +); + +// Calculate session IDs (deterministic from keys) +let lp_id = hash(client_lp_public || 0xCC || gateway_lp_public) & 0xFFFFFFFF; +let kcp_conv_id = hash(client_lp_public || 0xFF || gateway_lp_public) & 0xFFFFFFFF; +``` + +**Session ID Properties:** +- **Deterministic**: Same key pair always produces same ID +- **Order-independent**: `ID(A, B) == ID(B, A)` due to sorted hashing +- **Collision-resistant**: Uses full hash, truncated to u32 +- **Unique per protocol**: Different delimiters (0xCC for LP, 0xFF for KCP) + +### 3. Noise Handshake (XKpsk3 Pattern) + +``` +Client (Initiator) Gateway (Responder) + | | + |--- e ----------------------------------> | [1] Client ephemeral + | | + |<-- e, ee, s, es --------------------- | [2] Gateway ephemeral + static + | | + |--- s, se, psk -------------------------> | [3] Client static + PSK mix + | | + [Transport mode established] +``` + +**Message Contents:** + +**[1] Initiator → Responder: `e`** +- Payload: Client ephemeral public key (32 bytes) +- Encrypted: No (initial message) + +**[2] Responder → Initiator: `e, ee, s, es`** +- `e`: Responder ephemeral public key +- `ee`: Mix ephemeral-ephemeral DH +- `s`: Responder static public key (encrypted) +- `es`: Mix ephemeral-static DH +- Encrypted: Yes (with keys from `ee`) + +**[3] Initiator → Responder: `s, se, psk`** +- `s`: Initiator static public key (encrypted) +- `se`: Mix static-ephemeral DH +- `psk`: Mix pre-shared key (at position 3) +- Encrypted: Yes (with keys from `ee`, `es`) + +**Security Properties:** +- ✅ **Mutual authentication**: Both sides prove identity via static keys +- ✅ **Forward secrecy**: Ephemeral keys provide PFS +- ✅ **PSK authentication**: Binds session to out-of-band PSK +- ✅ **Identity hiding**: Static keys encrypted after first message + +**Handshake Characteristics:** +- **Messages**: 3 (1.5 round trips) +- **Minimum network RTTs**: 1.5 +- **Cryptographic operations**: ECDH, ChaCha20-Poly1305, SHA-256 + +### 4. PSK Derivation Details + +**Formula:** +``` +shared_secret = X25519(client_private_lp, gateway_public_lp) +psk = Blake3_derive_key( + context = "nym-lp-psk-v1", + key_material = shared_secret (32 bytes), + salt = timestamp || nonce (32 bytes) +) +``` + +**Implementation** (from `common/nym-lp/src/psk.rs:48`): +```rust +pub fn derive_psk( + local_private: &PrivateKey, + remote_public: &PublicKey, + salt: &[u8; 32], +) -> [u8; 32] { + let shared_secret = local_private.diffie_hellman(remote_public); + nym_crypto::kdf::derive_key_blake3(PSK_CONTEXT, shared_secret.as_bytes(), salt) +} +``` + +**Why This Design:** + +1. **Identity-bound**: PSK tied to LP keypairs, not ephemeral + - Prevents MITM without LP private key + - Links session to long-term identities + +2. **Session-specific via salt**: Different registrations use different PSKs + - `timestamp`: 8-byte Unix timestamp (milliseconds) + - `nonce`: 24-byte random value + - Prevents PSK reuse across sessions + +3. **Symmetric derivation**: Both sides derive same PSK + - Client: `ECDH(client_priv, gateway_pub)` + - Gateway: `ECDH(gateway_priv, client_pub)` + - Mathematical property: `ECDH(a, B) == ECDH(b, A)` + +4. **Blake3 KDF with domain separation**: + - Context string prevents cross-protocol attacks + - Generates uniform 32-byte output suitable for Noise + +**Salt Transmission:** +- Included in `ClientHello` message (cleartext) +- Gateway extracts salt before deriving PSK +- Timestamp validation rejects stale salts + +### 5. Replay Protection + +**Mechanism: Sliding Window with Bitmap** (from `common/nym-lp/src/replay/validator.rs:32`): + +```rust +const WORD_SIZE: usize = 64; +const N_WORDS: usize = 16; // 1024 bits total +const N_BITS: usize = WORD_SIZE * N_WORDS; // 1024 + +pub struct ReceivingKeyCounterValidator { + next: u64, // Next expected counter + receive_cnt: u64, // Total packets received + bitmap: [u64; 16], // 1024-bit bitmap +} +``` + +**Algorithm:** +``` +For each incoming packet with counter C: + 1. Quick check (branchless): + - If C >= next: Accept (growing) + - If C + 1024 < next: Reject (too old, outside window) + - If bitmap[C % 1024] is set: Reject (duplicate) + - Else: Accept (out-of-order within window) + + 2. After successful processing, mark: + - Set bitmap[C % 1024] = 1 + - If C >= next: Update next = C + 1 + - Increment receive_cnt +``` + +**Performance Optimizations:** + +1. **SIMD-accelerated bitmap operations** (from `common/nym-lp/src/replay/simd/`): + - AVX2 support (x86_64) + - SSE2 support (x86_64) + - NEON support (ARM) + - Scalar fallback (portable) + +2. **Branchless execution** (constant-time): + ```rust + // No early returns - prevents timing attacks + let result = if is_growing { + Some(Ok(())) + } else if too_far_back { + Some(Err(ReplayError::OutOfWindow)) + } else if duplicate { + Some(Err(ReplayError::DuplicateCounter)) + } else { + Some(Ok(())) + }; + result.unwrap() + ``` + +3. **Overflow-safe arithmetic**: + ```rust + let too_far_back = if counter > u64::MAX - 1024 { + false // Can't overflow, so not too far back + } else { + counter + 1024 < self.next + }; + ``` + +**Memory Usage** (verified from `common/nym-lp/src/replay/validator.rs:738`): +```rust +// test_memory_usage() +size = size_of::() * 2 + // next + receive_cnt = 16 bytes + size_of::() * N_WORDS; // bitmap = 128 bytes +// Total: 144 bytes +``` + +### 6. Registration Request + +After handshake completes, client sends encrypted registration request: + +```rust +pub struct RegistrationRequest { + pub mode: RegistrationMode, + pub credential: EcashCredential, + pub gateway_identity: String, +} + +pub enum RegistrationMode { + Dvpn { + wg_public_key: [u8; 32], + }, + Mixnet { + client_id: String, + mix_address: Option, + }, +} +``` + +**Encryption:** +- Encrypted using Noise transport mode +- Includes 16-byte Poly1305 authentication tag +- Replay protection via LP counter + +### 7. Credential Verification + +Gateway verifies the e-cash credential: + +```rust +// Gateway-side verification +pub async fn verify_credential( + &self, + credential: &EcashCredential, +) -> Result { + // 1. Check credential signature (BLS12-381) + verify_blinded_signature(&credential.signature)?; + + // 2. Check credential not already spent (nullifier check) + if self.storage.is_nullifier_spent(&credential.nullifier).await? { + return Err(CredentialError::AlreadySpent); + } + + // 3. Extract bandwidth allocation + let bandwidth_bytes = credential.bandwidth_value; + + // 4. Mark nullifier as spent + self.storage.mark_nullifier_spent(&credential.nullifier).await?; + + Ok(VerifiedCredential { + bandwidth_bytes, + expiry: credential.expiry, + }) +} +``` + +**For dVPN Mode:** +```rust +let peer_config = WireguardPeerConfig { + public_key: request.wg_public_key, + allowed_ips: vec!["10.0.0.0/8"], + bandwidth_limit: verified.bandwidth_bytes, +}; +self.wg_controller.add_peer(peer_config).await?; +``` + +### 8. Registration Response + +```rust +pub enum RegistrationResponse { + Success { + bandwidth_allocated: u64, + expiry: u64, + gateway_data: GatewayData, + }, + Error { + code: ErrorCode, + message: String, + }, +} + +pub enum ErrorCode { + InvalidCredential = 1, + CredentialExpired = 2, + CredentialAlreadyUsed = 3, + InsufficientBandwidth = 4, + WireguardPeerRegistrationFailed = 5, + InternalError = 99, +} +``` + +## State Machine and Security Protocol + +### Protocol Components + +The Lewes Protocol combines three cryptographic protocols for secure, post-quantum resistant communication: + +1. **KKT (KEM Key Transfer)** - Dynamically fetches responder's KEM public key with Ed25519 authentication +2. **PSQ (Post-Quantum Secure PSK)** - Derives PSK using KEM-based protocol for HNDL resistance +3. **Noise XKpsk3** - Provides encrypted transport with mutual authentication and forward secrecy + +### State Machine + +The LP state machine orchestrates the complete protocol flow from connection to encrypted transport: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ LEWES PROTOCOL STATE MACHINE │ +└─────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────┐ + │ ReadyToHandshake │ + │ │ + │ • Keys loaded │ + │ • Session ID set │ + └────────┬─────────┘ + │ + StartHandshake input + │ + ▼ + ┌───────────────────────────────────────┐ + │ KKTExchange │ + │ │ + │ Initiator: │ + │ 1. Send KKT request (signed) │ + │ 2. Receive KKT response │ + │ 3. Validate Ed25519 signature │ + │ 4. Extract KEM public key │ + │ │ + │ Responder: │ + │ 1. Wait for KKT request │ + │ 2. Validate signature │ + │ 3. Send signed KEM key │ + └───────────────┬───────────────────────┘ + │ + KKT Complete + │ + ▼ + ┌───────────────────────────────────────┐ + │ Handshaking │ + │ │ + │ PSQ Protocol: │ + │ 1. Initiator encapsulates PSK │ + │ (embedded in Noise msg 1) │ + │ 2. Responder decapsulates PSK │ + │ (sends ctxt_B in Noise msg 2) │ + │ 3. Both derive final PSK: │ + │ KDF(ECDH || KEM_shared) │ + │ │ + │ Noise XKpsk3 Handshake: │ + │ → msg 1: e, es, ss + PSQ payload │ + │ ← msg 2: e, ee, se + ctxt_B │ + │ → msg 3: s, se (handshake complete) │ + └───────────────┬───────────────────────┘ + │ + Handshake Complete + │ + ▼ + ┌───────────────────────────────────────┐ + │ Transport │ + │ │ + │ • Encrypted data transfer │ + │ • AEAD with ChaCha20-Poly1305 │ + │ • Replay protection (counters) │ + │ • Bidirectional communication │ + └───────────────┬───────────────────────┘ + │ + Close input + │ + ▼ + ┌──────────┐ + │ Closed │ + │ │ + │ • Reason │ + └──────────┘ +``` + +### Message Sequence + +Complete protocol flow from connection to encrypted transport: + +``` +Initiator Responder + │ │ + │ ════════════════ KKT EXCHANGE ════════════════ │ + │ │ + │ KKTRequest (signed with Ed25519) │ + ├──────────────────────────────────────────────────────────>│ + │ │ Validate + │ │ signature + │ KKTResponse (signed KEM key + hash) │ + │<──────────────────────────────────────────────────────────┤ + │ │ + │ Validate signature │ + │ Extract kem_pk │ + │ │ + │ ══════════════ PSQ + NOISE HANDSHAKE ══════════════ │ + │ │ + │ Noise msg 1: e, es, ss │ + │ + PSQ InitiatorMsg (KEM encapsulation) │ + ├──────────────────────────────────────────────────────────>│ + │ │ + │ │ PSQ: Decapsulate + │ │ Derive PSK + │ │ Inject into Noise + │ Noise msg 2: e, ee, se │ + │ + ctxt_B (encrypted PSK) │ + │<──────────────────────────────────────────────────────────┤ + │ │ + │ Extract ctxt_B │ + │ Store for re-registration │ + │ Inject PSK into Noise │ + │ │ + │ Noise msg 3: s, se │ + ├──────────────────────────────────────────────────────────>│ + │ │ + │ Handshake Complete ✓ │ Handshake Complete ✓ + │ Transport mode active │ Transport mode active + │ │ + │ ═══════════════ TRANSPORT MODE ═══════════════ │ + │ │ + │ EncryptedData (AEAD, counter N) │ + ├──────────────────────────────────────────────────────────>│ + │ │ + │ EncryptedData (counter M) │ + │<──────────────────────────────────────────────────────────┤ + │ │ + │ (bidirectional encrypted communication) │ + │◄──────────────────────────────────────────────────────────► + │ │ +``` + +### KKT (KEM Key Transfer) Protocol + +**Purpose**: Securely obtain responder's KEM public key before PSQ can begin. + +**Key Features**: +- Ed25519 signatures for authentication (both request and response signed) +- Optional hash validation for key pinning (future directory service integration) +- Currently signature-only mode (deployable without infrastructure) +- Easy upgrade path to hash-based key pinning + +**Initiator Flow**: +```rust +1. Generate KKT request with Ed25519 signature +2. Send KKTRequest to responder +3. Receive KKTResponse with signed KEM key +4. Validate Ed25519 signature +5. (Optional) Validate key hash against directory +6. Store KEM key for PSQ encapsulation +``` + +**Responder Flow**: +```rust +1. Receive KKTRequest from initiator +2. Validate initiator's Ed25519 signature +3. Generate KKTResponse with: + - Responder's KEM public key + - Ed25519 signature over (key || timestamp) + - Blake3 hash of KEM key +4. Send KKTResponse to initiator +``` + +### PSQ (Post-Quantum Secure PSK) Protocol + +**Purpose**: Derive a post-quantum secure PSK for Noise protocol. + +**Security Properties**: +- **HNDL resistance**: PSK derived from KEM-based protocol +- **Forward secrecy**: Ephemeral KEM keypair per session +- **Authentication**: Ed25519 signatures prevent MitM +- **Algorithm agility**: Easy upgrade from X25519 to ML-KEM + +**PSK Derivation**: +``` +Classical ECDH: + ecdh_secret = X25519_DH(local_private, remote_public) + +KEM Encapsulation (Initiator): + (kem_shared_secret, ciphertext) = KEM.Encap(responder_kem_pk) + +KEM Decapsulation (Responder): + kem_shared_secret = KEM.Decap(kem_private, ciphertext) + +Final PSK: + combined = ecdh_secret || kem_shared_secret || salt + psk = Blake3_KDF("nym-lp-psk-psq-v1", combined) +``` + +**Integration with Noise**: +- PSQ payload embedded in first Noise message (no extra round-trip) +- Responder sends encrypted PSK handle (ctxt_B) in second Noise message +- Both sides inject derived PSK before completing Noise handshake +- Noise validates PSK correctness during handshake + +**PSK Handle (ctxt_B)**: +The responder's encrypted PSK handle allows future re-registration without repeating PSQ: +- Encrypted with responder's long-term key +- Can be presented in future sessions +- Enables fast re-registration for returning clients + +### Security Guarantees + +**Achieved Properties**: +- ✅ **Mutual authentication**: Ed25519 signatures in KKT and PSQ +- ✅ **Forward secrecy**: Ephemeral keys in Noise handshake +- ✅ **Post-quantum PSK**: KEM-based PSK derivation +- ✅ **HNDL resistance**: PSK safe even if private keys compromised later +- ✅ **Replay protection**: Monotonic counters with sliding window +- ✅ **Key confirmation**: Noise handshake validates PSK correctness + +**Implementation Status**: +- 🔄 **Key pinning**: Hash validation via directory service (signature-only for now) +- 🔄 **ML-KEM support**: Easy config upgrade from X25519 to ML-KEM-768 +- 🔄 **PSK re-use**: ctxt_B handle stored for future re-registration + +### Algorithm Choices + +**Current (Testing/Development)**: +- KEM: X25519 (DHKEM) - Classical ECDH, widely tested +- Hash: Blake3 - Fast, secure, parallel +- Signature: Ed25519 - Fast verification, compact +- AEAD: ChaCha20-Poly1305 - Fast, constant-time + +**Future (Production)**: +- KEM: ML-KEM-768 - NIST-approved post-quantum KEM +- Hash: Blake3 - No change needed +- Signature: Ed25519 - No change needed (or upgrade to ML-DSA) +- AEAD: ChaCha20-Poly1305 - No change needed + +**Migration Path**: +```toml +# Current deployment +[lp.crypto] +kem_algorithm = "x25519" + +# Future upgrade (config change only) +[lp.crypto] +kem_algorithm = "ml-kem-768" +``` + +### Message Types + +**KKT Messages**: +```rust +// Message Type 0x0004 +struct KKTRequest { + timestamp: u64, // Unix timestamp (replay protection) + initiator_ed25519_pk: [u8; 32], // Initiator's public key + signature: [u8; 64], // Ed25519 signature +} + +// Message Type 0x0005 +struct KKTResponse { + kem_pk: Vec, // Responder's KEM public key + key_hash: [u8; 32], // Blake3 hash of KEM key + timestamp: u64, // Unix timestamp + signature: [u8; 64], // Ed25519 signature +} +``` + +**PSQ Embedding**: +- PSQ InitiatorMsg embedded in Noise message 1 payload (after 'e, es, ss') +- PSQ ResponderMsg (ctxt_B) embedded in Noise message 2 payload (after 'e, ee, se') +- No additional round-trips beyond standard 3-message Noise handshake + +## KCP Protocol Details + +### KCP Configuration + +From `common/nym-kcp/src/session.rs`: + +```rust +pub struct KcpSession { + conv: u32, // Conversation ID + mtu: usize, // Default: 1400 bytes + snd_wnd: u16, // Send window: 128 segments + rcv_wnd: u16, // Receive window: 128 segments + rx_minrto: u32, // Minimum RTO: 100ms (configurable) +} +``` + +### KCP Packet Format + +``` +┌────────────────────────────────────────────────┐ +│ Conv ID (4 bytes) - Conversation identifier │ +├────────────────────────────────────────────────┤ +│ Cmd (1 byte) - PSH/ACK/WND/ERR │ +├────────────────────────────────────────────────┤ +│ Frg (1 byte) - Fragment number (reverse order) │ +├────────────────────────────────────────────────┤ +│ Wnd (2 bytes) - Receive window size │ +├────────────────────────────────────────────────┤ +│ Timestamp (4 bytes) - Send timestamp │ +├────────────────────────────────────────────────┤ +│ Sequence Number (4 bytes) - Packet sequence │ +├────────────────────────────────────────────────┤ +│ UNA (4 bytes) - Unacknowledged sequence │ +├────────────────────────────────────────────────┤ +│ Length (4 bytes) - Data length │ +├────────────────────────────────────────────────┤ +│ Data (variable) - Payload │ +└────────────────────────────────────────────────┘ +``` + +**Total header**: 24 bytes + +### KCP Features + +**Reliability Mechanisms:** +- **Sequence Numbers (sn)**: Track packet ordering +- **Fragment Numbers (frg)**: Handle message fragmentation +- **UNA (Unacknowledged)**: Cumulative ACK up to this sequence +- **Selective ACK**: Via individual ACK packets +- **Fast Retransmit**: Triggered by duplicate ACKs (configurable threshold) +- **RTO Calculation**: Smoothed RTT with variance + +## LP Packet Format + +### LP Header + +``` +┌────────────────────────────────────────────────┐ +│ Protocol Version (1 byte) - Currently: 1 │ +├────────────────────────────────────────────────┤ +│ Session ID (4 bytes) - LP session identifier │ +├────────────────────────────────────────────────┤ +│ Counter (8 bytes) - Replay protection counter │ +└────────────────────────────────────────────────┘ +``` + +**Total header**: 13 bytes + +### LP Message Types + +```rust +pub enum LpMessage { + Handshake(Vec), + EncryptedData(Vec), + ClientHello { + client_lp_public: [u8; 32], + salt: [u8; 32], + timestamp: u64, + }, + Busy, +} +``` + +### Complete Packet Structure + +``` +┌─────────────────────────────────────┐ +│ LP Header (13 bytes) │ +│ - Version, Session ID, Counter │ +├─────────────────────────────────────┤ +│ LP Message (variable) │ +│ - Type tag (1 byte) │ +│ - Message data │ +├─────────────────────────────────────┤ +│ Trailer (16 bytes) │ +│ - Reserved for future MAC/tag │ +└─────────────────────────────────────┘ +``` + +## Security Properties + +### Threat Model + +**Protected Against:** +- ✅ **Passive eavesdropping**: Noise encryption (ChaCha20-Poly1305) +- ✅ **Active MITM**: Mutual authentication via static keys + PSK +- ✅ **Replay attacks**: Counter-based validation with 1024-packet window +- ✅ **Packet injection**: Poly1305 authentication tags +- ✅ **Timestamp replay**: 30-second window for ClientHello timestamps (configurable) +- ✅ **DoS (connection flood)**: Connection limit (default: 10,000, configurable) +- ✅ **Credential reuse**: Nullifier tracking in database + +**Not Protected Against:** +- ❌ **Network-level traffic analysis**: LP is not anonymous (use mixnet for that) +- ❌ **Gateway compromise**: Gateway sees client registration data +- ⚠️ **Per-IP DoS**: No per-IP rate limiting (global limit only) + +### Cryptographic Primitives + +| Component | Algorithm | Key Size | Source | +|-----------|-----------|----------|--------| +| Key Exchange | X25519 | 256 bits | RustCrypto | +| Encryption | ChaCha20 | 256 bits | RustCrypto | +| Authentication | Poly1305 | 256 bits | RustCrypto | +| KDF | Blake3 | 256 bits | nym_crypto | +| Hash (Noise) | SHA-256 | 256 bits | snow crate | +| Signature (E-cash) | BLS12-381 | 381 bits | E-cash contract | + +### Forward Secrecy + +Noise XKpsk3 provides forward secrecy through ephemeral keys: + +1. **Initial handshake**: Uses ephemeral + static keys +2. **Key compromise scenario**: + - Compromise of **static key**: Past sessions remain secure (ephemeral keys destroyed) + - Compromise of **PSK**: Attacker needs static key too (two-factor security) + - Compromise of **both**: Only future sessions affected, not past + +3. **Session key lifetime**: Destroyed after single registration completes + +### Timing Attack Resistance + +**Constant-time operations:** +- ✅ Replay protection check (branchless) +- ✅ Bitmap bit operations (branchless) +- ✅ Noise crypto operations (via snow/RustCrypto) + +**Variable-time operations:** +- ⚠️ Credential verification (database lookup time varies) +- ⚠️ WireGuard peer registration (filesystem operations) + +## Configuration + +### Gateway Configuration + +From `gateway/src/node/lp_listener/mod.rs:78`: + +```toml +[lp] +# Enable/disable LP listener +enabled = true + +# Bind address +bind_address = "0.0.0.0" + +# Control port (for LP handshake and registration) +control_port = 41264 + +# Data port (reserved for future use) +data_port = 51264 + +# Maximum concurrent connections +max_connections = 10000 + +# Timestamp validation window (seconds) +# ClientHello messages older than this are rejected +timestamp_tolerance_secs = 30 + +# Use mock e-cash verifier (TESTING ONLY!) +use_mock_ecash = false +``` + +### Firewall Rules + +**Required inbound rules:** +```bash +# Allow TCP connections to LP control port +iptables -A INPUT -p tcp --dport 41264 -j ACCEPT + +# Optional: Rate limiting +iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --set --name LP_LIMIT +iptables -A INPUT -p tcp --dport 41264 -m state --state NEW \ + -m recent --update --seconds 60 --hitcount 100 --name LP_LIMIT \ + -j DROP +``` + +## Metrics + +From `gateway/src/node/lp_listener/mod.rs:4`: + +**Connection Metrics:** +- `active_lp_connections`: Gauge tracking current active LP connections +- `lp_connections_total`: Counter for total LP connections handled +- `lp_connection_duration_seconds`: Histogram of connection durations +- `lp_connections_completed_gracefully`: Counter for successful completions +- `lp_connections_completed_with_error`: Counter for error terminations + +**Handshake Metrics:** +- `lp_handshakes_success`: Counter for successful handshakes +- `lp_handshakes_failed`: Counter for failed handshakes +- `lp_handshake_duration_seconds`: Histogram of handshake durations +- `lp_client_hello_failed`: Counter for ClientHello failures + +**Registration Metrics:** +- `lp_registration_attempts_total`: Counter for all registration attempts +- `lp_registration_success_total`: Counter for successful registrations +- `lp_registration_failed_total`: Counter for failed registrations +- `lp_registration_duration_seconds`: Histogram of registration durations + +**Mode-Specific:** +- `lp_registration_dvpn_attempts/success/failed`: dVPN mode counters +- `lp_registration_mixnet_attempts/success/failed`: Mixnet mode counters + +**Credential Metrics:** +- `lp_credential_verification_attempts/success/failed`: Verification counters +- `lp_bandwidth_allocated_bytes_total`: Total bandwidth allocated + +**Error Metrics:** +- `lp_errors_handshake`: Handshake errors +- `lp_errors_timestamp_too_old/too_far_future`: Timestamp validation errors +- `lp_errors_wg_peer_registration`: WireGuard peer registration failures + +## Error Codes + +### Handshake Errors + +| Error | Description | +|-------|-------------| +| `NOISE_DECRYPT_ERROR` | Invalid ciphertext or wrong keys | +| `NOISE_PROTOCOL_ERROR` | Unexpected message or state | +| `REPLAY_DUPLICATE` | Counter already seen | +| `REPLAY_OUT_OF_WINDOW` | Counter outside 1024-packet window | +| `TIMESTAMP_TOO_OLD` | ClientHello > configured tolerance | +| `TIMESTAMP_FUTURE` | ClientHello from future | + +### Registration Errors + +| Code | Name | Description | +|------|------|-------------| +| `CREDENTIAL_INVALID` | Invalid credential | Signature verification failed | +| `CREDENTIAL_EXPIRED` | Credential expired | Past expiry timestamp | +| `CREDENTIAL_SPENT` | Already used | Nullifier already in database | +| `INSUFFICIENT_BANDWIDTH` | Not enough bandwidth | Requested > credential value | +| `WIREGUARD_FAILED` | Peer registration failed | Kernel error adding WireGuard peer | + +## Limitations + +### Current Limitations + +1. **No persistent sessions**: Each registration is independent +2. **Single registration per session**: Connection closes after registration +3. **No streaming**: Protocol is request-response only +4. **No gateway discovery**: Client must know gateway's LP public key beforehand +5. **No version negotiation**: Protocol version fixed at 1 +6. **No per-IP rate limiting**: Only global connection limit + +### Testing Gaps + +1. **No end-to-end integration tests**: Unit tests exist, integration tests pending +2. **No performance benchmarks**: Latency/throughput not measured +3. **No load testing**: Concurrent connection limits not stress-tested +4. **No security audit**: Cryptographic implementation not externally reviewed + +## References + +### Specifications + +- **Noise Protocol Framework**: https://noiseprotocol.org/noise.html +- **XKpsk3 Pattern**: https://noiseexplorer.com/patterns/XKpsk3/ +- **KCP Protocol**: https://github.com/skywind3000/kcp +- **Blake3**: https://github.com/BLAKE3-team/BLAKE3-specs + +### Implementations + +- **snow**: Rust Noise protocol implementation +- **RustCrypto**: Cryptographic primitives (ChaCha20-Poly1305, X25519) +- **tokio**: Async runtime for network I/O + +### Security Audits + +- [ ] Noise implementation audit (pending) +- [ ] Replay protection audit (pending) +- [ ] E-cash integration audit (pending) +- [ ] Penetration testing (pending) + +## Changelog + +### Version 1.1 (Post-Quantum PSK with KKT) + +**Implemented:** +- KKTExchange state in state machine for pre-handshake KEM key transfer +- PSQ (Post-Quantum Secure PSK) protocol integration +- KKT (KEM Key Transfer) protocol with Ed25519 authentication +- Optional hash validation for KEM key pinning (signature-only mode active) +- PSK handle (ctxt_B) storage for future re-registration +- X25519 DHKEM support (ready for ML-KEM upgrade) +- Comprehensive state machine tests (7 test cases) +- generate_fresh_salt() utility for session creation + +**Security Improvements:** +- Post-quantum PSK derivation (KEM-based) +- HNDL (Harvest Now, Decrypt Later) resistance +- Mutual authentication via Ed25519 signatures +- Easy migration path to ML-KEM-768 + +**Architecture:** +- State flow: ReadyToHandshake → KKTExchange → Handshaking → Transport +- PSQ embedded in Noise handshake (no extra round-trip) +- Automatic KKT on StartHandshake (no manual key distribution) + +**Related Issues:** +- nym-4za: Add KKTExchange state to LpStateMachine + +### Version 1.0 (Initial Implementation) + +**Implemented:** +- Noise XKpsk3 handshake +- KCP reliability layer +- Replay protection (1024-packet window with SIMD) +- PSK derivation (ECDH + Blake3) +- dVPN and Mixnet registration modes +- E-cash credential verification +- WireGuard peer management +- Prometheus metrics +- DoS protection (connection limits, timestamp validation) + +**Pending:** +- End-to-end integration tests +- Performance benchmarks +- Security audit +- Client implementation +- Gateway probe support +- Per-IP rate limiting diff --git a/gateway/src/error.rs b/gateway/src/error.rs index dd62de82c7..0e6d4f73bc 100644 --- a/gateway/src/error.rs +++ b/gateway/src/error.rs @@ -146,6 +146,15 @@ pub enum GatewayError { address: String, source: Box, }, + + #[error("Failed to parse ip address: {source}")] + IpAddrParseError { + #[from] + source: defguard_wireguard_rs::net::IpAddrParseError, + }, + + #[error("Invalid SystemTime: {0}")] + InvalidSystemTime(#[from] std::time::SystemTimeError), } impl From for GatewayError { diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 36f57be1f6..3fd487e76a 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -6,10 +6,7 @@ use super::messages::{LpRegistrationRequest, LpRegistrationResponse}; use super::registration::process_registration; use super::LpHandlerState; use crate::error::GatewayError; -use nym_lp::{ - keypair::{Keypair, PrivateKey as LpPrivateKey, PublicKey}, - LpMessage, LpPacket, LpSession, -}; +use nym_lp::{keypair::PublicKey, LpMessage, LpPacket, LpSession}; use nym_metrics::{add_histogram_obs, inc}; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -104,36 +101,14 @@ impl LpConnectionHandler { // Track total LP connections handled inc!("lp_connections_total"); - // For LP, we need: - // 1. Gateway's keypair (from local_identity) - // 2. Client's public key (will be received during handshake) - // 3. PSK (pre-shared key) - for now use a placeholder - - // Derive LP keypair from gateway's ed25519 identity using proper conversion - // This creates a valid x25519 keypair for ECDH operations in Noise protocol - let x25519_private = self.state.local_identity.private_key().to_x25519(); - let x25519_public = self - .state - .local_identity - .public_key() - .to_x25519() - .map_err(|e| { - GatewayError::LpHandshakeError(format!( - "Failed to convert ed25519 public key to x25519: {}", - e - )) - })?; - - let lp_private = LpPrivateKey::from_bytes(x25519_private.as_bytes()); - let lp_public = PublicKey::from_bytes(x25519_public.as_bytes()).map_err(|e| { - GatewayError::LpHandshakeError(format!("Failed to create LP public key: {}", e)) - })?; - - let gateway_keypair = Keypair::from_keys(lp_private, lp_public); + // The state machine now accepts only Ed25519 keys and internally derives X25519 keys. + // This simplifies the API by removing manual key conversion from the caller. + // Gateway's Ed25519 identity is used for both PSQ authentication and X25519 derivation. // Receive client's public key and salt via ClientHello message // The client initiates by sending ClientHello as first packet - let (client_pubkey, salt) = match self.receive_client_hello().await { + let (_client_pubkey, client_ed25519_pubkey, salt) = match self.receive_client_hello().await + { Ok(result) => result, Err(e) => { // Track ClientHello failures (timestamp validation, protocol errors, etc.) @@ -144,13 +119,16 @@ impl LpConnectionHandler { } }; - // Derive PSK using ECDH + Blake3 KDF (nym-109) - // Both client and gateway derive the same PSK from their respective keys - let psk = nym_lp::derive_psk(gateway_keypair.private_key(), &client_pubkey, &salt); - tracing::trace!("Derived PSK from LP keys and ClientHello salt"); - // Create LP handshake as responder - let handshake = LpGatewayHandshake::new_responder(&gateway_keypair, &client_pubkey, &psk)?; + // Pass Ed25519 keys directly - X25519 derivation and PSK generation happen internally + let handshake = LpGatewayHandshake::new_responder( + ( + self.state.local_identity.private_key(), + self.state.local_identity.public_key(), + ), + &client_ed25519_pubkey, + &salt, + )?; // Complete the LP handshake with duration tracking let handshake_start = std::time::Instant::now(); @@ -239,18 +217,9 @@ impl LpConnectionHandler { fn validate_timestamp(client_timestamp: u64, tolerance_secs: u64) -> Result<(), GatewayError> { use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("System time before UNIX epoch") - .as_secs(); - - let age = if now >= client_timestamp { - now - client_timestamp - } else { - // Client timestamp is in the future - client_timestamp - now - }; + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let age = now.abs_diff(client_timestamp); if age > tolerance_secs { let direction = if now >= client_timestamp { "old" @@ -278,7 +247,16 @@ impl LpConnectionHandler { } /// Receive client's public key and salt via ClientHello message - async fn receive_client_hello(&mut self) -> Result<(PublicKey, [u8; 32]), GatewayError> { + async fn receive_client_hello( + &mut self, + ) -> Result< + ( + PublicKey, + nym_crypto::asymmetric::ed25519::PublicKey, + [u8; 32], + ), + GatewayError, + > { // Receive first packet which should be ClientHello let packet = self.receive_lp_packet().await?; @@ -294,25 +272,33 @@ impl LpConnectionHandler { timestamp, { use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("System time before UNIX epoch") - .as_secs(); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); now.abs_diff(timestamp) }, self.state.lp_config.timestamp_tolerance_secs ); - // Convert bytes to PublicKey + // Convert bytes to X25519 PublicKey (for Noise protocol) let client_pubkey = PublicKey::from_bytes(&hello_data.client_lp_public_key) .map_err(|e| { GatewayError::LpProtocolError(format!("Invalid client public key: {}", e)) })?; + // Convert bytes to Ed25519 PublicKey (for PSQ authentication) + let client_ed25519_pubkey = nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( + &hello_data.client_ed25519_public_key, + ) + .map_err(|e| { + GatewayError::LpProtocolError(format!( + "Invalid client Ed25519 public key: {}", + e + )) + })?; + // Extract salt for PSK derivation let salt = hello_data.salt; - Ok((client_pubkey, salt)) + Ok((client_pubkey, client_ed25519_pubkey, salt)) } other => Err(GatewayError::LpProtocolError(format!( "Expected ClientHello, got {}", @@ -484,7 +470,6 @@ mod tests { use crate::node::ActiveClientsStore; use bytes::BytesMut; use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; - use nym_lp::keypair::Keypair; use nym_lp::message::{ClientHelloData, EncryptedDataPayload, HandshakeData, LpMessage}; use nym_lp::packet::{LpHeader, LpPacket}; use std::sync::Arc; @@ -530,7 +515,7 @@ mod tests { ) -> Result<(), std::io::Error> { let mut packet_buf = BytesMut::new(); serialize_lp_packet(packet, &mut packet_buf) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + .map_err(|e| std::io::Error::other(e.to_string()))?; // Write length prefix let len = packet_buf.len() as u32; @@ -557,8 +542,7 @@ mod tests { stream.read_exact(&mut packet_buf).await?; // Parse packet - parse_lp_packet(&packet_buf) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) + parse_lp_packet(&packet_buf).map_err(|e| std::io::Error::other(e.to_string())) } // ==================== Existing Tests ==================== @@ -831,7 +815,9 @@ mod tests { assert_eq!(received.header().session_id, 200); assert_eq!(received.header().counter, 20); match received.message() { - LpMessage::EncryptedData(data) => assert_eq!(data, &EncryptedDataPayload(expected_payload)), + LpMessage::EncryptedData(data) => { + assert_eq!(data, &EncryptedDataPayload(expected_payload)) + } _ => panic!("Expected EncryptedData message"), } } @@ -845,7 +831,8 @@ mod tests { let addr = listener.local_addr().unwrap(); let client_key = [7u8; 32]; - let hello_data = ClientHelloData::new_with_fresh_salt(client_key); + let client_ed25519_key = [8u8; 32]; + let hello_data = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key); let expected_salt = hello_data.salt; // Clone salt before moving hello_data let server_task = tokio::spawn(async move { @@ -901,9 +888,17 @@ mod tests { let mut client_stream = TcpStream::connect(addr).await.unwrap(); // Create and send valid ClientHello - let client_keypair = Keypair::default(); - let hello_data = - ClientHelloData::new_with_fresh_salt(client_keypair.public_key().to_bytes()); + // Create separate Ed25519 keypair and derive X25519 from it (like production code) + use nym_crypto::asymmetric::ed25519; + use rand::rngs::OsRng; + + let client_ed25519_keypair = ed25519::KeyPair::new(&mut OsRng); + let client_x25519_public = client_ed25519_keypair.public_key().to_x25519().unwrap(); + + let hello_data = ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + client_ed25519_keypair.public_key().to_bytes(), + ); let packet = LpPacket::new( LpHeader { protocol_version: 1, @@ -919,10 +914,14 @@ mod tests { // Handler should receive and parse it let result = server_task.await.unwrap(); - assert!(result.is_ok()); + assert!(result.is_ok(), "Expected Ok, got: {:?}", result); - let (pubkey, salt) = result.unwrap(); - assert_eq!(pubkey.as_bytes(), &client_keypair.public_key().to_bytes()); + let (x25519_pubkey, ed25519_pubkey, salt) = result.unwrap(); + assert_eq!(x25519_pubkey.as_bytes(), &client_x25519_public.to_bytes()); + assert_eq!( + ed25519_pubkey.to_bytes(), + client_ed25519_keypair.public_key().to_bytes() + ); assert_eq!(salt, hello_data.salt); } @@ -944,9 +943,17 @@ mod tests { let mut client_stream = TcpStream::connect(addr).await.unwrap(); // Create ClientHello with old timestamp - let client_keypair = Keypair::default(); - let mut hello_data = - ClientHelloData::new_with_fresh_salt(client_keypair.public_key().to_bytes()); + // Use proper separate Ed25519 and X25519 keys (like production code) + use nym_crypto::asymmetric::ed25519; + use rand::rngs::OsRng; + + let client_ed25519_keypair = ed25519::KeyPair::new(&mut OsRng); + let client_x25519_public = client_ed25519_keypair.public_key().to_x25519().unwrap(); + + let mut hello_data = ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + client_ed25519_keypair.public_key().to_bytes(), + ); // Manually set timestamp to be very old (100 seconds ago) let old_timestamp = SystemTime::now() diff --git a/gateway/src/node/lp_listener/handshake.rs b/gateway/src/node/lp_listener/handshake.rs index 935b501303..2e79e83687 100644 --- a/gateway/src/node/lp_listener/handshake.rs +++ b/gateway/src/node/lp_listener/handshake.rs @@ -3,7 +3,6 @@ use crate::error::GatewayError; use nym_lp::{ - keypair::{Keypair, PublicKey}, state_machine::{LpAction, LpInput, LpStateMachine}, LpPacket, LpSession, }; @@ -18,16 +17,24 @@ pub struct LpGatewayHandshake { impl LpGatewayHandshake { /// Create a new responder (gateway side) handshake + /// + /// # Arguments + /// * `gateway_ed25519_keypair` - Gateway's Ed25519 identity keypair (for PSQ auth and X25519 derivation) + /// * `client_ed25519_public_key` - Client's Ed25519 public key (from ClientHello) + /// * `salt` - Salt from ClientHello (for PSK derivation) pub fn new_responder( - local_keypair: &Keypair, - remote_public_key: &PublicKey, - psk: &[u8; 32], + gateway_ed25519_keypair: ( + &nym_crypto::asymmetric::ed25519::PrivateKey, + &nym_crypto::asymmetric::ed25519::PublicKey, + ), + client_ed25519_public_key: &nym_crypto::asymmetric::ed25519::PublicKey, + salt: &[u8; 32], ) -> Result { let state_machine = LpStateMachine::new( false, // responder - local_keypair, - remote_public_key, - psk, + gateway_ed25519_keypair, + client_ed25519_public_key, + salt, ) .map_err(|e| { GatewayError::LpHandshakeError(format!("Failed to create state machine: {}", e)) diff --git a/gateway/src/node/lp_listener/registration.rs b/gateway/src/node/lp_listener/registration.rs index d1a8a80c23..2439721c8b 100644 --- a/gateway/src/node/lp_listener/registration.rs +++ b/gateway/src/node/lp_listener/registration.rs @@ -19,9 +19,6 @@ use nym_gateway_storage::traits::BandwidthGatewayStorage; use nym_metrics::{add_histogram_obs, inc, inc_by}; use nym_registration_common::GatewayData; use nym_wireguard::PeerControlRequest; -use rand::RngCore; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -use std::str::FromStr; use std::sync::Arc; use tracing::*; @@ -77,9 +74,7 @@ async fn credential_storage_preparation( .storage() .get_available_bandwidth(client_id) .await? - .ok_or_else(|| { - GatewayError::InternalError("bandwidth entry should exist".to_string()) - })?; + .ok_or_else(|| GatewayError::InternalError("bandwidth entry should exist".to_string()))?; Ok(bandwidth) } @@ -158,7 +153,6 @@ pub async fn process_registration( // Register as WireGuard peer first to get client_id let (gateway_data, client_id) = match register_wg_peer( request.wg_public_key.inner().as_ref(), - request.client_ip, request.ticket_type, state, ) @@ -223,7 +217,12 @@ pub async fn process_registration( inc!("lp_registration_mixnet_attempts"); // Generate i64 client_id from the [u8; 32] in the request - let client_id = i64::from_be_bytes(client_id_bytes[0..8].try_into().unwrap()); + #[allow(clippy::expect_used)] + let client_id = i64::from_be_bytes( + client_id_bytes[0..8] + .try_into() + .expect("This cannot fail, since the id is 32 bytes long"), + ); info!( "LP Mixnet registration for client_id {}, session {}", @@ -290,7 +289,6 @@ pub async fn process_registration( /// Register a WireGuard peer and return gateway data along with the client_id async fn register_wg_peer( public_key_bytes: &[u8], - client_ip: IpAddr, ticket_type: nym_credentials_interface::TicketType, state: &LpHandlerState, ) -> Result<(GatewayData, i64), GatewayError> { @@ -316,29 +314,47 @@ async fn register_wg_peer( key_bytes.copy_from_slice(public_key_bytes); let peer_key = Key::new(key_bytes); - // Allocate IP addresses for the client - // TODO: Proper IP pool management - for now use random in private range - inc!("wg_ip_allocation_attempts"); - let last_octet = { - let mut rng = rand::thread_rng(); - (rng.next_u32() % 254 + 1) as u8 - }; + // Allocate IPs from centralized pool managed by PeerController + let registration_data = nym_wireguard::PeerRegistrationData::new(peer_key.clone()); - let client_ipv4 = Ipv4Addr::new(10, 1, 0, last_octet); - let client_ipv6 = Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last_octet as u16); - inc!("wg_ip_allocation_success"); + // Request IP allocation from PeerController + let (tx, rx) = oneshot::channel(); + wg_controller + .send(PeerControlRequest::RegisterPeer { + registration_data, + response_tx: tx, + }) + .await + .map_err(|e| { + GatewayError::InternalError(format!("Failed to send IP allocation request: {}", e)) + })?; - // Create WireGuard peer + // Wait for IP allocation from pool + let ip_pair = rx + .await + .map_err(|e| { + GatewayError::InternalError(format!("Failed to receive IP allocation: {}", e)) + })? + .map_err(|e| { + error!("Failed to allocate IPs from pool: {}", e); + GatewayError::InternalError(format!("Failed to allocate IPs: {:?}", e)) + })?; + + let client_ipv4 = ip_pair.ipv4; + let client_ipv6 = ip_pair.ipv6; + + info!( + "Allocated IPs for peer {}: {} / {}", + peer_key, client_ipv4, client_ipv6 + ); + + // Create WireGuard peer with allocated IPs let mut peer = Peer::new(peer_key.clone()); peer.preshared_key = Some(Key::new(state.local_identity.public_key().to_bytes())); - peer.endpoint = Some( - format!("{}:51820", client_ip) - .parse() - .unwrap_or_else(|_| SocketAddr::from_str("0.0.0.0:51820").unwrap()), - ); + peer.endpoint = None; peer.allowed_ips = vec![ - format!("{client_ipv4}/32").parse().unwrap(), - format!("{client_ipv6}/128").parse().unwrap(), + format!("{client_ipv4}/32").parse()?, + format!("{client_ipv6}/128").parse()?, ]; peer.persistent_keepalive_interval = Some(25); @@ -357,7 +373,7 @@ async fn register_wg_peer( // This must happen BEFORE AddPeer because generate_bandwidth_manager() expects it to exist credential_storage_preparation(state.ecash_verifier.clone(), client_id).await?; - // Now send to WireGuard peer controller and track latency + // Now send peer to WireGuard controller and track latency let controller_start = std::time::Instant::now(); let (tx, rx) = oneshot::channel(); wg_controller diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 8e8fffcfcb..1edbdd3e24 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -225,7 +225,9 @@ impl GatewayTasksBuilder { info!("Using MockEcashManager for LP testing (credentials NOT verified)"); let mock_manager = MockEcashManager::new(Box::new(self.storage.clone())); return Ok(Arc::new(mock_manager) - as Arc); + as Arc< + dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync, + >); } // Production path: use real EcashManager with blockchain verification @@ -262,7 +264,9 @@ impl GatewayTasksBuilder { ); Ok(Arc::new(ecash_manager) - as Arc) + as Arc< + dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync, + >) } async fn ecash_manager( @@ -319,7 +323,10 @@ impl GatewayTasksBuilder { active_clients_store: ActiveClientsStore, ) -> Result { // Get WireGuard peer controller if available - let wg_peer_controller = self.wireguard_data.as_ref().map(|wg_data| wg_data.inner.peer_tx().clone()); + let wg_peer_controller = self + .wireguard_data + .as_ref() + .map(|wg_data| wg_data.inner.peer_tx().clone()); let handler_state = lp_listener::LpHandlerState { ecash_verifier: self.ecash_manager().await?, diff --git a/nym-gateway-probe/src/bandwidth_helpers.rs b/nym-gateway-probe/src/bandwidth_helpers.rs index ffb2becf63..1f0048d722 100644 --- a/nym-gateway-probe/src/bandwidth_helpers.rs +++ b/nym-gateway-probe/src/bandwidth_helpers.rs @@ -167,6 +167,7 @@ pub(crate) async fn acquire_bandwidth( /// /// This uses a pre-serialized test credential from the wireguard tests - since MockEcashManager /// doesn't verify anything, any valid CredentialSpendingData structure will work. +#[allow(clippy::expect_used)] // Test helper with hardcoded valid data pub(crate) fn create_dummy_credential( _gateway_identity: &[u8; 32], _ticket_type: TicketType, @@ -233,8 +234,8 @@ pub(crate) fn create_dummy_credential( 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, + 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, ]; diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 6349f03c90..5424386557 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -394,8 +394,10 @@ impl Probe { &NymNetworkDetails::new_from_env(), )?; let client = nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; - let bw_controller = - nym_bandwidth_controller::BandwidthController::new(storage.credential_store().clone(), client); + let bw_controller = nym_bandwidth_controller::BandwidthController::new( + storage.credential_store().clone(), + client, + ); // Run LP registration probe let lp_outcome = lp_registration_probe( @@ -836,41 +838,24 @@ where St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, ::StorageError: Send + Sync, { - use nym_lp::keypair::{Keypair as LpKeypair, PublicKey as LpPublicKey}; + use nym_crypto::asymmetric::ed25519; use nym_registration_client::LpRegistrationClient; - info!("Starting LP registration probe for gateway at {}", gateway_lp_address); + info!( + "Starting LP registration probe for gateway at {}", + gateway_lp_address + ); let mut lp_outcome = types::LpProbeResults::default(); - // Generate LP keypair for this connection - let client_lp_keypair = std::sync::Arc::new(LpKeypair::default()); + // Generate Ed25519 keypair for this connection (X25519 will be derived internally by LP) + let mut rng = rand::thread_rng(); + let client_ed25519_keypair = std::sync::Arc::new(ed25519::KeyPair::new(&mut rng)); - // Derive gateway LP public key from gateway identity using proper ed25519→x25519 conversion - let gateway_x25519_pub = match gateway_identity.to_x25519() { - Ok(key) => key, - Err(e) => { - let error_msg = format!("Failed to convert gateway ed25519 key to x25519: {}", e); - error!("{}", error_msg); - lp_outcome.error = Some(error_msg); - return Ok(lp_outcome); - } - }; - - let gateway_lp_key = match LpPublicKey::from_bytes(gateway_x25519_pub.as_bytes()) { - Ok(key) => key, - Err(e) => { - let error_msg = format!("Failed to create LP key from x25519 bytes: {}", e); - error!("{}", error_msg); - lp_outcome.error = Some(error_msg); - return Ok(lp_outcome); - } - }; - - // Create LP registration client + // Create LP registration client (uses Ed25519 keys directly, derives X25519 internally) let mut client = LpRegistrationClient::new_with_default_psk( - client_lp_keypair, - gateway_lp_key, + client_ed25519_keypair, + gateway_identity, gateway_lp_address, gateway_ip, ); @@ -913,7 +898,9 @@ where let wg_keypair = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng); // Convert gateway identity to ed25519 public key - let gateway_ed25519_pubkey = match nym_crypto::asymmetric::ed25519::PublicKey::from_bytes(&gateway_identity.to_bytes()) { + let gateway_ed25519_pubkey = match nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( + &gateway_identity.to_bytes(), + ) { Ok(key) => key, Err(e) => { let error_msg = format!("Failed to convert gateway identity: {}", e); @@ -932,12 +919,15 @@ where ticket_type, ); - match client.send_registration_request_with_credential( - &wg_keypair, - &gateway_ed25519_pubkey, - credential, - ticket_type, - ).await { + match client + .send_registration_request_with_credential( + &wg_keypair, + &gateway_ed25519_pubkey, + credential, + ticket_type, + ) + .await + { Ok(_) => { info!("LP registration request sent successfully with mock ecash"); } @@ -950,12 +940,15 @@ where } } else { info!("Using real bandwidth controller for LP registration"); - match client.send_registration_request( - &wg_keypair, - &gateway_ed25519_pubkey, - bandwidth_controller, - ticket_type, - ).await { + match client + .send_registration_request( + &wg_keypair, + &gateway_ed25519_pubkey, + bandwidth_controller, + ticket_type, + ) + .await + { Ok(_) => { info!("LP registration request sent successfully with real ecash"); } diff --git a/nym-gateway-probe/src/main.rs b/nym-gateway-probe/src/main.rs index d2237d4328..d5ebc4cdd5 100644 --- a/nym-gateway-probe/src/main.rs +++ b/nym-gateway-probe/src/main.rs @@ -15,6 +15,7 @@ client_defaults!( #[cfg(unix)] #[tokio::main] +#[allow(clippy::exit)] // Intentional exit on error for CLI tool async fn main() -> anyhow::Result<()> { match run::run().await { Ok(ref result) => { @@ -31,6 +32,7 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(unix))] #[tokio::main] +#[allow(clippy::exit)] // Intentional exit for unsupported platform async fn main() -> anyhow::Result<()> { eprintln!("This tool is only supported on Unix systems"); std::process::exit(1) diff --git a/nym-gateway-probe/src/nodes.rs b/nym-gateway-probe/src/nodes.rs index cd67c724bb..84128b15b3 100644 --- a/nym-gateway-probe/src/nodes.rs +++ b/nym-gateway-probe/src/nodes.rs @@ -4,9 +4,9 @@ use crate::TestedNodeDetails; use anyhow::{Context, anyhow, bail}; use nym_api_requests::models::{ - AuthenticatorDetails, DeclaredRoles, DescribedNodeType, HostInformation, - IpPacketRouterDetails, NetworkRequesterDetails, NymNodeData, OffsetDateTimeJsonSchemaWrapper, - WebSockets, WireguardDetails, + AuthenticatorDetails, DeclaredRoles, DescribedNodeType, HostInformation, IpPacketRouterDetails, + NetworkRequesterDetails, NymNodeData, OffsetDateTimeJsonSchemaWrapper, WebSockets, + WireguardDetails, }; use nym_authenticator_requests::AuthenticatorVersion; use nym_bin_common::build_information::BinaryBuildInformationOwned; @@ -166,8 +166,8 @@ pub async fn query_gateway_by_ip(address: String) -> anyhow::Result anyhow::Result = wireguard_result.map(|wg| WireguardDetails { - port: wg.port, - tunnel_port: wg.tunnel_port, - metadata_port: wg.metadata_port, - public_key: wg.public_key, - }); + #[allow(deprecated)] + let wireguard: Option = + wireguard_result.map(|wg| WireguardDetails { + port: wg.tunnel_port, // Use tunnel_port for deprecated port field + tunnel_port: wg.tunnel_port, + metadata_port: wg.metadata_port, + public_key: wg.public_key, + }); // Construct NymNodeData let node_data = NymNodeData { diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index c76b45c96c..f293733d2e 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -4,7 +4,7 @@ use clap::{Parser, Subcommand}; use nym_bin_common::bin_info; use nym_config::defaults::setup_env; -use nym_gateway_probe::nodes::{query_gateway_by_ip, NymApiDirectory}; +use nym_gateway_probe::nodes::{NymApiDirectory, query_gateway_by_ip}; use nym_gateway_probe::{CredentialArgs, NetstackArgs, ProbeResult, TestedNode}; use nym_sdk::mixnet::NodeIdentity; use std::path::Path; @@ -136,10 +136,7 @@ pub(crate) async fn run() -> anyhow::Result { // Still create the directory for potential secondary lookups, // but only if API URL is available - let directory = if let Some(api_url) = network - .endpoints - .first() - .and_then(|ep| ep.api_url()) + let directory = if let Some(api_url) = network.endpoints.first().and_then(|ep| ep.api_url()) { Some(NymApiDirectory::new(api_url).await?) } else { diff --git a/nym-registration-client/src/builder/config.rs b/nym-registration-client/src/builder/config.rs index af73e92b06..6816c1b1e2 100644 --- a/nym-registration-client/src/builder/config.rs +++ b/nym-registration-client/src/builder/config.rs @@ -481,8 +481,8 @@ mod tests { let builder = BuilderConfig::builder(); // Verify the builder returns itself for chaining - let builder = builder.two_hops(true); - let builder = builder.two_hops(false); + let builder = builder.data_path(None); + let builder = builder.data_path(Some("/tmp/test".into())); let builder = builder.data_path(None); // Builder should still fail because required fields are missing diff --git a/nym-registration-client/src/builder/mod.rs b/nym-registration-client/src/builder/mod.rs index 7b90ee8ead..b40afc90fc 100644 --- a/nym-registration-client/src/builder/mod.rs +++ b/nym-registration-client/src/builder/mod.rs @@ -12,10 +12,14 @@ use nym_validator_client::{ QueryHttpRpcNyxdClient, nyxd::{Config as NyxdClientConfig, NyxdClient}, }; +use std::time::Duration; use crate::{RegistrationClient, config::RegistrationClientConfig, error::RegistrationClientError}; use config::BuilderConfig; +/// Timeout for mixnet client startup and connection +const MIXNET_CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + pub(crate) mod config; pub struct RegistrationClientBuilder { @@ -46,7 +50,7 @@ impl RegistrationClientBuilder { let builder = MixnetClientBuilder::new_with_storage(mixnet_client_storage) .event_tx(EventSender(event_tx)); let mixnet_client = tokio::time::timeout( - self.config.mixnet_client_startup_timeout, + MIXNET_CLIENT_STARTUP_TIMEOUT, self.config.build_and_connect_mixnet_client(builder), ) .await??; @@ -56,7 +60,7 @@ impl RegistrationClientBuilder { } else { let builder = MixnetClientBuilder::new_ephemeral().event_tx(EventSender(event_tx)); let mixnet_client = tokio::time::timeout( - self.config.mixnet_client_startup_timeout, + MIXNET_CLIENT_STARTUP_TIMEOUT, self.config.build_and_connect_mixnet_client(builder), ) .await??; diff --git a/nym-registration-client/src/lib.rs b/nym-registration-client/src/lib.rs index ab4c79e3de..05d5ceeb7d 100644 --- a/nym-registration-client/src/lib.rs +++ b/nym-registration-client/src/lib.rs @@ -7,7 +7,6 @@ use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient}; use nym_bandwidth_controller::BandwidthTicketProvider; use nym_credentials_interface::TicketType; use nym_ip_packet_client::IprClientConnect; -use nym_lp::keypair::{Keypair as LpKeypair, PublicKey as LpPublicKey}; use nym_registration_common::AssignedAddresses; use nym_sdk::mixnet::{EventReceiver, MixnetClient, Recipient}; use std::sync::Arc; @@ -53,8 +52,7 @@ impl RegistrationClient { node_id: self.config.exit.node.identity.to_base58_string(), }, )?; - let mut ipr_client = - IprClientConnect::new(self.mixnet_client, self.cancel_token.clone()).await; + let mut ipr_client = IprClientConnect::new(self.mixnet_client, self.cancel_token.clone()); let interface_addresses = ipr_client .connect(ipr_address) .await @@ -125,22 +123,22 @@ impl RegistrationClient { let (entry, exit) = Box::pin(async { tokio::join!(entry_fut, exit_fut) }).await; - let entry = - entry.map_err( - |source| RegistrationClientError::EntryGatewayRegisterWireguard { - gateway_id: self.config.entry.node.identity.to_base58_string(), - authenticator_address: Box::new(entry_auth_address), - source: Box::new(source), - }, - )?; - let exit = - exit.map_err( - |source| RegistrationClientError::ExitGatewayRegisterWireguard { - gateway_id: self.config.exit.node.identity.to_base58_string(), - authenticator_address: Box::new(exit_auth_address), - source: Box::new(source), - }, - )?; + let entry = entry.map_err(|source| { + RegistrationClientError::from_authenticator_error( + source, + self.config.entry.node.identity.to_base58_string(), + entry_auth_address, + true, // is entry + ) + })?; + let exit = exit.map_err(|source| { + RegistrationClientError::from_authenticator_error( + source, + self.config.exit.node.identity.to_base58_string(), + exit_auth_address, + false, // is exit (not entry) + ) + })?; Ok(RegistrationResult::Wireguard(Box::new( WireguardRegistrationResult { @@ -171,49 +169,12 @@ impl RegistrationClient { tracing::debug!("Entry gateway LP address: {}", entry_lp_address); tracing::debug!("Exit gateway LP address: {}", exit_lp_address); - // Convert gateway ed25519 identities to x25519 LP public keys using proper conversion - let entry_x25519_pub = self.config.entry.node.identity.to_x25519().map_err(|e| { - RegistrationClientError::LpRegistrationNotPossible { - node_id: format!( - "{}: failed to convert ed25519 to x25519: {}", - self.config.entry.node.identity.to_base58_string(), - e - ), - } - })?; - - let entry_gateway_lp_key = LpPublicKey::from_bytes(entry_x25519_pub.as_bytes()).map_err(|e| { - RegistrationClientError::LpRegistrationNotPossible { - node_id: format!( - "{}: invalid LP key: {}", - self.config.entry.node.identity.to_base58_string(), - e - ), - } - })?; - - let exit_x25519_pub = self.config.exit.node.identity.to_x25519().map_err(|e| { - RegistrationClientError::LpRegistrationNotPossible { - node_id: format!( - "{}: failed to convert ed25519 to x25519: {}", - self.config.exit.node.identity.to_base58_string(), - e - ), - } - })?; - - let exit_gateway_lp_key = LpPublicKey::from_bytes(exit_x25519_pub.as_bytes()).map_err(|e| { - RegistrationClientError::LpRegistrationNotPossible { - node_id: format!( - "{}: invalid LP key: {}", - self.config.exit.node.identity.to_base58_string(), - e - ), - } - })?; - - // Generate LP keypairs for this connection - let client_lp_keypair = Arc::new(LpKeypair::default()); + // Generate fresh Ed25519 keypairs for LP registration + // These are ephemeral and used only for the LP handshake protocol + use nym_crypto::asymmetric::ed25519; + use rand::rngs::OsRng; + let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); + let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); // Register entry gateway via LP let entry_fut = { @@ -221,12 +182,12 @@ impl RegistrationClient { let entry_keys = self.config.entry.keys.clone(); let entry_identity = self.config.entry.node.identity; let entry_ip = self.config.entry.node.ip_address; - let lp_keypair = client_lp_keypair.clone(); + let entry_lp_keys = entry_lp_keypair.clone(); async move { let mut client = LpRegistrationClient::new_with_default_psk( - lp_keypair, - entry_gateway_lp_key, + entry_lp_keys, + entry_identity, entry_lp_address, entry_ip, ); @@ -263,12 +224,12 @@ impl RegistrationClient { let exit_keys = self.config.exit.keys.clone(); let exit_identity = self.config.exit.node.identity; let exit_ip = self.config.exit.node.ip_address; - let lp_keypair = client_lp_keypair; + let exit_lp_keys = exit_lp_keypair; async move { let mut client = LpRegistrationClient::new_with_default_psk( - lp_keypair, - exit_gateway_lp_key, + exit_lp_keys, + exit_identity, exit_lp_address, exit_ip, ); diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index 6f1e665d8e..e541a58766 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -12,7 +12,6 @@ use nym_credentials_interface::{CredentialSpendingData, TicketType}; use nym_crypto::asymmetric::{ed25519, x25519}; use nym_lp::LpPacket; use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; -use nym_lp::keypair::{Keypair, PublicKey}; use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; use nym_wireguard_types::PeerPublicKey; @@ -41,11 +40,11 @@ pub struct LpRegistrationClient { /// Created during `connect()`, None before connection is established. tcp_stream: Option, - /// Client's LP keypair for Noise protocol. - local_keypair: Arc, + /// Client's Ed25519 identity keypair (used for PSQ authentication and X25519 derivation). + local_ed25519_keypair: Arc, - /// Gateway's public key for Noise protocol. - gateway_public_key: PublicKey, + /// Gateway's Ed25519 public key (from directory/discovery). + gateway_ed25519_public_key: ed25519::PublicKey, /// Gateway LP listener address (host:port, e.g., "1.1.1.1:41264"). gateway_lp_address: SocketAddr, @@ -65,8 +64,8 @@ impl LpRegistrationClient { /// Creates a new LP registration client. /// /// # Arguments - /// * `local_keypair` - Client's LP keypair for Noise protocol - /// * `gateway_public_key` - Gateway's public key + /// * `local_ed25519_keypair` - Client's Ed25519 identity keypair (for PSQ auth and X25519 derivation) + /// * `gateway_ed25519_public_key` - Gateway's Ed25519 public key (from directory/discovery) /// * `gateway_lp_address` - Gateway's LP listener socket address /// * `client_ip` - Client IP address for registration /// * `config` - Configuration for timeouts and TCP parameters (use `LpConfig::default()`) @@ -74,18 +73,18 @@ impl LpRegistrationClient { /// # Note /// This creates the client but does not establish the connection. /// Call `connect()` to establish the TCP connection. - /// PSK is derived automatically during handshake using ECDH + Blake3 KDF (nym-109). + /// PSK is derived automatically during handshake inside the state machine. pub fn new( - local_keypair: Arc, - gateway_public_key: PublicKey, + local_ed25519_keypair: Arc, + gateway_ed25519_public_key: ed25519::PublicKey, gateway_lp_address: SocketAddr, client_ip: IpAddr, config: LpConfig, ) -> Self { Self { tcp_stream: None, - local_keypair, - gateway_public_key, + local_ed25519_keypair, + gateway_ed25519_public_key, gateway_lp_address, state_machine: None, client_ip, @@ -96,23 +95,23 @@ impl LpRegistrationClient { /// Creates a new LP registration client with default configuration. /// /// # Arguments - /// * `local_keypair` - Client's LP keypair for Noise protocol - /// * `gateway_public_key` - Gateway's public key + /// * `local_ed25519_keypair` - Client's Ed25519 identity keypair + /// * `gateway_ed25519_public_key` - Gateway's Ed25519 public key /// * `gateway_lp_address` - Gateway's LP listener socket address /// * `client_ip` - Client IP address for registration /// /// Uses default config (LpConfig::default()) with sane timeout and TCP parameters. - /// PSK is derived automatically during handshake using ECDH + Blake3 KDF (nym-109). + /// PSK is derived automatically during handshake inside the state machine. /// For custom config, use `new()` directly. pub fn new_with_default_psk( - local_keypair: Arc, - gateway_public_key: PublicKey, + local_ed25519_keypair: Arc, + gateway_ed25519_public_key: ed25519::PublicKey, gateway_lp_address: SocketAddr, client_ip: IpAddr, ) -> Self { Self::new( - local_keypair, - gateway_public_key, + local_ed25519_keypair, + gateway_ed25519_public_key, gateway_lp_address, client_ip, LpConfig::default(), @@ -232,9 +231,20 @@ impl LpRegistrationClient { tracing::debug!("Starting LP handshake as initiator"); - // Step 1: Generate ClientHelloData with fresh salt (timestamp + nonce) + // Step 1: Derive X25519 keys from Ed25519 for Noise protocol (internal to ClientHello) + // The Ed25519 keys are used for PSQ authentication and also converted to X25519 + let client_x25519_public = self + .local_ed25519_keypair + .public_key() + .to_x25519() + .map_err(|e| { + LpClientError::Crypto(format!("Failed to derive X25519 public key: {}", e)) + })?; + + // Step 2: Generate ClientHelloData with fresh salt and both public keys let client_hello_data = nym_lp::ClientHelloData::new_with_fresh_salt( - self.local_keypair.public_key().to_bytes(), + client_x25519_public.to_bytes(), + self.local_ed25519_keypair.public_key().to_bytes(), ); let salt = client_hello_data.salt; @@ -243,7 +253,7 @@ impl LpRegistrationClient { client_hello_data.extract_timestamp() ); - // Step 2: Send ClientHello as first packet (before Noise handshake) + // Step 3: Send ClientHello as first packet (before Noise handshake) let client_hello_header = nym_lp::packet::LpHeader::new( 0, // session_id not yet established 0, // counter starts at 0 @@ -255,20 +265,16 @@ impl LpRegistrationClient { Self::send_packet(stream, &client_hello_packet).await?; tracing::debug!("Sent ClientHello packet"); - // Step 3: Derive PSK using ECDH + Blake3 KDF - let psk = nym_lp::derive_psk( - self.local_keypair.private_key(), - &self.gateway_public_key, - &salt, - ); - tracing::trace!("Derived PSK from identity keys and salt"); - - // Step 4: Create state machine as initiator with derived PSK + // Step 4: Create state machine as initiator with Ed25519 keys + // PSK derivation happens internally in the state machine constructor let mut state_machine = LpStateMachine::new( true, // is_initiator - &self.local_keypair, - &self.gateway_public_key, - &psk, + ( + self.local_ed25519_keypair.private_key(), + self.local_ed25519_keypair.public_key(), + ), + &self.gateway_ed25519_public_key, + &salt, )?; // Start handshake - client (initiator) sends first @@ -311,6 +317,21 @@ impl LpRegistrationClient { tracing::info!("LP handshake completed successfully"); break; } + LpAction::KKTComplete => { + tracing::info!("KKT exchange completed, starting Noise handshake"); + // After KKT completes, initiator must send first Noise handshake message + let noise_msg = state_machine + .session()? + .prepare_handshake_message() + .ok_or_else(|| { + LpClientError::Transport( + "No handshake message available after KKT".to_string(), + ) + })??; + let noise_packet = state_machine.session()?.next_packet(noise_msg)?; + tracing::trace!("Sending first Noise handshake message"); + Self::send_packet(stream, &noise_packet).await?; + } other => { tracing::trace!("Received action during handshake: {:?}", other); } @@ -764,8 +785,9 @@ mod tests { #[test] fn test_client_creation() { - let keypair = Arc::new(Keypair::default()); - let gateway_key = PublicKey::default(); + let mut rng = rand::thread_rng(); + let keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + let gateway_key = *ed25519::KeyPair::new(&mut rng).public_key(); let address = "127.0.0.1:41264".parse().unwrap(); let client_ip = "192.168.1.100".parse().unwrap(); diff --git a/nym-registration-client/src/lp_client/config.rs b/nym-registration-client/src/lp_client/config.rs index 2ac695be8f..0d18a0299a 100644 --- a/nym-registration-client/src/lp_client/config.rs +++ b/nym-registration-client/src/lp_client/config.rs @@ -87,7 +87,7 @@ mod tests { assert_eq!(config.connect_timeout, Duration::from_secs(10)); assert_eq!(config.handshake_timeout, Duration::from_secs(15)); assert_eq!(config.registration_timeout, Duration::from_secs(30)); - assert_eq!(config.tcp_nodelay, true); + assert!(config.tcp_nodelay); assert_eq!(config.tcp_keepalive, None); } diff --git a/nym-registration-client/src/lp_client/error.rs b/nym-registration-client/src/lp_client/error.rs index 7b18b9eee1..20633a6dbc 100644 --- a/nym-registration-client/src/lp_client/error.rs +++ b/nym-registration-client/src/lp_client/error.rs @@ -53,6 +53,10 @@ pub enum LpClientError { /// Timeout waiting for response #[error("Timeout waiting for {operation}")] Timeout { operation: String }, + + /// Cryptographic operation failed + #[error("Cryptographic error: {0}")] + Crypto(String), } pub type Result = std::result::Result; diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 4f910e1a15..014cf185f9 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4289,6 +4289,7 @@ version = "0.4.0" dependencies = [ "base64 0.22.1", "bs58", + "curve25519-dalek", "ed25519-dalek", "jwt-simple", "nym-pemstore", @@ -4296,6 +4297,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_bytes", + "sha2 0.10.9", "subtle-encoding", "thiserror 2.0.12", "x25519-dalek",