Compare commits

...

24 Commits

Author SHA1 Message Date
Jędrzej Stuczyński 7c0babf35a LP: x25519/ed22519 cleanup round (#6335)
* removed dependency on nymsphinx::* key types and removed needless copies of ed25519 keys

* use more strongly types in ClientHelloData

* explicitly use provided client's x25519 from ClientHelloData

this requires adjusting LpSession constructor to take an additional key argument

* allow large LpInput enum

* clippy within tests

* removed redundant type aliases for x25519 keys
2026-01-16 16:37:53 +00:00
Andrej Mihajlov b6f234259c Upgrade to def_guard_wireguard v0.8.0 (#6315)
* Upgrade to def_guard_wireguard v0.8.0

* Update nix, netlink-packet-wireguard

* Adapt linux code for defguard_wireguard

* rustfmt

* Revert nix to 0.27.1

* clippy: fix

* fix from rebase

* Restore userspace imp on condition

* Add send+sync on boxed wgapi

* Use error to indicate when userspace/kernel imps are unavailable; userspace is not available on all platforms

* Remove duplicate import

---------

Co-authored-by: mfahampshire <maxhampshire@pm.me>
2026-01-16 11:29:21 +00:00
Jędrzej Stuczyński 7d8d1e9d6d Lp/encrypted kkt (#6331)
* enable encryption - kkt

* integrate encrypted kkt into nym-lp

* chore: remove unused imports

* chore: remove magic constants from KKTContext

* fixed KKT exchange

* use more strict typing for KKTFrame fields

* removed recursive error conversion

* removed needless borrow

* restored kkt tests

* fixed KKT benchmarks compilation

---------

Co-authored-by: Georgio Nicolas <me@georgio.xyz>
2026-01-16 10:11:49 +00:00
Jędrzej Stuczyński 3b75af34e8 ensure packets with incompatible versions are rejected (#6326) 2026-01-16 08:58:20 +00:00
Jędrzej Stuczyński 1a3c1fa466 standarise lp serialisation: (#6324)
* standarise lp serialisation:
- stop using bincode within `LpMessage` in favour of predictable bytes concatenation
- use consistent encode/decode interface for every `LpMessage` inner variant
- hide usage of bincode within `LpRegistrationResponse` / `LpRegistrationResponse` behind `serialise` / `try_deserialise` interface

* reduced 'target_lp_address' len encoding space from u32 to u16
2026-01-16 08:58:10 +00:00
benedetta davico 6ff981ecce Merge pull request #6333 from nymtech/master
Keep branches synced
2026-01-16 09:58:05 +01:00
benedetta davico 7a9a04d846 Merge pull request #6238 from YichiZhang0613/fix_assertion
fix: fix assertion
2026-01-15 15:31:01 +01:00
benedetta davico 64b971b1b9 Merge pull request #6329 from nymtech/merge/release/2026.1-niolo
release/2026.1-niolo to develop
2026-01-15 15:26:14 +01:00
benedetta davico 62fc6d8902 Merge pull request #6328 from nymtech/release/2026.1-niolo
release/2026.1-niolo to master
2026-01-15 14:51:51 +01:00
Jędrzej Stuczyński de7a082e58 Merge branch 'develop' into merge/release/2026.1-niolo 2026-01-15 13:47:20 +00:00
import this 877d4d68c9 Feature: NTM open SMTP + add rate limit fn & [DOCs/operators]: Release updates niolo (#6317)
* initialise smtp rate limit

* simplify

* remove duplicate hooks

* fix ordering

* ntm finalized

* add changelog for niolo

* bump up version

* correct nym buy info

* update stats

* fix typo

* fix typo

* ready to merge

* PR finished
2026-01-15 10:09:59 +00:00
Drazen Urch 8a00ed6071 LP Registration + Telescoping + Gateway Probe Localnet Mode (#6286)
* Add KKT cryptographic primitives

Post-quantum Key Encapsulation Mechanism (KEM) Key Transfer protocol.
Enables efficient distribution of post-quantum KEM public keys.

Squashed from georgio/noise-psq branch.

* Implement LP registration protocol with KKT/PSQ integration

Initial implementation of the Lewes Protocol (LP) for gateway registration:
- Add nym-lp crate with Noise protocol handshake
- Add LP listener to gateway for handling registrations
- Add LP client for registration flow
- Integrate KKT for post-quantum KEM key exchange
- Integrate PSQ for post-quantum PSK derivation
- Add Ed25519 authentication throughout
- Add docker/localnet support for testing

Co-authored-by: Jędrzej Stuczyński <jedrzej.stuczynski@gmail.com>

* Add LP telescoping with nested sessions and subsession support

Extends LP protocol with telescoping architecture for nested sessions:
- Add nested session support with KKpsk0 rekeying
- Add subsession support with collision detection
- Implement unified packet format with outer header
- Refactor gateway handlers for single-packet forwarding
- Add TTL-based state cleanup for stale sessions
- Add outer AEAD encryption layer
- Refactor registration client for packet-per-connection model

* Add gateway-probe localnet mode with WireGuard tunnel support

Adds localnet testing mode to gateway-probe for LP development:
- Add TestMode enum for different probe configurations
- Add --gateway-ip flag for direct gateway testing
- Implement two-hop WireGuard tunnel for localnet
- Add mock ecash support for testing without real credentials
- Add netstack Go bindings for userspace networking
- Restructure probe with mode and common modules
- Update README with localnet mode documentation

* Increase KCP fragment limit from u8 to u16

- Change frg field from u8 to u16 in packet header (25 bytes total)
- Update encode/decode to use get_u16_le/put_u16_le
- Update Segment struct frg field to u16
- Remove truncating cast in session.rs
- Max message size now ~91MB (65,535 fragments × MTU)
- Internal protocol only, no interop concerns

Nym uses KCP for reliability and multiplexing, not standard real-time
use cases. The u8 limit (255 fragments, ~355KB) was insufficient.

Addresses: nym-yih9

* Zeroize Ed25519 key material in to_x25519 conversion

Wrap hash and x25519_bytes in zeroize::Zeroizing to ensure private
key material is cleared from memory after use.

Closes: nym-k55g

* Return Result from KCP session input() for error detection

Change KcpSession::input() to return Result<(), KcpError> so callers
can detect invalid packets instead of silently ignoring them.

- Add ConvMismatch error variant for conversation ID mismatches
- Update driver to propagate errors from session.input()
- Update all test and example callers

Closes: nym-n0kk

* Fix Zeroizing deref in ed25519 to_x25519 conversion

The from_bytes() function expects &[u8], need to deref the Zeroizing
wrapper to get the inner array.

* Add semaphore-based connection limiting for LP packet forwarding

Limits concurrent outbound connections when forwarding LP packets to
prevent file descriptor exhaustion under high load.

Key changes:
- Add max_concurrent_forwards config (default 1000)
- Add forward_semaphore to LpHandlerState
- Acquire semaphore permit before connecting in handle_forward_packet
- Return "Gateway at forward capacity" error when at limit

This provides load signaling so clients can choose another gateway
when the current one is overloaded.

Design note: Connection pooling was considered but provides minimal
benefit since telescope setup is one-time and targets are distributed
across many different gateways. See AIDEV-NOTE in LpHandlerState for
full analysis.

Closes: nym-xi3m

* Return error on session unavailable in handle_subsession_packet

Replace .session().ok() with proper error handling to fail fast when
session is Closed or Processing after state machine processing.

Previously, the code silently continued with outer_key = None, which
could cause protocol errors downstream.

Closes: nym-8de0

* Use explicit bincode Options helper in nested_session

Add bincode_options() helper that returns DefaultOptions with explicit
big_endian and varint_encoding configuration. This future-proofs against
bincode 1.x/2.x default changes and makes serialization format explicit.

Updated all 4 bincode usages in nested_session.rs to use the helper.

* Deduplicate outer_key lookup pattern in nested_session.rs

Extract common state_machine.session().ok().and_then(...) pattern into
two helper methods:
- get_send_key() for encryption (outer_aead_key_for_sending)
- get_recv_key() for decryption (outer_aead_key)

Updated 6 call sites to use the helpers, reducing verbosity.

* Add LpConfig struct and AIDEV-NOTE documentation for KKT+PSQ

- Create config.rs with LpConfig struct (kem_algorithm, psk_ttl, enable_kkt)
- Export LpConfig from lib.rs
- Add AIDEV-NOTE to psk.rs explaining:
  - Why PSQ is embedded in Noise (single round-trip, PSK binding)
  - KEM migration path (X25519 → MlKem768 → XWing)
- Add AIDEV-NOTE to state_machine.rs explaining protocol flow:
  - KKTExchange → Handshaking → Transport state transitions
  - PSK derivation formula (ECDH || PSQ || salt)

* Add forward_timeout to LP client config

Add forward_timeout (30s default) to LpConfig and wrap send_forward_packet's
connect_send_receive call with tokio::time::timeout, matching the pattern
used by register() with registration_timeout.

This prevents indefinite hangs when forwarding packets through entry gateway.

* Add negotiated_version field to LpSession

Add AtomicU8 field to store the protocol version from handshake packet
headers. Includes getter and setter methods for future version negotiation
and compatibility checks.

- negotiated_version() returns current version (defaults to 1)
- set_negotiated_version() allows setting during handshake
- Subsessions inherit version 1 (can be enhanced to inherit parent's)

* Change MessageType from u16 to u32

Breaking wire protocol change: MessageType field increased from 2 bytes
to 4 bytes in LP packets. This future-proofs the message type space and
aligns with other u32 fields.

Changes:
- message.rs: #[repr(u32)], from_u32(), to_u32()
- error.rs: InvalidMessageType(u32)
- codec.rs: All serialization/deserialization updated to 4-byte msg_type
  - Cleartext parsing: inner_bytes[4..8], content at [8..]
  - AEAD parsing: decrypted[4..8], content at [8..]
  - Serialization: 4 bytes for message type

* Various smaller fixes

* Refactor LP to stream-oriented TCP processing

Gateway (handler.rs):
- Add bound_receiver_idx field for session-affine connections
- Convert handle() from single-packet to loop with EOF detection
- Add validate_or_set_binding() for receiver_idx validation
- Set binding in handle_client_hello after collision check
- Centralize emit_lifecycle_metrics in main loop only
- Add is_connection_closed() helper for graceful EOF

Client (client.rs):
- Add stream field for persistent TCP connection
- Add ensure_connected(), send_packet(), receive_packet(), close() methods
- Modify perform_handshake_inner() to use persistent stream
- Modify register_with_credential() to use persistent stream
- Modify send_forward_packet() to use persistent stream
- Keep connect_send_receive() for reference (marked dead_code)

This reduces handshake overhead from ~5 TCP connections to 1.

Drive-by: Fix log::info! -> info! in wireguard peer_controller.rs

* Add persistent exit stream for entry→exit forwarding

Entry gateway now maintains a persistent TCP connection to the exit
gateway per client session, reusing it for all forward requests from
that client. This reduces TCP handshake overhead significantly.

Key changes:
- Add exit_stream: Option<(TcpStream, SocketAddr)> to LpConnectionHandler
- Modify handle_forward_packet() to open on first forward, reuse after
- Clear exit_stream on connection errors (auto-reconnect on next forward)
- Semaphore only acquired for connection opens, not reuse (sequential access)

* Fix code review issues for stream-oriented LP

- Add 30s timeout to exit stream I/O operations (nym-df31)
  Prevents handler from hanging on unresponsive exit gateway

- Return error on forward target address mismatch (nym-zegu)
  Previously warned and proceeded, which could mask bugs

- Close client stream on handshake error paths (nym-scvm)
  Prevents state machine inconsistency on timeout or failure

* Add LP registration idempotency and retry logic

Make LP registration resilient to network failures that could waste
credentials. When registration succeeds on the gateway but the response
is lost (e.g., network drop), clients can retry with the same WG key
and get the cached result instead of spending another credential.

Gateway-side:
- Add check_existing_registration() helper that looks up WG peer and
  returns cached GatewayData if already registered
- Add idempotency check in process_registration() dVPN branch
- Only return cached response if bandwidth > 0 (ensures registration
  was actually completed, not just peer created)
- Track idempotent registrations with lp_registration_dvpn_idempotent metric

Client-side:
- Add register_with_retry() to LpRegistrationClient that acquires
  credential once and retries handshake+registration on failure
- Add handshake_and_register_with_retry() to NestedLpSession for
  exit gateway registration via forwarding
- Add exponential backoff with jitter between retry attempts
- Verify outer session validity before nested session retry

Both retry methods clear state machine before retry to ensure fresh
handshake, and reuse the same credential across all attempts.

* Add no-mix-acks feature flag to nym-sphinx-framing

When enabled, mix nodes skip ack extraction and forwarding entirely.
The full payload (including ack portion) is returned as the message.

Closes: nym-3wrr

* Create nym-lp-speedtest crate scaffold

- Created tools/nym-lp-speedtest/ with Cargo.toml
- Added main.rs with CLI argument parsing
- Created stub modules: client.rs, speedtest.rs, topology.rs
- Added to workspace members
- Verified compilation with cargo check

* Implement topology fetching for nym-lp-speedtest

- Add topology.rs with NymTopology integration
- Fetch mix nodes and gateways from nym-api
- Build GatewayInfo with LP addresses (port 41264)
- Provide random_route_to_gateway() for Sphinx routing
- Add required Cargo.toml dependencies

* Implement LP+Sphinx+KCP client with SURB support

- Add send_data() and send_data_with_surbs() methods for mixnet data
- Integrate KCP reliable delivery with Sphinx packet construction
- Add x25519 encryption keypair for SURB reply mechanism
- Wire up main.rs to test LP handshake and data path
- Add NymRouteProvider support in topology for SURB construction
- Refactor send_data() to delegate to send_data_with_surbs(0) (DRY)

The client can now:
- Perform LP handshake with gateways
- Send data through the mixnet wrapped in KCP + Sphinx packets
- Attach SURBs for bidirectional communication
- Return encryption keys for decrypting replies

* Rename nym-lp-speedtest to nym-lp-client and fix KCP bug

- Rename crate from nym-lp-speedtest to nym-lp-client
- Fix KCP bug: add driver.update() call before fetch_outgoing()
  Without update(), KCP never moves segments from snd_queue to snd_buf
- Update CLI name, about string, and user agent to match new name

* Add LP mixnet mode registration with nym address return

- Extend RegistrationMode::Mixnet to include client_ed25519_pubkey
  and client_x25519_pubkey for nym address construction
- Add LpGatewayData struct containing gateway_identity and
  gateway_sphinx_key for SURB reply routing
- Add lp_gateway_data field to LpRegistrationResponse for mixnet mode
- Implement success_mixnet() constructor for mixnet registrations
- Update gateway registration to insert clients into ActiveClientsStore
  for SURB reply delivery, matching the websocket flow

* Implement LP data handler on UDP:51264

- Add LpDataHandler for UDP data plane (port 51264)
- Decrypt LP layer and forward Sphinx packets to mixnet
- Add outbound_mix_sender to LpHandlerState
- Integrate data handler spawn into LpListener::run()
- Add metrics for data packets received/forwarded/errors

Implements nym-yzzm

* Fix replay protection vulnerability in LP data handler

Use state machine process_input() instead of manual decryption to ensure
proper replay protection:
- Counter check against receiving window
- Counter marking after successful decryption

Also handle subsession actions gracefully (SendPacket ignored on UDP,
clients should use TCP control plane for rekeying).

Security fix for nym-yzzm implementation.

* feat(ipr): add KcpSessionManager for LP client KCP handling

- Add fetch_incoming() and recv() methods to KcpDriver for retrieving
  reassembled messages
- Create KcpSessionManager in ip-packet-router that manages KCP sessions
  keyed by conv_id (first 4 bytes of KCP packet header)
- Store ReplySurbs per session for sending anonymous replies
- Implement session timeout (5 min) and max sessions limit (10000)
- Add comprehensive tests for session lifecycle and KCP roundtrip

* feat(ipr): integrate KcpSessionManager into MixnetListener

- Add KcpSessionManager field to MixnetListener struct
- Add is_kcp_message() helper to detect KCP-wrapped payloads
- Add on_kcp_message() to process LP client KCP messages
- Refactor on_reconstructed_message() to route KCP vs regular IPR
- Add KCP tick timer (100ms) for session updates and cleanup
- Initialize KcpSessionManager in IpPacketRouter::run_service_provider()

KCP messages are detected by checking byte 4 for valid KCP commands
(81-84), which doesn't conflict with IPR protocol version bytes (6-8)
at position 0.

Closes: nym-96zl

* fix(ipr): prevent KCP detection false positives on IPR messages

Add secondary check in is_kcp_message() to exclude messages that match
IPR protocol header pattern (version 6-8 at byte 0, ServiceProviderType
0-2 at byte 1). This prevents false positives where IPR messages with
byte 4 in range 81-84 would be incorrectly routed to KCP processing.

Added 4 unit tests to validate the detection logic.

Closes: nym-6f3x

* fix(ipr): wrap KCP client responses in KCP before SURB reply

- Modify on_kcp_message to handle responses directly instead of returning them
- Add handle_kcp_response method that wraps response in KCP and sends via mixnet
- Ensures KCP clients receive KCP-wrapped responses for proper reassembly

Closes: nym-7oh2

* fix(ipr): send KCP protocol packets in tick instead of just logging

- Add get_sender_tag() and fetch_outgoing_for_conv() to KcpSessionManager
- Change handle_kcp_tick() to actually send ACKs/retransmissions via mixnet
- Reduce KCP tick interval from 100ms to 10ms for better responsiveness

This fixes the KCP reliability protocol which was broken because
protocol packets (ACKs, retransmissions) were generated but never sent.

* feat(lp-client): wrap payload in IpPacketRequest before KCP

- Add nym-ip-packet-requests and bytes dependencies
- Wrap payload in IpPacketRequest::new_data_request() before sending to KCP
- Add LP_DATA_PORT constant (51264) and lp_data_address field to GatewayInfo

This ensures IPR can properly parse incoming messages as DataRequest.
LP framing (wrapping Sphinx in LP before sending) is a separate task.

* feat(lp-client): add LP session management and UDP data plane support

- Add wrap_data() and session_id() to LpRegistrationClient for LP packet
  creation after handshake
- Add init_lp_session() and close_lp_session() to SpeedtestClient for
  managing LP sessions
- Extract prepare_sphinx_fragments() helper to reduce code duplication
  between send_data_with_surbs() and send_data_via_lp()
- Add send_data_via_lp() for sending Sphinx packets through LP's UDP
  data plane (port 51264)

The LP session is kept alive after TCP handshake closes, allowing
subsequent wrap_data() calls for UDP transmission without re-handshaking.

* random formatting

* replaced all instances of bincode::serialize and bincode::deserialize with explicit lp_bincode_serialiser() within the LP

* additional formatting

* removed source of possible panic from nym-kkt

invalid KEM mapping will now return an Err rather than panicking

* integration test for LP entry registration

This includes creation of mocks of various gateway-related components, such as the PeerController

* changed ClientHelloData serialisation

the old variant using bincode did not produce constant-length output in some cases

* Fixed generation of receiver index

removes the possible clash with the boostrap id

* Integration test for nested LP registration

- move `LpTransport` trait definition to shared `nym-lp-transport` crate
- make transport layer within `LpConnectionHandler` generic with respect to the forwarding target. it must, however, use the same type as the incoming client connection
- extracted explicit `LpConnectionHandler::establish_exit_stream` to more easily modify it in the future to fully protect the channel and disallow using untrusted egress points
- fix additional log-string interpolation nits

* resolved clippy issues pointed out by clippy 1.91

* added LP discovery into self-described endpoint:

- removed changes to the node bonding within the contract
- introduced '/api/v1/lewes-protocol' route within nym-node http api
- added 'lewes_protocol' field to 'NymNodeData' inside of NymNodeDescription
- refactored LpConfig to allow separate bind and announce addresses and used more strict typing

* chore: allow unwrap/expect within kkt benchmarking code

* chore: downgraded sha2 dep for cosmwasm compatibility

* clippy

* marking simd calls as unsafe

* fixed calls to '_mm_testz_si128'

* additional clippy fixes

---------

Co-authored-by: Georgio Nicolas <me@georgio.xyz>
Co-authored-by: Jędrzej Stuczyński <jedrzej.stuczynski@gmail.com>
2026-01-14 09:06:02 +00:00
benedettadavico b68e13f0f2 update changelog 2026-01-13 16:47:13 +01:00
Andrej Mihajlov fc0b7189c7 Merge pull request #6316 from nymtech/am/update-nix-v0.30.1
Update nix to v0.30.1
2026-01-13 09:13:45 +01:00
Andrej Mihajlov bc6d2fad48 Left Drop handle funlock 2026-01-12 18:08:11 +01:00
p17o 29de743bd2 [DOCs/operators]: Update OVHCloud (#6070)
Co-authored-by: import this <97586125+serinko@users.noreply.github.com>
2026-01-12 12:29:14 +00:00
Tommy Verrall 6fb5d002e6 Merge pull request #6313 from promalert/develop
chore: remove repetitive words in comment
2026-01-08 13:25:29 +01:00
Andrej Mihajlov 898b8d6ae5 Update nix to v0.30.1
Use new Flock
2026-01-08 12:14:39 +01:00
import this 122397f460 [feature/operators]: Improve Ansible UX, Nginx indempotency and error handling (#6310)
* make wireguard enabled flag bulletproof

* correct firewall setting

* add nginx handler

* make systemd template case sensitive

* twek nginx and ssl template

* finalize nginx and certbot configs

* add nginx purge command

* fix typo

* add removing vm guide
2026-01-07 13:45:56 +00:00
promalert 09d444b78b chore: remove repetitive words in comment
Signed-off-by: promalert <promalert@outlook.com>
2026-01-07 16:47:40 +08:00
Jędrzej Stuczyński 46fe1bc819 bugfix: mozzarella -> niolo config migration (#6259)
* bugfix: mozzarella -> niolo config migration

* clippy
2025-12-02 15:29:30 +00:00
benedettadavico 37ae72d8ec bump versions 2025-11-28 19:18:05 +01:00
zyc e50051795e Fix comment 2025-11-26 21:11:38 +08:00
zyc 91b9f4c4c6 Fix assertion 2025-11-26 21:07:29 +08:00
229 changed files with 41281 additions and 1790 deletions
+3
View File
@@ -1,2 +1,5 @@
nym-validator-rewarder/.sqlx/** diff=nodiff
nym-node-status-api/nym-node-status-api/.sqlx/** diff=nodiff
# Use bd merge for beads JSONL files
.beads/beads.jsonl merge=beads
+1 -1
View File
@@ -25,7 +25,7 @@ Steps to reproduce the behaviour, if you're familiar with BDD syntax, please wri
*An example:*
- Given I was setting up a mix-node following the instructions in the docs
- And I successfully bonded my node via the the wallet
- And I successfully bonded my node via the wallet
- When I went to start my mixnode
- Then I was presented with an error
+11 -1
View File
@@ -64,4 +64,14 @@ nym-api/redocly/formatted-openapi.json
**/settings.sql
**/enter_db.sh
*.profraw
*.profraw
.beads
CLAUDE.md
docs
.claude
.superego
# Superego (machine-specific paths)
.superego/
.claude/hooks/superego/
.claude/settings.json
+56
View File
@@ -4,6 +4,62 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.1-niolo] (2026-01-13)
- bugfix: mozzarella -> niolo config migration ([#6259])
- chore: remove run DKG migration ([#6253])
- bugfix: reexposed 'derive_extended_private_key' ([#6247])
- Bump js-yaml from 3.14.1 to 3.14.2 in /sdk/typescript/codegen/contract-clients ([#6231])
- Statistics API v2 ([#6227])
- Bump golang.org/x/crypto from 0.39.0 to 0.45.0 in /nym-gateway-probe/netstack_ping ([#6220])
- Update chain registry link ([#6219])
- Bump glob from 10.3.4 to 10.5.0 in /documentation/scripts/post-process ([#6216])
- Bump js-yaml from 4.1.0 to 4.1.1 in /sdk/typescript/tests/integration-tests/mix-fetch ([#6215])
- gateway-probe fixes for run-local ([#6212])
- chore: updated default endpoint for retrieving attestation.json ([#6207])
- chore: remove support for legacy mixnode within the performance contract ([#6205])
- feat: upgrade mode: VPN adjustments ([#6189])
- Bump min-document from 2.19.0 to 2.19.1 ([#6181])
- Bump next from 15.4.1 to 15.4.7 in /nym-node-status-api/nym-node-status-ui ([#6180])
- feat: merge intermediate upgrade mode changes ([#6174])
- Add weighted scoring to NS API ([#6144])
- build(deps): bump mikefarah/yq from 4.47.1 to 4.48.1 ([#6107])
- build(deps): bump SonarSource/sonarqube-scan-action from 5 to 6 in /.github/workflows ([#6068])
- build(deps): bump tar-fs from 3.0.9 to 3.1.1 in /sdk/typescript/tests/integration-tests/mix-fetch ([#6063])
- build(deps): bump ammonia from 4.1.1 to 4.1.2 ([#6057])
- build(deps): bump tower-http from 0.5.2 to 0.6.6 ([#6030])
- build(deps): bump actions/setup-go from 5 to 6 ([#6013])
- build(deps): bump next from 14.2.28 to 14.2.32 ([#5996])
- build(deps): bump tracing-subscriber from 0.3.19 to 0.3.20 ([#5993])
- build(deps): bump actions/upload-pages-artifact from 3 to 4 ([#5992])
[#6259]: https://github.com/nymtech/nym/pull/6259
[#6253]: https://github.com/nymtech/nym/pull/6253
[#6247]: https://github.com/nymtech/nym/pull/6247
[#6231]: https://github.com/nymtech/nym/pull/6231
[#6227]: https://github.com/nymtech/nym/pull/6227
[#6220]: https://github.com/nymtech/nym/pull/6220
[#6219]: https://github.com/nymtech/nym/pull/6219
[#6216]: https://github.com/nymtech/nym/pull/6216
[#6215]: https://github.com/nymtech/nym/pull/6215
[#6212]: https://github.com/nymtech/nym/pull/6212
[#6207]: https://github.com/nymtech/nym/pull/6207
[#6205]: https://github.com/nymtech/nym/pull/6205
[#6189]: https://github.com/nymtech/nym/pull/6189
[#6181]: https://github.com/nymtech/nym/pull/6181
[#6180]: https://github.com/nymtech/nym/pull/6180
[#6174]: https://github.com/nymtech/nym/pull/6174
[#6144]: https://github.com/nymtech/nym/pull/6144
[#6107]: https://github.com/nymtech/nym/pull/6107
[#6068]: https://github.com/nymtech/nym/pull/6068
[#6063]: https://github.com/nymtech/nym/pull/6063
[#6057]: https://github.com/nymtech/nym/pull/6057
[#6030]: https://github.com/nymtech/nym/pull/6030
[#6013]: https://github.com/nymtech/nym/pull/6013
[#5996]: https://github.com/nymtech/nym/pull/5996
[#5993]: https://github.com/nymtech/nym/pull/5993
[#5992]: https://github.com/nymtech/nym/pull/5992
## [2025.21-mozzarella] (2025-11-25)
- [bugfix] Tunnel not waiting on MixnetClient to shut down cleanly ([#6225])
-686
View File
@@ -1,686 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Nym is a privacy platform that uses mixnet technology to protect against metadata surveillance. The platform consists of several key components:
- Mixnet nodes (mixnodes) for packet mixing
- Gateways (entry/exit points for the network)
- Clients for interacting with the network
- Network monitoring tools
- Validators for network consensus
- Various service providers and integrations
## Build Commands
### Rust Components
```bash
# Default build (debug)
cargo build
# Release build
cargo build --release
# Build a specific package
cargo build -p <package-name>
# Build main components
make build
# Build release versions of main binaries and contracts
make build-release
# Build specific binaries
make build-nym-cli
cargo build -p nym-node --release
cargo build -p nym-api --release
```
### Testing
```bash
# Run clippy, unit tests, and formatting
make test
# Run all tests including slow tests
make test-all
# Run clippy on all workspaces
make clippy
# Run unit tests for a specific package
cargo test -p <package-name>
# Run only expensive/ignored tests
cargo test --workspace -- --ignored
# Run API tests
dotenv -f envs/sandbox.env -- cargo test --test public-api-tests
# Run tests with specific log level
RUST_LOG=debug cargo test -p <package-name>
# Run specific test scripts
./nym-node/tests/test_apis.sh
./scripts/wireguard-exit-policy/exit-policy-tests.sh
```
### Linting and Formatting
```bash
# Run rustfmt on all code
make fmt
# Check formatting without modifying
cargo fmt --all -- --check
# Run clippy with all targets
cargo clippy --workspace --all-targets -- -D warnings
# TypeScript linting
yarn lint
yarn lint:fix
yarn types:lint:fix
# Check dependencies for security/licensing issues
cargo deny check
```
### WASM Components
```bash
# Build all WASM components
make sdk-wasm-build
# Build TypeScript SDK
yarn build:sdk
npx lerna run --scope @nymproject/sdk build --stream
# Build and test WASM components
make sdk-wasm
# Build specific WASM packages
cd wasm/client && make
cd wasm/mix-fetch && make
cd wasm/node-tester && make
```
### Contract Development
```bash
# Build all contracts
make contracts
# Build contracts in release mode
make build-release-contracts
# Generate contract schemas
make contract-schema
# Run wasm-opt on contracts
make wasm-opt-contracts
# Check contracts with cosmwasm-check
make cosmwasm-check-contracts
```
### Running Components
```bash
# Run nym-node as a mixnode
cargo run -p nym-node -- run --mode mixnode
# Run nym-node as a gateway
cargo run -p nym-node -- run --mode gateway
# Run the network monitor
cargo run -p nym-network-monitor
# Run the API server
cargo run -p nym-api
# Run with specific environment
dotenv -f envs/sandbox.env -- cargo run -p nym-api
# Start a local network
./scripts/localnet_start.sh
```
## Architecture
The Nym platform consists of various components organized as a monorepo:
1. **Core Mixnet Infrastructure**:
- `nym-node`: Core binary supporting mixnode and gateway modes
- `common/nymsphinx`: Implementation of the Sphinx packet format
- `common/topology`: Network topology management
- `common/types`: Shared data types across components
2. **Network Monitoring**:
- `nym-network-monitor`: Monitors the network's reliability and performance
- `nym-api`: API server for network stats and monitoring data
- Metrics tracking for nodes, routes, and overall network health
3. **Client Implementations**:
- `clients/native`: Native Rust client implementation
- `clients/socks5`: SOCKS5 proxy client for standard applications
- `wasm`: WebAssembly client implementations (for browsers)
- `nym-connect`: Desktop and mobile clients
4. **Blockchain & Smart Contracts**:
- `common/cosmwasm-smart-contracts`: Smart contract implementations
- `contracts`: CosmWasm contracts for the Nym network
- `common/ledger`: Blockchain integration
5. **Utilities & Tools**:
- `tools`: Various CLI tools and utilities
- `sdk`: SDKs for different languages and platforms
- `documentation`: Documentation generation and management
## Packet System
Nym uses a modified Sphinx packet format for its mixnet:
1. **Message Chunking**:
- Messages are divided into "sets" and "fragments"
- Each fragment fits in a single Sphinx packet
- The `common/nymsphinx/chunking` module handles message fragmentation
2. **Routing**:
- Packets traverse through 3 layers of mixnodes
- Routing information is encrypted in layers (onion routing)
- The final gateway receives and processes the messages
3. **Monitoring**:
- Monitoring system tracks packet delivery through the network
- Routes are analyzed for reliability statistics
- Node performance metrics are collected
## Network Protocol
Nym implements the Loopix mixnet design with several key privacy features:
1. **Continuous-time Mixing**:
- Each mixnode delays messages independently with an exponential distribution
- This creates random reordering of packets, destroying timing correlations
- Offers better anonymity properties than batch mixing approaches
2. **Cover Traffic**:
- Clients and nodes generate dummy "loop" packets that circulate through the network
- These packets are indistinguishable from real traffic
- Creates a baseline level of traffic that hides actual communication patterns
- Provides unobservability (hiding when and how much real traffic is being sent)
3. **Stratified Network Architecture**:
- Traffic flows through Entry Gateway → 3 Mixnode Layers → Exit Gateway
- Path selection is independent per-message (unlike Tor)
- Each node connects only to adjacent layers
4. **Anonymous Replies**:
- Single-Use Reply Blocks (SURBs) allow receiving messages without revealing identity
- Enables bidirectional communication while maintaining privacy
## Network Monitoring Architecture
The network monitoring system is a core component that measures mixnet reliability:
1. The `nym-network-monitor` sends test packets through the network
2. These packets follow predefined routes through multiple mixnodes
3. Metrics are collected about:
- Successful and failed packet deliveries
- Node reliability (percentage of successful packet handling)
- Route reliability (which specific route combinations work best)
4. Results are stored in the database and used by `nym-api` to:
- Present node performance statistics
- Determine network rewards
- Provide route selection guidance to clients
In the current branch, metrics collection is being enhanced with a fanout approach to submit to multiple API endpoints.
## Development Environment
### Required Dependencies
- Rust toolchain (stable, 1.80+)
- Node.js (v20+) and yarn for TypeScript components
- SQLite for local database development
- PostgreSQL for API database (optional, for full API functionality)
- CosmWasm tools for contract development
- For building contracts: `wasm-opt` tool from `binaryen`
- Python 3.8+ for some scripts
- Docker (optional, for containerized development)
- protoc (Protocol Buffers compiler) for some components
### Environment Configurations
The `envs/` directory contains pre-configured environments:
#### Available Environments
- **`local.env`**: Local development environment
- Points to local services (localhost)
- Uses test mnemonics and keys
- Ideal for testing without external dependencies
- **`sandbox.env`**: Sandbox test network
- Public test network with real nodes
- Test tokens available from faucet
- Contract addresses for sandbox deployment
- API: https://sandbox-nym-api1.nymtech.net
- **`mainnet.env`**: Production mainnet
- Real network with real tokens
- Production contract addresses
- API: https://validator.nymtech.net
- Use with caution!
- **`canary.env`**: Canary deployment
- Pre-release testing environment
- Tests new features before mainnet
- **`mainnet-local-api.env`**: Hybrid environment
- Uses mainnet contracts but local API
- Useful for API development against mainnet data
#### Key Environment Variables
```bash
# Network configuration
NETWORK_NAME=sandbox # Network identifier
BECH32_PREFIX=n # Address prefix (n for sandbox, n for mainnet)
NYM_API=https://sandbox-nym-api1.nymtech.net/api
NYXD=https://rpc.sandbox.nymtech.net
NYM_API_NETWORK=sandbox
# Contract addresses (network-specific)
MIXNET_CONTRACT_ADDRESS=n1xr3rq8yvd7qplsw5yx90ftsr2zdhg4e9z60h5duusgxpv72hud3sjkxkav
VESTING_CONTRACT_ADDRESS=n1unyuj8qnmygvzuex3dwmg9yzt9alhvyeat0uu0jedg2wj33efl5qackslz
# ... other contract addresses
# Mnemonic for testing (NEVER use in production)
MNEMONIC="clutch captain shoe salt awake harvest setup primary inmate ugly among become"
# API Keys and tokens
IPINFO_API_TOKEN=your_token_here
AUTHENTICATOR_PASSWORD=password_here
# Logging
RUST_LOG=info # Options: error, warn, info, debug, trace
RUST_BACKTRACE=1 # Enable backtraces
# Database
DATABASE_URL=postgresql://user:pass@localhost/nym_api
```
#### Using Environment Files
```bash
# Load environment and run command
dotenv -f envs/sandbox.env -- cargo run -p nym-api
# Export to shell
source envs/sandbox.env
# Use with make targets
dotenv -f envs/sandbox.env -- make run-api-tests
```
## Initial Setup
### First Time Setup
1. **Install Prerequisites**
```bash
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Node.js and yarn
# Via nvm (recommended):
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
npm install -g yarn
# Install build tools
# Ubuntu/Debian:
sudo apt-get install build-essential pkg-config libssl-dev protobuf-compiler libpq-dev
# macOS:
brew install protobuf postgresql
# Install wasm-opt for contract builds
npm install -g wasm-opt
# Add wasm target for Rust
rustup target add wasm32-unknown-unknown
```
2. **Clone and Setup Repository**
```bash
git clone https://github.com/nymtech/nym.git
cd nym/nym
# Install JavaScript dependencies
yarn install
# Build the project
make build
```
3. **Database Setup (Optional, for API development)**
```bash
# Install PostgreSQL
# Create database
createdb nym_api
# Run migrations (from nym-api directory)
cd nym-api
sqlx migrate run
```
### Quick Start
```bash
# Run a mixnode locally
dotenv -f envs/sandbox.env -- cargo run -p nym-node -- run --mode mixnode --id my-mixnode
# Run a gateway locally
dotenv -f envs/sandbox.env -- cargo run -p nym-node -- run --mode gateway --id my-gateway
# Run the API server
dotenv -f envs/sandbox.env -- cargo run -p nym-api
# Run a client
cargo run -p nym-client -- init --id my-client
cargo run -p nym-client -- run --id my-client
```
## CI/CD Pipeline
The project uses GitHub Actions for CI/CD with several key workflows:
1. **Build and Test**:
- `ci-build.yml`: Main build workflow for Rust components
- Tests are run on multiple platforms (Linux, Windows, macOS)
- Includes formatting check (rustfmt) and linting (clippy)
2. **Release Process**:
- Binary artifacts are published on release tags
- Multiple platform builds are created
3. **Documentation**:
- Documentation is automatically built and deployed
## Database Structure
The system uses SQLite databases with tables like:
- `mixnode_status`: Status information about mixnodes
- `gateway_status`: Status information about gateways
- `routes`: Route performance information (success/failure of specific paths)
- `monitor_run`: Information about monitoring test runs
## Development Workflows
### Running a Node
To run the mixnode or gateway:
```bash
# Run nym-node as a mixnode with specified identity
cargo run -p nym-node -- run --mode mixnode --id my-mixnode
# Run nym-node as a gateway
cargo run -p nym-node -- run --mode gateway --id my-gateway
```
### Configuration
Nodes can be configured with files in various locations:
- Command-line arguments
- Environment variables
- `.env` files specified with `--config-env-file`
### Monitoring
To monitor the health of your node:
- View logs for real-time information
- Use the node's HTTP API for status information
- Check the explorer for public node statistics
## Common Libraries
- `common/types`: Shared data types across all components
- `common/crypto`: Cryptographic primitives and wrappers
- `common/client-core`: Core client functionality
- `common/gateway-client`: Client-gateway communication
- `common/task`: Task management and concurrency utilities
- `common/nymsphinx`: Sphinx packet implementation for mixnet
- `common/topology`: Network topology management
- `common/credentials`: Credential system for privacy-preserving authentication
- `common/bandwidth-controller`: Bandwidth management and accounting
## Code Conventions
- Error handling: Use anyhow/thiserror for structured error handling
- Logging: Use the tracing framework for logging and diagnostics
- State management: Generally use Tokio/futures for async code
- Configuration: Use the config crate and env vars with defaults
- Database: Use sqlx for type-safe database queries
- Follow clippy recommendations and rustfmt formatting
- Use semantic commit messages: feat, fix, docs, refactor, test, chore
## When Making Changes
- Run `make test` before submitting PRs
- Follow Rust naming conventions
- Use `clippy` to check for common issues
- Update SQLx query caches when modifying DB queries: `cargo sqlx prepare`
- Consider backward compatibility for protocol changes
- Use lefthook pre-commit hooks for TypeScript formatting
- Run `cargo deny check` to verify dependency compliance
- Test against both sandbox and local environments when possible
- Update relevant documentation and CHANGELOG.md
## Development Tools
### Useful Cargo Commands
```bash
# Check for outdated dependencies
cargo outdated
# Analyze binary size
cargo bloat --release -p nym-node
# Generate dependency graph
cargo tree -p nym-api
# Run with instrumentation
cargo run --features profiling -p nym-node
# Check for security advisories
cargo audit
```
### Database Tools
```bash
# SQLx CLI for migrations
cargo install sqlx-cli
# Create new migration
cd nym-api && sqlx migrate add <migration_name>
# Prepare query metadata for offline compilation
cargo sqlx prepare --workspace
# View database schema
./nym-api/enter_db.sh
```
### Development Scripts
- `scripts/build_topology.py`: Generate network topology files
- `scripts/node_api_check.py`: Verify node API endpoints
- `scripts/network_tunnel_manager.sh`: Manage network tunnels
- `scripts/localnet_start.sh`: Start a local test network
- Various deployment scripts in `deployment/` for different environments
## Debugging
- Enable more verbose logging with the RUST_LOG environment variable:
```
RUST_LOG=debug,nym_node=trace cargo run -p nym-node -- run --mode mixnode
```
- Use the HTTP API endpoints for status information
- Check monitoring data in the database for network performance metrics
- For complex issues, use tracing tools to follow packet flow
- Enable backtraces: `RUST_BACKTRACE=full`
- For WASM debugging: Use browser developer tools with source maps
## Deployment and Advanced Configurations
### Deployment Structure
The `deployment/` directory contains Ansible playbooks and configurations for various deployment scenarios:
- **`aws/`**: AWS-specific deployment configurations
- **`mixnode/`**: Mixnode deployment playbooks
- **`gateway/`**: Gateway deployment playbooks
- **`validator/`**: Validator node deployment
- **`sandbox-v2/`**: Complete sandbox environment setup
- **`big-dipper-2/`**: Block explorer deployment
### Sandbox V2 Deployment
The sandbox-v2 deployment (`deployment/sandbox-v2/`) provides a complete test environment:
```bash
# Key playbooks:
- deploy.yaml # Main deployment orchestrator
- deploy-mixnodes.yaml # Deploy mixnodes
- deploy-gateways.yaml # Deploy gateways
- deploy-validators.yaml # Deploy validator nodes
- deploy-nym-api.yaml # Deploy API services
```
### Custom Environment Setup
To create a custom environment:
1. Copy an existing env file: `cp envs/sandbox.env envs/custom.env`
2. Modify the network endpoints and contract addresses
3. Update the `NETWORK_NAME` to your identifier
4. Set appropriate mnemonics and keys (use fresh ones for production!)
### Contract Addresses
Contract addresses are network-specific and defined in environment files:
- Mixnet contract: Manages mixnode/gateway registry
- Vesting contract: Handles token vesting schedules
- Coconut contracts: Privacy-preserving credentials
- Name service: Human-readable address mapping
- Ecash contract: Electronic cash functionality
### Local Network Setup
For a completely local network:
```bash
# Start local chain
./scripts/localnet_start.sh
# Deploy contracts
cd contracts
make deploy-local
# Start nodes with local config
dotenv -f envs/local.env -- cargo run -p nym-node -- run --mode mixnode
```
## Common Issues and Troubleshooting
### Database Issues
- When modifying database queries, you must update SQLx query caches:
```bash
cargo sqlx prepare
```
- If you see SQLx errors about missing query files, this is likely the cause
- For "database is locked" errors with SQLite, ensure only one process accesses the DB
- For PostgreSQL connection issues, verify DATABASE_URL and that the server is running
### API Connection Issues
- Check the environment variables pointing to the APIs (NYM_API, NYXD)
- Verify network connectivity and API health endpoints
- For authentication issues, check node keys and credentials
- Common endpoints to verify:
- API health: `$NYM_API/health`
- Chain status: `$NYXD/status`
- Contract info: `$NYXD/cosmwasm/wasm/v1/contract/$CONTRACT_ADDRESS`
### Build Problems
- Clean dependencies with `cargo clean` for a fresh build
- Check for compatible Rust version (1.80+ recommended)
- For smart contract builds, ensure wasm-opt is installed: `npm install -g wasm-opt`
- For cross-compilation issues, check target-specific dependencies
- WASM build issues: Ensure wasm32-unknown-unknown target is installed:
```bash
rustup target add wasm32-unknown-unknown
```
- For "cannot find -lpq" errors, install PostgreSQL development files:
```bash
# Ubuntu/Debian
sudo apt-get install libpq-dev
# macOS
brew install postgresql
```
### Environment Issues
- Contract address mismatches: Ensure you're using the correct environment file
- "Account sequence mismatch": The account nonce is out of sync, wait and retry
- Token decimal issues: Sandbox uses different decimal places than mainnet
- API version mismatches: Ensure your local API version matches the network
- "Insufficient funds": Get test tokens from faucet (sandbox) or check balance
- Gateway/mixnode bonding issues: Verify minimum stake requirements
## Working with Routes and Monitoring
1. Route monitoring metrics are stored in a `routes` table with:
- Layer node IDs (layer1, layer2, layer3, gw)
- Success flag (boolean)
- Timestamp
2. To analyze routes:
- Check `NetworkAccount` and `AccountingRoute` in `nym-network-monitor/src/accounting.rs`
- View monitoring logic in `common/nymsphinx/chunking/monitoring.rs`
- Observe how routes are submitted to the database in the `submit_accounting_routes_to_db` function
## Performance Optimization
### Profiling and Benchmarking
```bash
# Run benchmarks
cargo bench -p nym-node
# Profile with perf (Linux)
cargo build --release --features profiling
perf record --call-graph=dwarf ./target/release/nym-node run --mode mixnode
perf report
# Generate flamegraph
cargo install flamegraph
cargo flamegraph --bin nym-node -- run --mode mixnode
```
### Common Performance Considerations
- Use bounded channels for backpressure
- Batch database operations where possible
- Monitor memory usage with `RUST_LOG=nym_node::metrics=debug`
- Use connection pooling for database connections
- Consider using `jemalloc` for better memory allocation performance
Generated
+912 -89
View File
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -72,6 +72,10 @@ members = [
"common/nym-cache",
"common/nym-connection-monitor",
"common/nym-id",
"common/nym-kcp",
"common/nym-lp",
"common/nym-lp-common",
"common/nym-kkt",
"common/nym-metrics",
"common/nym_offline_compact_ecash",
"common/nymnoise",
@@ -153,13 +157,14 @@ members = [
"tools/internal/contract-state-importer/importer-cli",
"tools/internal/contract-state-importer/importer-contract",
"tools/internal/mixnet-connectivity-check",
# "tools/internal/sdk-version-bump",
# "tools/internal/sdk-version-bump",
"tools/internal/ssl-inject",
"tools/internal/testnet-manager",
"tools/internal/testnet-manager/dkg-bypass-contract",
"tools/internal/validator-status-check",
"tools/nym-cli",
"tools/nym-id-cli",
"tools/nym-lp-client",
"tools/nym-nr-query",
"tools/nymvisor",
"tools/ts-rs-cli",
@@ -168,7 +173,8 @@ members = [
"wasm/mix-fetch",
"wasm/node-tester",
"wasm/zknym-lib",
"nym-gateway-probe"
"nym-gateway-probe",
"integration-tests", "common/nym-lp-transport",
]
default-members = [
@@ -186,6 +192,7 @@ default-members = [
"service-providers/ip-packet-router",
"service-providers/network-requester",
"tools/nymvisor",
"nym-registration-client"
]
exclude = ["contracts", "nym-wallet", "cpu-cycles"]
@@ -207,6 +214,7 @@ aes = "0.8.1"
aes-gcm = "0.10.1"
aes-gcm-siv = "0.11.1"
ammonia = "4"
ansi_term = "0.12"
anyhow = "1.0.98"
arc-swap = "1.7.1"
argon2 = "0.5.0"
@@ -246,9 +254,9 @@ criterion = "0.5"
csv = "1.3.1"
ctr = "0.9.1"
cupid = "0.6.1"
curve25519-dalek = "4.1.3"
dashmap = "5.5.3"
# We want https://github.com/DefGuard/wireguard-rs/pull/64 , but there's no crates.io release being pushed out anymore
defguard_wireguard_rs = { git = "https://github.com/DefGuard/wireguard-rs.git", rev = "v0.4.7" }
defguard_wireguard_rs = "0.8.0"
digest = "0.10.7"
dirs = "6.0"
dotenvy = "0.15.6"
@@ -286,7 +294,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"
@@ -294,8 +304,9 @@ ledger-transport-hid = "0.10.0"
log = "0.4"
mime = "0.3.17"
moka = { version = "0.12", features = ["future"] }
nix = "0.27.1"
nix = "0.30.1"
notify = "5.1.0"
num_enum = "0.7.5"
once_cell = "1.21.3"
opentelemetry = "0.19.0"
opentelemetry-jaeger = "0.18.0"
@@ -326,7 +337,7 @@ serde_repr = "0.1"
serde_with = "3.9.0"
serde_yaml = "0.9.25"
serde_plain = "1.0.2"
sha2 = "0.10.9"
sha2 = "0.10.3"
si-scale = "0.2.3"
snow = "0.9.6"
sphinx-packet = "=0.6.0"
@@ -342,6 +353,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"
+10 -10
View File
@@ -2,7 +2,7 @@
ansible_ssh_private_key_file: ~/.ssh/<SSH_KEY>
# nym_version: "v2025.21-mozzarella"
#
#
# NOTE:
# if you want to pin Nym to a specific version instead of using the
# latest release from GitHub in /tasks/main.yml then
@@ -13,17 +13,17 @@ tunnel_manager_url: "https://github.com/nymtech/nym/raw/refs/heads/develop/scrip
quic_bridge_deployment_url: "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/quic_bridge_deployment.sh"
# NOTE: These values will be used globally unless overwritten per node in inventory/all
ansible_user: root # used for ssh, like `ssh root@nym-exit.ch-1.mynodes.net`
email: "<EMAIL>" # used in certbot, description.toml and landing page
website: "<WEBSITE>" # it is used in the description.toml
description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
ansible_user: root # used for ssh, like `ssh root@nym-exit.ch-1.mynodes.net`
email: "<EMAIL>" # used in certbot, description.toml and landing page
website: "<WEBSITE>" # it is used in the description.toml
description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
# NOTE: Set these vars if you want them globally for all nodes
# Per node changes in inventory/all will overwrite these global ones:
hostname: "" # this is a fallback, keep it and setup hostname per node in inventory/all
# moniker: "<MONIKER>" # if not setup here not in inventory/all it get's derived from the hostname
# mode: <MODE> # entry-gateway/exit-gateway/mixnode
# wireguard_enabled: <WIREGUARD_ENABLED> # true/false
hostname: "" # this is a fallback, keep it and setup hostname per node in inventory/all
# moniker: "<MONIKER>" # if not setup here not in inventory/all it get's derived from the hostname
# mode: <MODE> # entry-gateway/exit-gateway/mixnode
# wireguard_enabled: <WIREGUARD_ENABLED> # true/false
# NOTE: Possible vars to incule on landing page, etc.
# operator_name: "<OPERATOR_NAME>"
@@ -41,4 +41,4 @@ packages:
- ca-certificates
- jq
- wget
- ufw
- ufw
+4 -3
View File
@@ -1,9 +1,10 @@
---
- name: Set hostname
hostname:
name: "{{ hostname }}"
when: hostname is defined and hostname | length > 0
- name: Install aptitude
- name: Install aptitude
apt:
name: aptitude
update_cache: yes
@@ -14,9 +15,9 @@
apt:
update_cache: yes
upgrade: yes
- name: Install essential packages
package:
name: "{{ packages }}"
state: latest
update_cache: yes
update_cache: yes
@@ -0,0 +1,10 @@
---
- name: Reload nginx
service:
name: nginx
state: reloaded
- name: Restart nginx
service:
name: nginx
state: restarted
+127 -15
View File
@@ -1,3 +1,4 @@
---
- name: Install nginx and certbot
apt:
name:
@@ -5,57 +6,168 @@
- certbot
- python3-certbot-nginx
state: present
update_cache: yes
- name: Create web root directory
- name: Ensure nginx snippets directory exists
file:
path: /etc/nginx/snippets
state: directory
mode: "0755"
# own SSL defaults - don't rely on certbot files
- name: Install Nym SSL options snippet
copy:
dest: /etc/nginx/snippets/nym-ssl-options.conf
mode: "0644"
content: |
ssl_session_cache shared:NYMSSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Reasonable modern cipher set (works across Ubuntu nginx builds)
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305";
# OCSP stapling is nice but can break if resolver isn't set; keep minimal here.
notify: Restart nginx
- name: Ensure web root directory exists
file:
path: "/var/www/{{ hostname }}"
state: directory
mode: "0755"
- name: Create landing page template
tags: landing
- name: Deploy landing page
template:
src: landing.html.j2
dest: "/var/www/{{ hostname }}/index.html"
mode: "0644"
notify: Restart nginx
- name: Remove default nginx site
# remove default site - safe on fresh + redeploy
- name: Disable default nginx site symlink
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Restart nginx
- name: Add bare-bones nginx template
- name: Remove default nginx site definition if present
file:
path: /etc/nginx/sites-available/default
state: absent
notify: Restart nginx
# always deploy/enable HTTP vhost
- name: Deploy HTTP vhost
template:
src: nginx-site.conf.j2
dest: "/etc/nginx/sites-available/{{ hostname }}"
mode: "0644"
notify: Restart nginx
- name: Enable nginx config
- name: Enable HTTP vhost (force correct symlink)
file:
src: "/etc/nginx/sites-available/{{ hostname }}"
dest: "/etc/nginx/sites-enabled/{{ hostname }}"
state: link
force: true
notify: Restart nginx
- name: Validate nginx configuration
# detect if cert exists already
- name: Check whether certificate exists
stat:
path: "/etc/letsencrypt/live/{{ hostname }}/fullchain.pem"
register: le_cert
# if cert does NOT exist yet, ensure SSL/WSS are NOT enabled
- name: Ensure SSL and WSS vhosts are disabled until cert exists
file:
path: "{{ item }}"
state: absent
loop:
- "/etc/nginx/sites-enabled/{{ hostname }}-ssl"
- "/etc/nginx/sites-enabled/nym-wss-config"
when: not le_cert.stat.exists
notify: Restart nginx
- name: Ensure nginx is enabled and running (needed for ACME http-01)
service:
name: nginx
state: started
enabled: yes
- name: Validate nginx configuration (HTTP stage)
command: nginx -t
changed_when: false
- name: Obtain SSL certificate
command:
cmd: "certbot --nginx --non-interactive --agree-tos --redirect -m {{ email }} -d {{ hostname }}"
- name: Flush handlers (ensure HTTP is active before certbot)
meta: flush_handlers
- name: Add wss config from nginx template
# certbot strategy:
# - if cert exists: webroot - doesn't touch nginx
# - else: --nginx works first-time; may touch nginx
- name: Obtain/renew certificate
command:
cmd: >-
{% if le_cert.stat.exists %}
certbot certonly --webroot
-w /var/www/{{ hostname }}
--non-interactive --agree-tos --keep-until-expiring
-m {{ email }} -d {{ hostname }}
{% else %}
certbot --nginx
--non-interactive --agree-tos --redirect
-m {{ email }} -d {{ hostname }}
{% endif %}
register: certbot_result
failed_when: false
# re-check cert after certbot attempt
- name: Re-check whether certificate exists after certbot
stat:
path: "/etc/letsencrypt/live/{{ hostname }}/fullchain.pem"
register: le_cert_after
# only deploy/enable SSL & WSS if cert exists
- name: Deploy HTTPS vhost for {{ hostname }}
template:
src: nginx-site-ssl.conf.j2
dest: "/etc/nginx/sites-available/{{ hostname }}-ssl"
mode: "0644"
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Enable HTTPS vhost (force correct symlink)
file:
src: "/etc/nginx/sites-available/{{ hostname }}-ssl"
dest: "/etc/nginx/sites-enabled/{{ hostname }}-ssl"
state: link
force: true
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Deploy WSS vhost
template:
src: wss-config.conf.j2
dest: "/etc/nginx/sites-available/nym-wss-config"
mode: "0644"
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Enable WSS config
- name: Enable WSS vhost (force correct symlink)
file:
src: "/etc/nginx/sites-available/nym-wss-config"
dest: "/etc/nginx/sites-enabled/nym-wss-config"
state: link
force: true
when: le_cert_after.stat.exists
notify: Restart nginx
- name: Validate nginx config after wss
- name: Validate nginx configuration (final)
command: nginx -t
changed_when: false
- name: Restart nginx to apply changes
service: name=nginx state=restarted enabled=yes
- name: Flush handlers (apply restart after successful tests)
meta: flush_handlers
@@ -0,0 +1,17 @@
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{ hostname }};
ssl_certificate /etc/letsencrypt/live/{{ hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ hostname }}/privkey.pem;
include /etc/nginx/snippets/nym-ssl-options.conf;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
@@ -4,10 +4,15 @@ server {
server_name {{ hostname }};
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
root /var/www/{{ hostname }};
index index.html;
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
try_files $uri =404;
}
}
location / {
return 301 https://$host$request_uri;
}
}
@@ -4,10 +4,9 @@ server {
server_name {{ hostname }};
ssl_certificate /etc/letsencrypt/live/{{ hostname }}/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/{{ hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ hostname }}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
include /etc/nginx/snippets/nym-ssl-options.conf;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
+1 -5
View File
@@ -6,10 +6,6 @@ nym_install_dir: /root/nym-binaries
http_bind_address: "0.0.0.0:8080" # maps to --http-bind-address
mixnet_bind_address: "0.0.0.0:1789" # maps to --mixnet-bind-address
# WireGuard boolean
wireguard_enabled: "{{ wireguard_enabled | default(false) | bool }}"
# Landing page base dir, hostname is appended in the task
landing_page_assets_base_dir: "/var/www"
@@ -37,4 +33,4 @@ nym_ufw_rules:
- { port: 8080, proto: tcp }
- { port: 9000, proto: tcp }
- { port: 9001, proto: tcp }
- { port: 51822, proto: udp }
- { port: 51822, proto: udp }
@@ -1,3 +1,4 @@
---
- name: Reload systemd
systemd:
daemon_reload: yes
+3 -3
View File
@@ -1,5 +1,5 @@
---
# Useful when the host is behind a NAT
# useful when the host is behind a NAT
- name: Fetch the public IP address
command: "curl -4 canhazip.com"
register: ipv4
@@ -11,7 +11,7 @@
public_ip: "{{ ipv4.stdout | default(ansible_default_ipv4.address) }}"
- name: Initialize nym node
# Delete the part from --hostname onward if you run mode=mixnode only
# delete the part from --hostname onward if you run mode=mixnode only
command:
cmd: >
{{ nym_install_dir }}/nym-node run
@@ -25,7 +25,7 @@
{{ nym_extra_flags }}
--hostname {{ hostname }}
--wireguard-enabled {{ wireguard_enabled }}
--wireguard-enabled {{ (wireguard_enabled | default('false') | bool) | ternary('true','false') }}
--landing-page-assets-path {{ landing_page_assets_base_dir }}/{{ hostname }}/
{% if nym_write_flag %}-w{% endif %}
{% if nym_init_only_flag %}--init-only{% endif %}
+11 -1
View File
@@ -1,3 +1,12 @@
---
- name: Ensure UFW is installed
apt:
name: ufw
state: present
update_cache: yes
when: nym_ufw_enable
- name: Configure UFW rules
ufw:
rule: allow
@@ -14,9 +23,10 @@
- name: Allow bandwidth/topup rule inside WG tunnel
command: >
ufw allow in on nymwg to any port 51830 proto tcp comment 'bandwidth queries/topup'
changed_when: false
when:
- nym_ufw_enable
- (wireguard_enabled | bool)
- (wireguard_enabled | default(false) | bool)
- name: Enable UFW
ufw:
@@ -6,10 +6,10 @@ StartLimitBurst=10
[Service]
User={{ ansible_user }}
LimitNOFILE=65536
ExecStart=/root/nym-binaries/nym-node run --mode {{ mode }} --accept-operator-terms-and-conditions --wireguard-enabled {{ wireguard_enabled }}
ExecStart=/root/nym-binaries/nym-node run --mode {{ mode }} --accept-operator-terms-and-conditions --wireguard-enabled {{ (wireguard_enabled | default(false) | bool) | ternary('true','false') }}
KillSignal=SIGINT
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
+6 -9
View File
@@ -1,14 +1,11 @@
- name: Download network-tunnel-manager.sh
tags: network tunnel manager
get_url:
url: "{{ tunnel_manager_url }}"
dest: "/root/nym-binaries/network-tunnel-manager.sh"
mode: "0755"
---
- name: Configure tunnel manager
tags: network tunnel manager
tags:
- network_tunnel_manager
become: true
command:
cmd: "/root/nym-binaries/network-tunnel-manager.sh {{ item }}"
loop:
- complete_networking_configuration
- complete_networking_configuration
register: tunnel_mgr
failed_when: false
@@ -9,7 +9,7 @@
changed_when: false
when: not ansible_check_mode
# show the full stdout so we dont depend on regex parsing at all
# show the full stdout
# show full upgraded version output, line by line
- name: Show upgraded nym-node version info
debug:
@@ -116,7 +116,7 @@
when: not ansible_check_mode and (upgrade_ok | default(false)) == false
# optional: hard-fail the play for CI environments
#- name: Fail the play to signal upgrade failure
#- name: fail the play to signal upgrade failure
# fail:
# msg: "nym-node upgrade failed; rolled back to previous binary."
# when: not ansible_check_mode and (upgrade_ok | default(false)) == false
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-client"
version = "1.1.67"
version = "1.1.68"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
description = "Implementation of the Nym Client"
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-socks5-client"
version = "1.1.67"
version = "1.1.68"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
edition = "2021"
+1
View File
@@ -28,6 +28,7 @@ pub use traits::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
pub mod acquire;
pub mod error;
mod event;
pub mod mock;
mod traits;
mod utils;
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#![allow(clippy::expect_used)]
use crate::error::BandwidthControllerError;
use crate::{BandwidthTicketProvider, PreparedCredential, PreparedCredentialMetadata};
use async_trait::async_trait;
use nym_credentials_interface::{CredentialSpendingData, TicketType};
use nym_crypto::asymmetric::ed25519::PublicKey;
use nym_ecash_time::OffsetDateTime;
#[derive(Default)]
pub struct MockBandwidthController {
// TODO: inject proper bls381 keys and just sign credentials
//
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl BandwidthTicketProvider for MockBandwidthController {
async fn get_ecash_ticket(
&self,
_ticket_type: TicketType,
_gateway_id: PublicKey,
tickets_to_spend: u32,
) -> Result<PreparedCredential, BandwidthControllerError> {
assert_eq!(tickets_to_spend, 1);
// This is a valid serialized CredentialSpendingData taken from integration tests
// See: common/wireguard-private-metadata/tests/src/lib.rs:CREDENTIAL_BYTES
const CREDENTIAL_BYTES: [u8; 1245] = [
0, 0, 4, 133, 96, 179, 223, 185, 136, 23, 213, 166, 59, 203, 66, 69, 209, 181, 227,
254, 16, 102, 98, 237, 59, 119, 170, 111, 31, 194, 51, 59, 120, 17, 115, 229, 79, 91,
11, 139, 154, 2, 212, 23, 68, 70, 167, 3, 240, 54, 224, 171, 221, 1, 69, 48, 60, 118,
119, 249, 123, 35, 172, 227, 131, 96, 232, 209, 187, 123, 4, 197, 102, 90, 96, 45, 125,
135, 140, 99, 1, 151, 17, 131, 143, 157, 97, 107, 139, 232, 212, 87, 14, 115, 253, 255,
166, 167, 186, 43, 90, 96, 173, 105, 120, 40, 10, 163, 250, 224, 214, 200, 178, 4, 160,
16, 130, 59, 76, 193, 39, 240, 3, 101, 141, 209, 183, 226, 186, 207, 56, 210, 187, 7,
164, 240, 164, 205, 37, 81, 184, 214, 193, 195, 90, 205, 238, 225, 195, 104, 12, 123,
203, 57, 233, 243, 215, 145, 195, 196, 57, 38, 125, 172, 18, 47, 63, 165, 110, 219,
180, 40, 58, 116, 92, 254, 160, 98, 48, 92, 254, 232, 107, 184, 80, 234, 60, 160, 235,
249, 76, 41, 38, 165, 28, 40, 136, 74, 48, 166, 50, 245, 23, 201, 140, 101, 79, 93,
235, 128, 186, 146, 126, 180, 134, 43, 13, 186, 19, 195, 48, 168, 201, 29, 216, 95,
176, 198, 132, 188, 64, 39, 212, 150, 32, 52, 53, 38, 228, 199, 122, 226, 217, 75, 40,
191, 151, 48, 164, 242, 177, 79, 14, 122, 105, 151, 85, 88, 199, 162, 17, 96, 103, 83,
178, 128, 9, 24, 30, 74, 108, 241, 85, 240, 166, 97, 241, 85, 199, 11, 198, 226, 234,
70, 107, 145, 28, 208, 114, 51, 12, 234, 108, 101, 202, 112, 48, 185, 22, 159, 67, 109,
49, 27, 149, 90, 109, 32, 226, 112, 7, 201, 208, 209, 104, 31, 97, 134, 204, 145, 27,
181, 206, 181, 106, 32, 110, 136, 115, 249, 201, 111, 5, 245, 203, 71, 121, 169, 126,
151, 178, 236, 59, 221, 195, 48, 135, 115, 6, 50, 227, 74, 97, 107, 107, 213, 90, 2,
203, 154, 138, 47, 128, 52, 134, 128, 224, 51, 65, 240, 90, 8, 55, 175, 180, 178, 204,
206, 168, 110, 51, 57, 189, 169, 48, 169, 136, 121, 99, 51, 170, 178, 214, 74, 1, 96,
151, 167, 25, 173, 180, 171, 155, 10, 55, 142, 234, 190, 113, 90, 79, 80, 244, 71, 166,
30, 235, 113, 150, 133, 1, 218, 17, 109, 111, 223, 24, 216, 177, 41, 2, 204, 65, 221,
212, 207, 236, 144, 6, 65, 224, 55, 42, 1, 1, 161, 134, 118, 127, 111, 220, 110, 127,
240, 71, 223, 129, 12, 93, 20, 220, 60, 56, 71, 146, 184, 95, 132, 69, 28, 56, 53, 192,
213, 22, 119, 230, 152, 225, 182, 188, 163, 219, 37, 175, 247, 73, 14, 247, 38, 72,
243, 1, 48, 131, 59, 8, 13, 96, 143, 185, 127, 241, 161, 217, 24, 149, 193, 40, 16, 30,
202, 151, 28, 119, 240, 153, 101, 156, 61, 193, 72, 245, 199, 181, 12, 231, 65, 166,
67, 142, 121, 207, 202, 58, 197, 113, 188, 248, 42, 124, 105, 48, 161, 241, 55, 209,
36, 194, 27, 63, 233, 144, 189, 85, 117, 234, 9, 139, 46, 31, 206, 114, 95, 131, 29,
240, 13, 81, 142, 140, 133, 33, 30, 41, 141, 37, 80, 217, 95, 221, 76, 115, 86, 201,
165, 51, 252, 9, 28, 209, 1, 48, 150, 74, 248, 212, 187, 222, 66, 210, 3, 200, 19, 217,
171, 184, 42, 148, 53, 150, 57, 50, 6, 227, 227, 62, 49, 42, 148, 148, 157, 82, 191,
58, 24, 34, 56, 98, 120, 89, 105, 176, 85, 15, 253, 241, 41, 153, 195, 136, 1, 48, 142,
126, 213, 101, 223, 79, 133, 230, 105, 38, 161, 149, 2, 21, 136, 150, 42, 72, 218, 85,
146, 63, 223, 58, 108, 186, 183, 248, 62, 20, 47, 34, 113, 160, 177, 204, 181, 16, 24,
212, 224, 35, 84, 51, 168, 56, 136, 11, 1, 48, 135, 242, 62, 149, 230, 178, 32, 224,
119, 26, 234, 163, 237, 224, 114, 95, 112, 140, 170, 150, 96, 125, 136, 221, 180, 78,
18, 11, 12, 184, 2, 198, 217, 119, 43, 69, 4, 172, 109, 55, 183, 40, 131, 172, 161, 88,
183, 101, 1, 48, 173, 216, 22, 73, 42, 255, 211, 93, 249, 87, 159, 115, 61, 91, 55,
130, 17, 216, 60, 34, 122, 55, 8, 244, 244, 153, 151, 57, 5, 144, 178, 55, 249, 64,
211, 168, 34, 148, 56, 89, 92, 203, 70, 124, 219, 152, 253, 165, 0, 32, 203, 116, 63,
7, 240, 222, 82, 86, 11, 149, 167, 72, 224, 55, 190, 66, 201, 65, 168, 184, 96, 47,
194, 241, 168, 124, 7, 74, 214, 250, 37, 76, 32, 218, 69, 122, 103, 215, 145, 169, 24,
212, 229, 168, 106, 10, 144, 31, 13, 25, 178, 242, 250, 106, 159, 40, 48, 163, 165, 61,
130, 57, 146, 4, 73, 32, 254, 233, 125, 135, 212, 29, 111, 4, 177, 114, 15, 210, 170,
82, 108, 110, 62, 166, 81, 209, 106, 176, 156, 14, 133, 242, 60, 127, 120, 242, 28, 97,
0, 1, 32, 103, 93, 109, 89, 240, 91, 1, 84, 150, 50, 206, 157, 203, 49, 220, 120, 234,
175, 234, 150, 126, 225, 94, 163, 164, 199, 138, 114, 62, 99, 106, 112, 1, 32, 171, 40,
220, 82, 241, 203, 76, 146, 111, 139, 182, 179, 237, 182, 115, 75, 128, 201, 107, 43,
214, 0, 135, 217, 160, 68, 150, 232, 144, 114, 237, 98, 32, 30, 134, 232, 59, 93, 163,
253, 244, 13, 202, 52, 147, 168, 83, 121, 123, 95, 21, 210, 209, 225, 223, 143, 49, 10,
205, 238, 1, 22, 83, 81, 70, 1, 32, 26, 76, 6, 234, 160, 50, 139, 102, 161, 232, 155,
106, 130, 171, 226, 210, 233, 178, 85, 247, 71, 123, 55, 53, 46, 67, 148, 137, 156,
207, 208, 107, 1, 32, 102, 31, 4, 98, 110, 156, 144, 61, 229, 140, 198, 84, 196, 238,
128, 35, 131, 182, 137, 125, 241, 95, 69, 131, 170, 27, 2, 144, 75, 72, 242, 102, 3,
32, 121, 80, 45, 173, 56, 65, 218, 27, 40, 251, 197, 32, 169, 104, 123, 110, 90, 78,
153, 166, 38, 9, 129, 228, 99, 8, 1, 116, 142, 233, 162, 69, 32, 216, 169, 159, 116,
95, 12, 63, 176, 195, 6, 183, 123, 135, 75, 61, 112, 106, 83, 235, 176, 41, 27, 248,
48, 71, 165, 170, 12, 92, 103, 103, 81, 32, 58, 74, 75, 145, 192, 94, 153, 69, 80, 128,
241, 3, 16, 117, 192, 86, 161, 103, 44, 174, 211, 196, 182, 124, 55, 11, 107, 142, 49,
88, 6, 41, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 0, 37, 139, 240, 0, 0, 0, 0, 0,
0, 0, 1,
];
let mut credential = CredentialSpendingData::try_from_bytes(&CREDENTIAL_BYTES)
.expect("Failed to deserialize test credential - this is a bug in the test harness");
// Update spend_date to today to pass validation
credential.spend_date = OffsetDateTime::now_utc().date();
Ok(PreparedCredential {
data: credential,
epoch_id: 0,
metadata: PreparedCredentialMetadata {
ticketbook_id: 0,
tickets_withdrawn: 1,
used_tickets: 0,
},
})
}
async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
Ok(None)
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub(crate) enum CommonConfigsWrapper {
// nym-api
NymApi(NymApiConfigLight),
// anything else that might get get introduced
// anything else that might get introduced
Unknown(UnknownConfigWrapper),
}
@@ -30,6 +30,7 @@ nym-crypto = { path = "../crypto", features = ["asymmetric"] }
nym-ecash-contract-common = { path = "../cosmwasm-smart-contracts/ecash-contract" }
nym-gateway-requests = { path = "../gateway-requests" }
nym-gateway-storage = { path = "../gateway-storage" }
nym-metrics = { path = "../nym-metrics" }
nym-task = { path = "../task" }
nym-validator-client = { path = "../client-libs/validator-client" }
nym-upgrade-mode-check = { path = "../upgrade-mode-check" }
@@ -59,9 +59,13 @@ impl traits::EcashManager for EcashManager {
.verify(aggregated_verification_key)
.map_err(|err| match err {
CompactEcashError::ExpirationDateSignatureValidity => {
nym_metrics::inc!("ecash_verification_failures_invalid_date_signature");
EcashTicketError::MalformedTicketInvalidDateSignatures
}
_ => EcashTicketError::MalformedTicket,
_ => {
nym_metrics::inc!("ecash_verification_failures_signature");
EcashTicketError::MalformedTicket
}
})?;
self.insert_pay_info(credential.pay_info.into(), insert_index)
@@ -170,14 +174,14 @@ impl EcashManager {
}
pub struct MockEcashManager {
verfication_key: tokio::sync::RwLock<VerificationKeyAuth>,
verification_key: tokio::sync::RwLock<VerificationKeyAuth>,
storage: Box<dyn BandwidthGatewayStorage + Send + Sync>,
}
impl MockEcashManager {
pub fn new(storage: Box<dyn BandwidthGatewayStorage + Send + Sync>) -> Self {
Self {
verfication_key: tokio::sync::RwLock::new(
verification_key: tokio::sync::RwLock::new(
VerificationKeyAuth::from_bytes(&[
129, 187, 76, 12, 1, 51, 46, 26, 132, 205, 148, 109, 140, 131, 50, 119, 45,
128, 51, 218, 106, 70, 181, 74, 244, 38, 162, 62, 42, 12, 5, 100, 7, 136, 32,
@@ -233,7 +237,7 @@ impl traits::EcashManager for MockEcashManager {
&self,
_epoch_id: EpochId,
) -> Result<RwLockReadGuard<'_, VerificationKeyAuth>, EcashTicketError> {
Ok(self.verfication_key.read().await)
Ok(self.verification_key.read().await)
}
fn storage(&self) -> Box<dyn BandwidthGatewayStorage + Send + Sync> {
@@ -249,4 +253,8 @@ impl traits::EcashManager for MockEcashManager {
}
fn async_verify(&self, _ticket: ClientTicket) {}
fn is_mock(&self) -> bool {
true
}
}
@@ -222,9 +222,13 @@ impl SharedState {
RwLockReadGuard::try_map(guard, |data| data.get(&epoch_id).map(|d| &d.master_key))
{
trace!("we already had cached api clients for epoch {epoch_id}");
nym_metrics::inc!("ecash_verification_key_cache_hits");
return Ok(mapped);
}
// Cache miss - need to fetch and set epoch data
nym_metrics::inc!("ecash_verification_key_cache_misses");
let write_guard = self.set_epoch_data(epoch_id).await?;
let guard = write_guard.downgrade();
@@ -20,4 +20,10 @@ pub trait EcashManager {
aggregated_verification_key: &VerificationKeyAuth,
) -> Result<(), EcashTicketError>;
fn async_verify(&self, ticket: ClientTicket);
/// Returns true if this is a mock ecash manager (for local testing).
/// Default implementation returns false.
fn is_mock(&self) -> bool {
false
}
}
+37 -2
View File
@@ -8,6 +8,7 @@ use nym_credentials::ecash::utils::{EcashTime, cred_exp_date, ecash_today};
use nym_credentials_interface::{Bandwidth, ClientTicket, TicketType};
use nym_gateway_requests::models::CredentialSpendingRequest;
use std::sync::Arc;
use std::time::Instant;
use time::{Date, OffsetDateTime};
use tracing::*;
@@ -21,6 +22,10 @@ pub mod ecash;
pub mod error;
pub mod upgrade_mode;
// Histogram buckets for ecash verification duration (in seconds)
const ECASH_VERIFICATION_DURATION_BUCKETS: &[f64] =
&[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0];
pub struct CredentialVerifier {
credential: CredentialSpendingRequest,
ecash_verifier: Arc<dyn EcashManager + Send + Sync>,
@@ -64,6 +69,7 @@ impl CredentialVerifier {
.await?;
if spent {
trace!("the credential has already been spent before at this gateway");
nym_metrics::inc!("ecash_verification_failures_double_spending");
return Err(Error::BandwidthCredentialAlreadySpent);
}
Ok(())
@@ -105,6 +111,9 @@ impl CredentialVerifier {
}
pub async fn verify(&mut self) -> Result<i64> {
let start = Instant::now();
nym_metrics::inc!("ecash_verification_attempts");
let received_at = OffsetDateTime::now_utc();
let spend_date = ecash_today();
@@ -113,15 +122,39 @@ impl CredentialVerifier {
let credential_type = TicketType::try_from_encoded(self.credential.data.payment.t_type)?;
if self.credential.data.payment.spend_value != 1 {
nym_metrics::inc!("ecash_verification_failures_multiple_tickets");
return Err(Error::MultipleTickets);
}
self.check_credential_spending_date(spend_date.ecash_date())?;
if let Err(e) = self.check_credential_spending_date(spend_date.ecash_date()) {
nym_metrics::inc!("ecash_verification_failures_invalid_spend_date");
return Err(e);
}
self.check_local_db_for_double_spending(&serial_number)
.await?;
// TODO: do we HAVE TO do it?
self.cryptographically_verify_ticket().await?;
let verify_result = self.cryptographically_verify_ticket().await;
// Track verification duration
let duration = start.elapsed().as_secs_f64();
nym_metrics::add_histogram_obs!(
"ecash_verification_duration_seconds",
duration,
ECASH_VERIFICATION_DURATION_BUCKETS
);
// Track epoch ID - use dynamic metric name via registry
let epoch_id = self.credential.data.epoch_id;
let epoch_metric = format!(
"nym_credential_verification_ecash_epoch_{}_verifications",
epoch_id
);
nym_metrics::metrics_registry().maybe_register_and_inc(&epoch_metric, None);
// Check verification result after timing
verify_result?;
let ticket_id = self.store_received_ticket(received_at).await?;
self.async_verify_ticket(ticket_id);
@@ -135,6 +168,8 @@ impl CredentialVerifier {
.increase_bandwidth(bandwidth, cred_exp_date())
.await?;
nym_metrics::inc!("ecash_verification_success");
Ok(self
.bandwidth_storage_manager
.client_bandwidth
+16 -1
View File
@@ -3,6 +3,7 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use thiserror::Error;
use time::{Date, OffsetDateTime};
@@ -73,7 +74,7 @@ pub struct CredentialSigningData {
pub ticketbook_type: TicketType,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[derive(Serialize, Deserialize, PartialEq, Clone)]
pub struct CredentialSpendingData {
pub payment: Payment,
@@ -86,6 +87,20 @@ pub struct CredentialSpendingData {
pub epoch_id: u64,
}
impl Debug for CredentialSpendingData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// we're redacting the payment not since it contains secret,
// but because it's producing a lot of noise in the output and
// we are not really interested in coordinates of each of the attached curve points
f.debug_struct("CredentialSpendingData")
.field("payment", &"[REDACTED]")
.field("pay_info", &self.pay_info)
.field("spend_date", &self.spend_date)
.field("epoch_id", &self.epoch_id)
.finish()
}
}
impl CredentialSpendingData {
pub fn verify(&self, verification_key: &VerificationKeyAuth) -> Result<(), CompactEcashError> {
self.payment.spend_verify(
+2 -1
View File
@@ -15,6 +15,7 @@ base64.workspace = true
bs58 = { workspace = true }
blake3 = { workspace = true, features = ["traits-preview"], optional = true }
ctr = { workspace = true, optional = true }
curve25519-dalek = { workspace = true, optional = true }
digest = { workspace = true, optional = true }
generic-array = { workspace = true, optional = true }
hkdf = { workspace = true, optional = true }
@@ -47,7 +48,7 @@ default = []
aead = ["dep:aead", "aead/std", "aes-gcm-siv", "generic-array"]
naive_jwt = ["asymmetric", "jwt-simple"]
serde = ["dep:serde", "serde_bytes", "ed25519-dalek/serde", "x25519-dalek/serde"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "zeroize"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "curve25519-dalek", "sha2", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2"]
stream_cipher = ["aes", "ctr", "cipher", "generic-array"]
sphinx = ["nym-sphinx-types/sphinx"]
+115 -3
View File
@@ -20,6 +20,7 @@ pub use serde_helpers::*;
#[cfg(feature = "sphinx")]
use nym_sphinx_types::{DESTINATION_ADDRESS_LENGTH, DestinationAddressBytes};
use crate::asymmetric::x25519;
#[cfg(feature = "rand")]
use rand::{CryptoRng, Rng, RngCore};
#[cfg(feature = "serde")]
@@ -110,6 +111,18 @@ impl KeyPair {
index: fake_index(pub_bytes),
})
}
/// Converts this Ed25519 keypair to an X25519 keypair for ECDH.
///
/// Uses the standard ed25519→x25519 conversion via SHA-512 hash and clamping.
/// This is the same approach as libsodium's `crypto_sign_ed25519_sk_to_curve25519`.
///
/// # Returns
/// The converted X25519 keypair
pub fn to_x25519(&self) -> x25519::KeyPair {
let private_key = self.private_key.to_x25519();
x25519::KeyPair::from(private_key)
}
}
/// Reduces a byte slice into a u32 value by XOR-ing all its bytes into a 4-byte accumulator.
@@ -136,6 +149,16 @@ impl From<PrivateKey> for KeyPair {
}
}
impl From<(PrivateKey, PublicKey)> for KeyPair {
fn from((private_key, public_key): (PrivateKey, PublicKey)) -> Self {
KeyPair {
private_key,
public_key,
index: fake_index(public_key.to_bytes().as_ref()),
}
}
}
impl PemStorableKeyPair for KeyPair {
type PrivatePemKey = PrivateKey;
type PublicPemKey = PublicKey;
@@ -185,14 +208,25 @@ impl PublicKey {
}
/// Convert this public key to a byte array.
#[inline]
pub fn to_bytes(self) -> [u8; PUBLIC_KEY_LENGTH] {
self.0.to_bytes()
}
/// View this public key as a byte array.
#[inline]
pub fn as_bytes(&self) -> &[u8; PUBLIC_KEY_LENGTH] {
self.0.as_bytes()
}
#[inline]
pub fn from_bytes(b: &[u8]) -> Result<Self, Ed25519RecoveryError> {
Ok(PublicKey(ed25519_dalek::VerifyingKey::from_bytes(
b.try_into()?,
)?))
Self::from_byte_array(b.try_into()?)
}
#[inline]
pub fn from_byte_array(b: &[u8; PUBLIC_KEY_LENGTH]) -> Result<Self, Ed25519RecoveryError> {
Ok(PublicKey(ed25519_dalek::VerifyingKey::from_bytes(b)?))
}
pub fn to_base58_string(self) -> String {
@@ -213,6 +247,37 @@ impl PublicKey {
) -> Result<(), SignatureError> {
self.0.verify(message.as_ref(), &signature.0)
}
/// Converts this Ed25519 public key to an X25519 public key for ECDH.
///
/// Uses the standard ed25519→x25519 conversion by converting the Edwards point
/// to Montgomery form. This is the same approach as libsodium's
/// `crypto_sign_ed25519_pk_to_curve25519`.
///
/// # Returns
/// * `Ok(x25519::PublicKey)` - The converted X25519 public key
/// * `Err(Ed25519RecoveryError)` - If the conversion fails (e.g., low-order point)
pub fn to_x25519(&self) -> Result<crate::asymmetric::x25519::PublicKey, Ed25519RecoveryError> {
use curve25519_dalek::edwards::CompressedEdwardsY;
// Decompress the Ed25519 point
let compressed = CompressedEdwardsY((*self).to_bytes());
let edwards_point = compressed.decompress().ok_or_else(|| {
Ed25519RecoveryError::MalformedBytes(SignatureError::from_source(
"Failed to decompress Ed25519 point".to_string(),
))
})?;
// Convert to Montgomery form
let montgomery = edwards_point.to_montgomery();
// Create X25519 public key
crate::asymmetric::x25519::PublicKey::from_bytes(montgomery.as_bytes()).map_err(|_| {
Ed25519RecoveryError::MalformedBytes(SignatureError::from_source(
"Failed to convert to X25519".to_string(),
))
})
}
}
#[cfg(feature = "sphinx")]
@@ -334,6 +399,30 @@ impl PrivateKey {
let signature_bytes = self.sign(text).to_bytes();
bs58::encode(signature_bytes).into_string()
}
/// Converts this Ed25519 private key to an X25519 private key for ECDH.
///
/// Uses the standard ed25519→x25519 conversion via SHA-512 hash and clamping.
/// This is the same approach as libsodium's `crypto_sign_ed25519_sk_to_curve25519`.
///
/// # Returns
/// The converted X25519 private key
pub fn to_x25519(&self) -> crate::asymmetric::x25519::PrivateKey {
use sha2::{Digest, Sha512};
// Hash the Ed25519 secret key with SHA-512
// Both hash and x25519_bytes wrapped in Zeroizing to clear key material
let mut hash = zeroize::Zeroizing::new([0u8; 64]);
hash.copy_from_slice(&Sha512::digest(self.0));
// Take first 32 bytes (clamping is done automatically by x25519_dalek::StaticSecret)
let mut x25519_bytes = zeroize::Zeroizing::new([0u8; 32]);
x25519_bytes.copy_from_slice(&hash[..32]);
#[allow(clippy::expect_used)]
crate::asymmetric::x25519::PrivateKey::from_bytes(&*x25519_bytes)
.expect("x25519 key conversion should never fail")
}
}
#[cfg(feature = "serde")]
@@ -517,4 +606,27 @@ mod tests {
assert_eq!(sig1.to_vec(), sig2);
}
#[test]
#[cfg(feature = "rand")]
fn test_ed25519_to_x25519_ecdh() {
let mut rng = thread_rng();
// Create two ed25519 keypairs
let alice_ed = KeyPair::new(&mut rng);
let bob_ed = KeyPair::new(&mut rng);
// Convert to x25519
let alice_x25519_private = alice_ed.private_key().to_x25519();
let alice_x25519_public = alice_ed.public_key().to_x25519().unwrap();
let bob_x25519_private = bob_ed.private_key().to_x25519();
let bob_x25519_public = bob_ed.public_key().to_x25519().unwrap();
// Perform ECDH both ways
let alice_shared = alice_x25519_private.diffie_hellman(&bob_x25519_public);
let bob_shared = bob_x25519_private.diffie_hellman(&alice_x25519_public);
// Both should produce the same shared secret
assert_eq!(alice_shared, bob_shared);
}
}
+45 -1
View File
@@ -4,6 +4,7 @@
use base64::Engine;
use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair};
use std::fmt::{self, Debug, Display, Formatter};
use std::ops::Deref;
use std::str::FromStr;
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop};
@@ -56,6 +57,15 @@ pub struct KeyPair {
pub(crate) public_key: PublicKey,
}
impl Debug for KeyPair {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("KeyPair")
.field("private_key", &"<redacted>")
.field("public_key", &self.public_key.to_base58_string())
.finish()
}
}
impl KeyPair {
#[cfg(feature = "rand")]
pub fn new<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
@@ -93,6 +103,15 @@ impl From<PrivateKey> for KeyPair {
}
}
impl From<(PrivateKey, PublicKey)> for KeyPair {
fn from((private_key, public_key): (PrivateKey, PublicKey)) -> Self {
KeyPair {
private_key,
public_key,
}
}
}
impl PemStorableKeyPair for KeyPair {
type PrivatePemKey = PrivateKey;
type PublicPemKey = PublicKey;
@@ -116,6 +135,13 @@ impl PemStorableKeyPair for KeyPair {
#[derive(PartialEq, Eq, Hash, Copy, Clone)]
pub struct PublicKey(x25519_dalek::PublicKey);
impl Deref for PublicKey {
type Target = x25519_dalek::PublicKey;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Display for PublicKey {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
Display::fmt(&self.to_base58_string(), f)
@@ -129,14 +155,17 @@ impl Debug for PublicKey {
}
impl PublicKey {
#[inline]
pub fn to_bytes(self) -> [u8; PUBLIC_KEY_SIZE] {
*self.0.as_bytes()
}
#[inline]
pub fn as_bytes(&self) -> &[u8; PUBLIC_KEY_SIZE] {
self.0.as_bytes()
}
#[inline]
pub fn from_bytes(b: &[u8]) -> Result<Self, KeyRecoveryError> {
if b.len() != PUBLIC_KEY_SIZE {
return Err(KeyRecoveryError::InvalidSizePublicKey {
@@ -146,7 +175,12 @@ impl PublicKey {
}
let mut bytes = [0; PUBLIC_KEY_SIZE];
bytes.copy_from_slice(&b[..PUBLIC_KEY_SIZE]);
Ok(Self(x25519_dalek::PublicKey::from(bytes)))
Ok(Self::from_byte_array(&bytes))
}
#[inline]
pub fn from_byte_array(b: &[u8; PUBLIC_KEY_SIZE]) -> Self {
Self(x25519_dalek::PublicKey::from(*b))
}
pub fn to_base58_string(self) -> String {
@@ -174,6 +208,12 @@ impl PublicKey {
}
}
impl From<[u8; PUBLIC_KEY_SIZE]> for PublicKey {
fn from(bytes: [u8; PUBLIC_KEY_SIZE]) -> Self {
PublicKey(x25519_dalek::PublicKey::from(bytes))
}
}
impl FromStr for PublicKey {
type Err = KeyRecoveryError;
@@ -296,6 +336,10 @@ impl PrivateKey {
Ok(Self(x25519_dalek::StaticSecret::from(bytes)))
}
pub fn from_secret(secret: [u8; PRIVATE_KEY_SIZE]) -> Self {
Self(x25519_dalek::StaticSecret::from(secret))
}
pub fn to_base58_string(&self) -> String {
bs58::encode(&self.to_bytes()).into_string()
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Key Derivation Functions using Blake3.
/// Derives a 32-byte key using Blake3's key derivation mode.
///
/// Uses Blake3's built-in `derive_key` function with domain separation via context string.
///
/// # Arguments
/// * `context` - Context string for domain separation (e.g., "nym-lp-psk-v1")
/// * `key_material` - Input key material (shared secret from ECDH, etc.)
/// * `salt` - Additional salt for freshness (timestamp + nonce)
///
/// # Returns
/// 32-byte derived key suitable for use as PSK
///
/// # Example
/// ```ignore
/// let psk = derive_key_blake3("nym-lp-psk-v1", shared_secret.as_bytes(), &salt);
/// ```
pub fn derive_key_blake3(context: &str, key_material: &[u8], salt: &[u8]) -> [u8; 32] {
// Concatenate key_material and salt as input
let input = [key_material, salt].concat();
// Use Blake3's derive_key with context for domain separation
// blake3::derive_key returns [u8; 32] directly
blake3::derive_key(context, &input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deterministic_derivation() {
let context = "test-context";
let key_material = b"shared_secret_12345";
let salt = b"salt_67890";
let key1 = derive_key_blake3(context, key_material, salt);
let key2 = derive_key_blake3(context, key_material, salt);
assert_eq!(key1, key2, "Same inputs should produce same output");
}
#[test]
fn test_different_contexts_produce_different_keys() {
let key_material = b"shared_secret";
let salt = b"salt";
let key1 = derive_key_blake3("context1", key_material, salt);
let key2 = derive_key_blake3("context2", key_material, salt);
assert_ne!(
key1, key2,
"Different contexts should produce different keys"
);
}
#[test]
fn test_different_salts_produce_different_keys() {
let context = "test-context";
let key_material = b"shared_secret";
let key1 = derive_key_blake3(context, key_material, b"salt1");
let key2 = derive_key_blake3(context, key_material, b"salt2");
assert_ne!(key1, key2, "Different salts should produce different keys");
}
#[test]
fn test_different_key_material_produces_different_keys() {
let context = "test-context";
let salt = b"salt";
let key1 = derive_key_blake3(context, b"secret1", salt);
let key2 = derive_key_blake3(context, b"secret2", salt);
assert_ne!(
key1, key2,
"Different key material should produce different keys"
);
}
#[test]
fn test_output_length() {
let key = derive_key_blake3("test", b"key", b"salt");
assert_eq!(key.len(), 32, "Output should be exactly 32 bytes");
}
#[test]
fn test_empty_inputs() {
// Should not panic with empty inputs
let key = derive_key_blake3("test", b"", b"");
assert_eq!(key.len(), 32);
}
}
+2
View File
@@ -10,6 +10,8 @@ pub mod crypto_hash;
pub mod hkdf;
#[cfg(feature = "hashing")]
pub mod hmac;
#[cfg(feature = "hashing")]
pub mod kdf;
#[cfg(all(feature = "asymmetric", feature = "hashing", feature = "stream_cipher"))]
pub mod shared_key;
pub mod symmetric;
+7
View File
@@ -133,6 +133,13 @@ impl GatewayStorage {
}
};
Self::from_connection_pool(connection_pool, message_retrieval_limit).await
}
pub async fn from_connection_pool(
connection_pool: sqlx::sqlite::SqlitePool,
message_retrieval_limit: i64,
) -> Result<Self, GatewayStorageError> {
if let Err(err) = sqlx::migrate!("./migrations").run(&connection_pool).await {
error!("Failed to perform migration on the SQLx database: {err}");
return Err(err.into());
@@ -150,6 +150,10 @@ impl OutputParams {
pub fn get_output(&self) -> Output {
self.output.unwrap_or_default()
}
pub fn to_response<T: Serialize>(self, data: T) -> FormattedResponse<T> {
self.get_output().to_response(data)
}
}
impl Output {
+1 -1
View File
@@ -7,7 +7,7 @@ use nym_sdk::mixnet::{MixnetClientSender, Recipient};
use tokio_util::sync::CancellationToken;
use tracing::info;
// Import these here for for all modules to use, to keep the version consistent
// Import these here for all modules to use, to keep the version consistent
pub(crate) use nym_ip_packet_requests::v8 as nym_ip_packet_requests_current;
mod error;
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "nym-kcp"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[lib]
name = "nym_kcp"
path = "src/lib.rs"
[[bin]]
name = "wire_format"
path = "bin/wire_format/main.rs"
[[bin]]
name = "session"
path = "bin/session/main.rs"
[dependencies]
tokio-util = { workspace = true, features = ["codec"] }
bytes = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
ansi_term = { workspace = true }
[dev-dependencies]
env_logger = "0.11"
+80
View File
@@ -0,0 +1,80 @@
use bytes::BytesMut;
use log::info;
use nym_kcp::{packet::KcpPacket, session::KcpSession};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create two KcpSessions, simulating two endpoints
let mut local_sess = KcpSession::new(42);
let mut remote_sess = KcpSession::new(42);
// Set an MSS (max segment size) smaller than our data to force fragmentation
local_sess.set_mtu(40);
remote_sess.set_mtu(40);
// Some data larger than 30 bytes to demonstrate multi-fragment
let big_data = b"The quick brown fox jumps over the lazy dog. This is a test.";
// --- LOCAL sends data ---
info!(
"Local: sending data: {:?}",
String::from_utf8_lossy(big_data)
);
local_sess.send(big_data);
// Update local session's logic at time=0
local_sess.update(100);
// LOCAL fetches outgoing (to be sent across the network)
let outgoing_pkts = local_sess.fetch_outgoing();
info!("Local: outgoing pkts: {:?}", outgoing_pkts);
// Here you'd normally encrypt and send them. Well just encode them into a buffer.
// Then that buffer is "transferred" to the remote side.
let mut wire_buf = BytesMut::new();
for pkt in &outgoing_pkts {
pkt.encode(&mut wire_buf);
}
// --- REMOTE receives data ---
// The remote side "decrypts" (here we just clone) and decodes
let mut remote_in = wire_buf.clone();
// Decode zero or more KcpPackets from remote_in
while let Some(decoded_pkt) = KcpPacket::decode(&mut remote_in)? {
info!(
"Decoded packet, sn: {}, frg: {}",
decoded_pkt.sn(),
decoded_pkt.frg()
);
remote_sess.input(&decoded_pkt)?;
}
// Update remote session to process newly received data
remote_sess.update(100);
// The remote session likely generated ACK packets
let ack_pkts = remote_sess.fetch_outgoing();
// --- LOCAL receives ACKs ---
// The local side decodes them
let mut ack_buf = BytesMut::new();
for pkt in &ack_pkts {
pkt.encode(&mut ack_buf);
}
while let Some(decoded_pkt) = KcpPacket::decode(&mut ack_buf)? {
local_sess.input(&decoded_pkt)?;
}
// Update local again with some arbitrary time, e.g. 50 ms later
local_sess.update(100);
// Just for completeness, local might produce more packets, though typically it's just empty now
let _ = local_sess.fetch_outgoing();
// --- REMOTE reads reassembled data ---
let incoming = remote_sess.fetch_incoming();
info!("Remote: incoming pkts: {:?}", incoming);
Ok(())
}
+83
View File
@@ -0,0 +1,83 @@
use std::{
fs::File,
io::{BufRead as _, BufReader},
};
use bytes::BytesMut;
use log::info;
use nym_kcp::{
codec::KcpCodec,
packet::{KcpCommand, KcpPacket},
};
use tokio_util::codec::{Decoder as _, Encoder as _};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1) Open a file and read lines
let file = File::open("bin/wire_format/packets.txt")?;
let reader = BufReader::new(file);
// 2) Create our KcpCodec
let mut codec = KcpCodec {};
// We'll use out_buf for encoded data from *all* lines
let mut out_buf = BytesMut::new();
let mut input_lines = vec![];
// Read lines & encode them all
for (i, line) in reader.lines().enumerate() {
let line = line?;
info!("Original line #{}: {}", i + 1, line);
// Construct a KcpPacket
let pkt = KcpPacket::new(
42,
KcpCommand::Push,
0,
128,
0,
i as u32,
0,
line.as_bytes().to_vec(),
);
input_lines.push(pkt.clone_data());
// Encode (serialize) the packet into out_buf
codec.encode(pkt, &mut out_buf)?;
}
// === Simulate encryption & transmission ===
// In reality, you might do `encrypt(&out_buf)` and then
// send it over the network. We'll just clone here:
let mut received_buf = out_buf.clone();
// 3) Now decode (deserialize) all packets at once
// For demonstration, read them back out
let mut count = 0;
let mut decoded_lines = vec![];
#[allow(clippy::while_let_loop)]
loop {
match codec.decode(&mut received_buf)? {
Some(decoded_pkt) => {
count += 1;
// Convert packet data back to a string
let decoded_str = String::from_utf8_lossy(decoded_pkt.data());
info!("Decoded line #{}: {}", decoded_pkt.sn() + 1, decoded_str);
decoded_lines.push(decoded_pkt.clone_data());
}
None => break,
}
}
for (i, j) in input_lines.iter().zip(decoded_lines.iter()) {
assert_eq!(i, j);
}
info!("Decoded {} lines total.", count);
Ok(())
}
@@ -0,0 +1,10 @@
packet 1
packet 2
packet 3
packet 4
packet 5
packet 6
packet 7
packet 8
packet 9
packet 10
+30
View File
@@ -0,0 +1,30 @@
use std::io;
use bytes::BytesMut;
use tokio_util::codec::{Decoder, Encoder};
use super::packet::KcpPacket;
/// Our codec for encoding/decoding KCP packets
#[derive(Debug, Default)]
pub struct KcpCodec;
impl Decoder for KcpCodec {
type Item = KcpPacket;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
// We simply delegate to `KcpPacket::decode`
KcpPacket::decode(src).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
}
impl Encoder<KcpPacket> for KcpCodec {
type Error = io::Error;
fn encode(&mut self, item: KcpPacket, dst: &mut BytesMut) -> Result<(), Self::Error> {
// We just call `item.encode` to append the bytes
item.encode(dst);
Ok(())
}
}
+75
View File
@@ -0,0 +1,75 @@
use bytes::BytesMut;
use log::{debug, trace};
use crate::{error::KcpError, packet::KcpPacket, session::KcpSession};
pub struct KcpDriver {
session: KcpSession,
buffer: BytesMut,
}
impl KcpDriver {
pub fn conv_id(&self) -> Result<u32, KcpError> {
Ok(self.session.conv)
}
pub fn send(&mut self, data: &[u8]) {
self.session.send(data);
}
pub fn input(&mut self, data: &[u8]) -> Result<Vec<KcpPacket>, KcpError> {
self.buffer.extend_from_slice(data);
let mut pkts = Vec::new();
while let Ok(Some(pkt)) = KcpPacket::decode(&mut self.buffer) {
debug!(
"Decoded packet, cmd: {}, sn: {}, frg: {}",
pkt.command(),
pkt.sn(),
pkt.frg()
);
self._input(&pkt)?;
pkts.push(pkt);
}
Ok(pkts)
}
fn _input(&mut self, pkt: &KcpPacket) -> Result<(), KcpError> {
self.session.input(pkt)
}
pub fn fetch_outgoing(&mut self) -> Vec<KcpPacket> {
trace!(
"ts_flush: {}, ts_current: {}",
self.session.ts_flush(),
self.session.ts_current()
);
self.session.fetch_outgoing()
}
pub fn update(&mut self, tick: u64) {
self.session.update(tick as u32);
}
pub fn new(session: KcpSession) -> Self {
KcpDriver {
session,
buffer: BytesMut::new(),
}
}
/// Fetch any complete messages that have been reassembled from received KCP packets.
///
/// Returns a vector of complete messages. Messages are only returned once all
/// fragments have been received and reassembled.
pub fn fetch_incoming(&mut self) -> Vec<BytesMut> {
self.session.fetch_incoming()
}
/// Read reassembled data into a buffer.
///
/// Returns the number of bytes read into the buffer.
/// If no complete message is available, returns 0.
pub fn recv(&mut self, buf: &mut [u8]) -> usize {
self.session.recv(buf)
}
}
+13
View File
@@ -0,0 +1,13 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum KcpError {
#[error("Invalid KCP command value: {0}")]
InvalidCommand(u8),
#[error("Conversation ID mismatch: expected {expected}, received {received}")]
ConvMismatch { expected: u32, received: u32 },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
+7
View File
@@ -0,0 +1,7 @@
pub mod codec;
pub mod driver;
pub mod error;
pub mod packet;
pub mod session;
pub const MAX_RTO: u32 = 60000; // Same as used in update_rtt
+224
View File
@@ -0,0 +1,224 @@
use bytes::{Buf, BufMut, BytesMut};
use log::{debug, trace};
use super::error::KcpError;
// Nym-KCP uses a modified header format with u16 for frg field (25 bytes total).
// Standard KCP uses u8 for frg (24 bytes). This deviation from skywind3000/kcp protocol
// supports messages up to ~91MB (65535 fragments × MTU) vs standard 355KB limit.
// This is intentional - Nym uses KCP internally for reliability/multiplexing, not interop.
pub const KCP_HEADER: usize = 25;
/// Typed enumeration for KCP commands.
#[repr(u8)]
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum KcpCommand {
Push = 81, // cmd: push data
Ack = 82, // cmd: ack
Wask = 83, // cmd: window probe (ask)
Wins = 84, // cmd: window size (tell)
}
impl std::fmt::Display for KcpCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KcpCommand::Push => write!(f, "Push"),
KcpCommand::Ack => write!(f, "Ack"),
KcpCommand::Wask => write!(f, "Window Probe (ask)"),
KcpCommand::Wins => write!(f, "Window Size (tell)"),
}
}
}
impl TryFrom<u8> for KcpCommand {
type Error = KcpError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
81 => Ok(KcpCommand::Push),
82 => Ok(KcpCommand::Ack),
83 => Ok(KcpCommand::Wask),
84 => Ok(KcpCommand::Wins),
_ => Err(KcpError::InvalidCommand(value)),
}
}
}
#[allow(clippy::from_over_into)]
impl Into<u8> for KcpCommand {
fn into(self) -> u8 {
self as u8
}
}
/// A single KCP packet (on-wire format).
/// Note: Nym-KCP uses u16 for frg (fragment count) instead of standard u8.
#[derive(Debug, Clone)]
pub struct KcpPacket {
conv: u32,
cmd: KcpCommand,
frg: u16,
wnd: u16,
ts: u32,
sn: u32,
una: u32,
data: Vec<u8>,
}
#[allow(clippy::too_many_arguments)]
impl KcpPacket {
pub fn new(
conv: u32,
cmd: KcpCommand,
frg: u16,
wnd: u16,
ts: u32,
sn: u32,
una: u32,
data: Vec<u8>,
) -> Self {
Self {
conv,
cmd,
frg,
wnd,
ts,
sn,
una,
data,
}
}
pub fn command(&self) -> KcpCommand {
self.cmd
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn clone_data(&self) -> Vec<u8> {
self.data.clone()
}
pub fn conv(&self) -> u32 {
self.conv
}
pub fn cmd(&self) -> KcpCommand {
self.cmd
}
pub fn frg(&self) -> u16 {
self.frg
}
pub fn wnd(&self) -> u16 {
self.wnd
}
pub fn ts(&self) -> u32 {
self.ts
}
pub fn sn(&self) -> u32 {
self.sn
}
pub fn una(&self) -> u32 {
self.una
}
}
impl Default for KcpPacket {
fn default() -> Self {
// We must pick some default command, e.g. `Push`.
// Or omit `Default` if you don't need it.
KcpPacket {
conv: 0,
cmd: KcpCommand::Push,
frg: 0,
wnd: 0,
ts: 0,
sn: 0,
una: 0,
data: Vec::new(),
}
}
}
impl KcpPacket {
/// Attempt to decode a `KcpPacket` from `src`.
/// Returns Ok(Some(pkt)) if fully available, Ok(None) if not enough data,
/// or Err(...) if there's an invalid command or other error.
pub fn decode(src: &mut BytesMut) -> Result<Option<Self>, KcpError> {
trace!("Decoding buffer with len: {}", src.len());
if src.len() < KCP_HEADER {
// Not enough for even the header, this is usually fine, more data will arrive
debug!("Not enough data for header");
return Ok(None);
}
// Peek into the header (25 bytes for Nym-KCP)
let mut header = &src[..KCP_HEADER];
let conv = header.get_u32_le();
let cmd_byte = header.get_u8();
let frg = header.get_u16_le();
let wnd = header.get_u16_le();
let ts = header.get_u32_le();
let sn = header.get_u32_le();
let una = header.get_u32_le();
let len = header.get_u32_le() as usize;
let total_needed = KCP_HEADER + len;
if src.len() < total_needed {
// We don't have the full packet yet
debug!(
"Not enough data for packet, want {}, have {}",
total_needed,
src.len()
);
return Ok(None);
}
// Convert the raw u8 into our KcpCommand enum
let cmd = KcpCommand::try_from(cmd_byte)?;
// Now we can read out the data portion
let data = src[KCP_HEADER..KCP_HEADER + len].to_vec();
// Advance the buffer so it no longer contains this packet
src.advance(total_needed);
Ok(Some(Self {
conv,
cmd,
frg,
wnd,
ts,
sn,
una,
data,
}))
}
/// Encode this packet into `dst`.
pub fn encode(&self, dst: &mut BytesMut) {
let total_len = KCP_HEADER + self.data.len();
trace!("Encoding packet: {:?}, len: {}", self, total_len);
dst.reserve(total_len);
dst.put_u32_le(self.conv);
dst.put_u8(self.cmd.into()); // Convert enum -> u8
dst.put_u16_le(self.frg);
dst.put_u16_le(self.wnd);
dst.put_u32_le(self.ts);
dst.put_u32_le(self.sn);
dst.put_u32_le(self.una);
dst.put_u32_le(self.data.len() as u32);
dst.extend_from_slice(&self.data);
trace!("Encoded packet: {:?}, len: {}", dst, dst.len());
}
}
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "nym-kkt"
version = "0.1.0"
authors = ["Georgio Nicolas <georgio@nymtech.net>"]
edition = { workspace = true }
license.workspace = true
[dependencies]
blake3 = { workspace = true }
thiserror = { workspace = true }
num_enum = { workspace = true }
# internal
nym-crypto = { path = "../crypto", features = ["asymmetric", "serde"] }
libcrux-kem = { git = "https://github.com/cryspen/libcrux" }
libcrux-sha3 = { git = "https://github.com/cryspen/libcrux" }
libcrux-ecdh = { git = "https://github.com/cryspen/libcrux", features = ["codec"] }
libcrux-chacha20poly1305 = { git = "https://github.com/cryspen/libcrux" }
#rand = "0.9.2"
rand = "0.9.2"
zeroize = { workspace = true, features = ["zeroize_derive"] }
classic-mceliece-rust = { git = "https://github.com/georgio/classic-mceliece-rust", features = ["mceliece460896f", "zeroize"] }
[dev-dependencies]
rand_chacha = "0.9.0"
anyhow = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "benches"
harness = false
[lints]
workspace = true
+480
View File
@@ -0,0 +1,480 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// fine in benchmarking code
#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
use criterion::{Criterion, criterion_group, criterion_main};
use nym_crypto::asymmetric::ed25519;
use nym_kkt::{
ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM, SignatureScheme},
context::KKTMode,
frame::KKTFrame,
key_utils::{generate_keypair_libcrux, generate_keypair_mceliece, hash_encapsulation_key},
session::{
anonymous_initiator_process, initiator_ingest_response, initiator_process,
responder_ingest_message, responder_process,
},
};
use rand::prelude::*;
pub fn gen_ed25519_keypair(c: &mut Criterion) {
c.bench_function("Generate Ed25519 Keypair", |b| {
b.iter(|| {
let mut s: [u8; 32] = [0u8; 32];
rand::rng().fill_bytes(&mut s);
ed25519::KeyPair::from_secret(s, 0)
});
});
}
pub fn gen_mlkem768_keypair(c: &mut Criterion) {
c.bench_function("Generate MlKem768 Keypair", |b| {
b.iter(|| {
libcrux_kem::key_gen(libcrux_kem::Algorithm::MlKem768, &mut rand::rng()).unwrap()
});
});
}
pub fn kkt_benchmark(c: &mut Criterion) {
let mut rng = rand::rng();
// generate ed25519 keys
let mut secret_initiator: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut secret_initiator);
let initiator_ed25519_keypair = ed25519::KeyPair::from_secret(secret_initiator, 0);
let mut secret_responder: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut secret_responder);
let responder_ed25519_keypair = ed25519::KeyPair::from_secret(secret_responder, 1);
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::SHAKE128,
HashFunction::SHAKE256,
] {
let ciphersuite = Ciphersuite::resolve_ciphersuite(
kem,
hash_function,
SignatureScheme::Ed25519,
None,
)
.unwrap();
// generate kem public keys
let (responder_kem_public_key, initiator_kem_public_key) = match kem {
KEM::MlKem768 => (
EncapsulationKey::MlKem768(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
EncapsulationKey::MlKem768(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
),
KEM::XWing => (
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
),
KEM::X25519 => (
EncapsulationKey::X25519(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
EncapsulationKey::X25519(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
),
KEM::McEliece => (
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
),
};
let i_kem_key_bytes = initiator_kem_public_key.encode();
let r_kem_key_bytes = responder_kem_public_key.encode();
let i_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&i_kem_key_bytes,
);
let r_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&r_kem_key_bytes,
);
// Anonymous Initiator, OneWay
{
c.bench_function(
&format!("{kem}, {hash_function} | Anonymous Initiator: Generate Request",),
|b| {
b.iter(|| anonymous_initiator_process(&mut rng, ciphersuite).unwrap());
},
);
let (mut i_context, i_frame) =
anonymous_initiator_process(&mut rng, ciphersuite).unwrap();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Encode Frame - Request",
),
|b| b.iter(|| i_frame.to_bytes()),
);
let i_frame_bytes = i_frame.to_bytes();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Decode Frame - Request",
),
|b| b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()),
);
let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Responder Ingest Frame",
),
|b| {
b.iter(|| {
responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap()
});
},
);
let (mut r_context, _) =
responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Responder Generate Response",
),
|b| {
b.iter(|| {
responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Responder Encode Frame",
),
|b| b.iter(|| r_frame.to_bytes()),
);
c.bench_function(
&format!(
"{kem}, {hash_function} | Anonymous Initiator: Initiator Ingest Response",
),
|b| {
b.iter(|| {
initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let obtained_key = initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, OneWay
{
let (mut i_context, i_frame) = initiator_process(
&mut rng,
KKTMode::OneWay,
ciphersuite,
initiator_ed25519_keypair.private_key(),
None,
)
.unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Generate Request",),
|b| {
b.iter(|| {
initiator_process(
&mut rng,
KKTMode::OneWay,
ciphersuite,
initiator_ed25519_keypair.private_key(),
None,
)
.unwrap()
});
},
);
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Encode Frame - Request",),
|b| b.iter(|| i_frame.to_bytes()),
);
let i_frame_bytes = i_frame.to_bytes();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Decode Frame - Request",),
|b| b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap()),
);
let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Responder Ingest Frame",),
|b| {
b.iter(|| {
responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
None,
&i_frame_r,
)
.unwrap()
});
},
);
let (mut r_context, r_obtained_key) = responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
None,
&i_frame_r,
)
.unwrap();
assert!(r_obtained_key.is_none());
c.bench_function(
&format!(
"{kem}, {hash_function} | Initiator OneWay: Responder Generate Response",
),
|b| {
b.iter(|| {
responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator OneWay: Responder Encode Frame",),
|b| {
b.iter(|| r_frame.to_bytes());
},
);
c.bench_function(
&format!(
"{kem}, {hash_function} | Initiator OneWay: Initiator Ingest Response",
),
|b| {
b.iter(|| {
initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, Mutual
{
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Generate Request",),
|b| {
b.iter(|| {
initiator_process(
&mut rng,
KKTMode::Mutual,
ciphersuite,
initiator_ed25519_keypair.private_key(),
Some(&initiator_kem_public_key),
)
.unwrap()
});
},
);
let (mut i_context, i_frame) = initiator_process(
&mut rng,
KKTMode::Mutual,
ciphersuite,
initiator_ed25519_keypair.private_key(),
Some(&initiator_kem_public_key),
)
.unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Encode Frame - Request",),
|b| {
b.iter(|| i_frame.to_bytes());
},
);
let i_frame_bytes = i_frame.to_bytes();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Decode Frame - Request",),
|b| {
b.iter(|| KKTFrame::from_bytes(&i_frame_bytes).unwrap());
},
);
let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Responder Ingest Frame",),
|b| {
b.iter(|| {
responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
Some(&i_dir_hash),
&i_frame_r,
)
.unwrap()
});
},
);
let (mut r_context, r_obtained_key) = responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
Some(&i_dir_hash),
&i_frame_r,
)
.unwrap();
assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes);
c.bench_function(
&format!(
"{kem}, {hash_function} | Initiator Mutual: Responder Generate Response",
),
|b| {
b.iter(|| {
responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
c.bench_function(
&format!("{kem}, {hash_function} | Initiator Mutual: Responder Encode Frame",),
|b| {
b.iter(|| {
r_frame.to_bytes();
});
},
);
c.bench_function(
&format!(
"{kem}, {hash_function} | Initiator Mutual: Initiator Ingest Response",
),
|b| {
b.iter(|| {
initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let obtained_key = initiator_ingest_response(
&mut i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(obtained_key.encode(), r_kem_key_bytes)
}
}
}
}
criterion_group!(
benches,
gen_ed25519_keypair,
gen_mlkem768_keypair,
kkt_benchmark
);
criterion_main!(benches);
+293
View File
@@ -0,0 +1,293 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt::Display;
use libcrux_kem::{Algorithm, MlKem768PublicKey};
use nym_crypto::asymmetric::ed25519;
use crate::error::KKTError;
pub const HASH_LEN_256: usize = 32;
pub const CIPHERSUITE_ENCODING_LEN: usize = 4;
pub const CURVE25519_KEY_LEN: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum HashFunction {
Blake3,
SHAKE128,
SHAKE256,
SHA256,
}
impl Display for HashFunction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
HashFunction::Blake3 => "Blake3",
HashFunction::SHAKE128 => "SHAKE128",
HashFunction::SHAKE256 => "SHAKE256",
HashFunction::SHA256 => "SHA256",
})
}
}
pub enum EncapsulationKey<'a> {
MlKem768(libcrux_kem::PublicKey),
XWing(libcrux_kem::PublicKey),
X25519(libcrux_kem::PublicKey),
McEliece(classic_mceliece_rust::PublicKey<'a>),
}
pub enum DecapsulationKey<'a> {
MlKem768(libcrux_kem::PrivateKey),
XWing(libcrux_kem::PrivateKey),
X25519(libcrux_kem::PrivateKey),
McEliece(classic_mceliece_rust::SecretKey<'a>),
}
impl<'a> EncapsulationKey<'a> {
pub(crate) fn decode(kem: KEM, bytes: &[u8]) -> Result<Self, KKTError> {
match kem {
KEM::McEliece => {
if bytes.len() != classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES {
Err(KKTError::KEMError {
info: "Received McEliece Encapsulation Key with Invalid Length",
})
} else {
let mut public_key_bytes =
Box::new([0u8; classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES]);
// Size must be correct due to KKTFrame::from_bytes(message_bytes)?
public_key_bytes.clone_from_slice(bytes);
Ok(EncapsulationKey::McEliece(
classic_mceliece_rust::PublicKey::from(public_key_bytes),
))
}
}
KEM::X25519 => Ok(EncapsulationKey::X25519(libcrux_kem::PublicKey::decode(
map_kem_to_libcrux_kem(kem)?,
bytes,
)?)),
KEM::MlKem768 => Ok(EncapsulationKey::MlKem768(libcrux_kem::PublicKey::decode(
map_kem_to_libcrux_kem(kem)?,
bytes,
)?)),
KEM::XWing => Ok(EncapsulationKey::XWing(libcrux_kem::PublicKey::decode(
map_kem_to_libcrux_kem(kem)?,
bytes,
)?)),
}
}
pub fn encode(&self) -> Vec<u8> {
match self {
EncapsulationKey::XWing(public_key)
| EncapsulationKey::MlKem768(public_key)
| EncapsulationKey::X25519(public_key) => public_key.encode(),
EncapsulationKey::McEliece(public_key) => Vec::from(public_key.as_array()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum SignatureScheme {
Ed25519,
}
impl Display for SignatureScheme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SignatureScheme::Ed25519 => "Ed25519",
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum KEM {
MlKem768,
XWing,
X25519,
McEliece,
}
impl Display for KEM {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
KEM::MlKem768 => "MlKem768",
KEM::XWing => "XWing",
KEM::X25519 => "x25519",
KEM::McEliece => "McEliece",
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Ciphersuite {
hash_function: HashFunction,
signature_scheme: SignatureScheme,
kem: KEM,
hash_length: u8,
encapsulation_key_length: usize,
signing_key_length: usize,
verification_key_length: usize,
signature_length: usize,
}
impl Ciphersuite {
pub fn kem_key_len(&self) -> usize {
self.encapsulation_key_length
}
pub fn signature_len(&self) -> usize {
self.signature_length
}
pub fn signing_key_len(&self) -> usize {
self.signing_key_length
}
pub fn verification_key_len(&self) -> usize {
self.verification_key_length
}
pub fn hash_function(&self) -> HashFunction {
self.hash_function
}
pub fn kem(&self) -> KEM {
self.kem
}
pub fn signature_scheme(&self) -> SignatureScheme {
self.signature_scheme
}
pub fn hash_len(&self) -> usize {
self.hash_length as usize
}
pub fn resolve_ciphersuite(
kem: KEM,
hash_function: HashFunction,
signature_scheme: SignatureScheme,
// This should be None 99.9999% of the time
custom_hash_length: Option<u8>,
) -> Result<Self, KKTError> {
let hash_len = match custom_hash_length {
Some(l) => {
if l < 16 {
return Err(KKTError::InsecureHashLen);
} else {
l
}
}
None => HASH_LEN_256 as u8,
};
Ok(Self {
hash_function,
signature_scheme,
kem,
hash_length: hash_len,
encapsulation_key_length: match kem {
// 1184 bytes
KEM::MlKem768 => MlKem768PublicKey::len(),
// 1216 bytes = 1184 + 32
KEM::XWing => MlKem768PublicKey::len() + CURVE25519_KEY_LEN,
// 32 bytes
KEM::X25519 => CURVE25519_KEY_LEN,
// 524160 bytes
KEM::McEliece => classic_mceliece_rust::CRYPTO_PUBLICKEYBYTES,
},
signing_key_length: match signature_scheme {
// 32 bytes
SignatureScheme::Ed25519 => ed25519::SECRET_KEY_LENGTH,
},
verification_key_length: match signature_scheme {
// 32 bytes
SignatureScheme::Ed25519 => ed25519::PUBLIC_KEY_LENGTH,
},
signature_length: match signature_scheme {
// 64 bytes
SignatureScheme::Ed25519 => ed25519::SIGNATURE_LENGTH,
},
})
}
pub fn encode(&self) -> [u8; CIPHERSUITE_ENCODING_LEN] {
// [kem, hash, hashlen, sig]
[
match self.kem {
KEM::XWing => 0,
KEM::MlKem768 => 1,
KEM::McEliece => 2,
KEM::X25519 => 255,
},
match self.hash_function {
HashFunction::Blake3 => 0,
HashFunction::SHAKE256 => 1,
HashFunction::SHAKE128 => 2,
HashFunction::SHA256 => 3,
},
match self.hash_length as usize {
HASH_LEN_256 => 0u8,
_ => self.hash_length,
},
match self.signature_scheme {
SignatureScheme::Ed25519 => 0,
},
]
}
pub fn decode(encoding: [u8; CIPHERSUITE_ENCODING_LEN]) -> Result<Self, KKTError> {
let kem = match encoding[0] {
0 => KEM::XWing,
1 => KEM::MlKem768,
2 => KEM::McEliece,
255 => KEM::X25519,
_ => {
return Err(KKTError::CiphersuiteDecodingError {
info: format!("Undefined KEM: {}", encoding[0]),
});
}
};
let hash_function = match encoding[1] {
0 => HashFunction::Blake3,
1 => HashFunction::SHAKE256,
2 => HashFunction::SHAKE128,
3 => HashFunction::SHA256,
_ => {
return Err(KKTError::CiphersuiteDecodingError {
info: format!("Undefined Hash Function: {}", encoding[1]),
});
}
};
let custom_hash_length = match encoding[2] {
0 => None,
_ => Some(encoding[2]),
};
let signature_scheme = match encoding[3] {
0 => SignatureScheme::Ed25519,
_ => {
return Err(KKTError::CiphersuiteDecodingError {
info: format!("Undefined Signature Scheme: {}", encoding[3]),
});
}
};
Self::resolve_ciphersuite(kem, hash_function, signature_scheme, custom_hash_length)
}
}
impl Display for Ciphersuite {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
&format!(
"{}_{}({})_{}",
self.kem, self.hash_function, self.hash_length, self.signature_scheme
)
.to_ascii_lowercase(),
)
}
}
pub const fn map_kem_to_libcrux_kem(kem: KEM) -> Result<Algorithm, KKTError> {
match kem {
KEM::MlKem768 => Ok(Algorithm::MlKem768),
KEM::XWing => Ok(Algorithm::XWingKemDraft06),
KEM::X25519 => Ok(Algorithm::X25519),
KEM::McEliece => Err(KKTError::KEMMapping {
info: "attempted to map McEliece KEM to libcrux_kem",
}),
}
}
+241
View File
@@ -0,0 +1,241 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::ciphersuite::CIPHERSUITE_ENCODING_LEN;
use crate::{KKT_VERSION, ciphersuite::Ciphersuite, error::KKTError, frame::KKT_SESSION_ID_LEN};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use std::fmt::Display;
pub const KKT_CONTEXT_LEN: usize = 7;
// bitmask used: 0b1110_0000
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
pub enum KKTStatus {
Ok = 0b0000_0000,
InvalidRequestFormat = 0b0010_0000,
InvalidResponseFormat = 0b0100_0000,
InvalidSignature = 0b0110_0000,
UnsupportedCiphersuite = 0b1000_0000,
UnsupportedKKTVersion = 0b1010_0000,
InvalidKey = 0b1100_0000,
Timeout = 0b1110_0000,
}
impl Display for KKTStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
KKTStatus::Ok => "Ok",
KKTStatus::InvalidRequestFormat => "Invalid Request Format",
KKTStatus::InvalidResponseFormat => "Invalid Response Format",
KKTStatus::InvalidSignature => "Invalid Signature",
KKTStatus::UnsupportedCiphersuite => "Unsupported Ciphersuite",
KKTStatus::UnsupportedKKTVersion => "Unsupported KKT Version",
KKTStatus::InvalidKey => "Invalid Key",
KKTStatus::Timeout => "Timeout",
})
}
}
// bitmask used: 0b0000_0011
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
pub enum KKTRole {
Initiator = 0b0000_0000,
Responder = 0b0000_0001,
AnonymousInitiator = 0b0000_0010,
}
// bitmask used: 0b0001_1100
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
pub enum KKTMode {
OneWay = 0b0000_0000,
Mutual = 0b0000_0100,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct KKTContext {
version: u8,
message_sequence: u8,
status: KKTStatus,
mode: KKTMode,
role: KKTRole,
ciphersuite: Ciphersuite,
}
impl KKTContext {
pub fn new(role: KKTRole, mode: KKTMode, ciphersuite: Ciphersuite) -> Result<Self, KKTError> {
if role == KKTRole::AnonymousInitiator && mode != KKTMode::OneWay {
return Err(KKTError::IncompatibilityError {
info: "Anonymous Initiator can only use OneWay mode",
});
}
Ok(Self {
version: KKT_VERSION,
message_sequence: 0,
status: KKTStatus::Ok,
mode,
role,
ciphersuite,
})
}
pub fn derive_responder_header(&self) -> Result<Self, KKTError> {
let mut responder_header = *self;
responder_header.increment_message_sequence_count()?;
responder_header.role = KKTRole::Responder;
Ok(responder_header)
}
pub fn increment_message_sequence_count(&mut self) -> Result<(), KKTError> {
if self.message_sequence + 1 < (1 << 4) {
self.message_sequence += 1;
Ok(())
} else {
Err(KKTError::MessageCountLimitReached)
}
}
pub fn update_status(&mut self, status: KKTStatus) {
self.status = status;
}
pub fn version(&self) -> u8 {
self.version
}
pub fn status(&self) -> KKTStatus {
self.status
}
pub fn ciphersuite(&self) -> Ciphersuite {
self.ciphersuite
}
pub fn role(&self) -> KKTRole {
self.role
}
pub fn mode(&self) -> KKTMode {
self.mode
}
pub fn body_len(&self) -> usize {
if self.status != KKTStatus::Ok
|| (self.mode == KKTMode::OneWay
&& (self.role == KKTRole::Initiator || self.role == KKTRole::AnonymousInitiator))
{
0
} else {
self.ciphersuite.kem_key_len()
}
}
pub fn signature_len(&self) -> usize {
match self.role {
KKTRole::Initiator | KKTRole::Responder => self.ciphersuite.signature_len(),
KKTRole::AnonymousInitiator => 0,
}
}
pub const fn header_len(&self) -> usize {
KKT_CONTEXT_LEN
}
pub const fn session_id_len(&self) -> usize {
// note: if anyone decides to update this function and changes the constant value,
// you will have to adjust encoding/decoding functions
// match self.role {
// KKTRole::Initiator | KKTRole::Responder => SESSION_ID_LENGTH,
// It doesn't make sense to send a session_id if we send messages in the clear
// KKTRole::AnonymousInitiator => 0,
// }
KKT_SESSION_ID_LEN
}
pub fn full_message_len(&self) -> usize {
self.body_len() + self.signature_len() + self.header_len() + self.session_id_len()
}
pub fn encode(&self) -> Result<[u8; KKT_CONTEXT_LEN], KKTError> {
let mut header_bytes = [0u8; KKT_CONTEXT_LEN];
if self.message_sequence >= 1 << 4 {
return Err(KKTError::MessageCountLimitReached);
}
let ciphersuite_bytes = self.ciphersuite.encode();
header_bytes[0] = (KKT_VERSION << 4) + self.message_sequence;
header_bytes[1] = u8::from(self.status) + u8::from(self.mode) + u8::from(self.role);
let mut i = 2;
for b in ciphersuite_bytes.into_iter() {
header_bytes[i] = b;
i += 1;
}
header_bytes[i] = 0;
Ok(header_bytes)
}
pub fn try_decode(header_bytes: [u8; KKT_CONTEXT_LEN]) -> Result<Self, KKTError> {
let kkt_version = (header_bytes[0] & 0b1111_0000) >> 4;
let message_sequence_counter = header_bytes[0] & 0b0000_1111;
// We only check if stuff is valid here, not necessarily if it's compatible
if kkt_version > KKT_VERSION {
return Err(KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Version: {kkt_version}"),
});
}
let raw_kkt_status = header_bytes[1] & 0b1110_0000;
let raw_kkt_role = header_bytes[1] & 0b0000_0011;
let raw_kkt_mode = header_bytes[1] & 0b0001_1100;
let status =
KKTStatus::try_from(raw_kkt_status).map_err(|_| KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Status: {raw_kkt_status}"),
})?;
let role = KKTRole::try_from(raw_kkt_role).map_err(|_| KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Role: {raw_kkt_role}"),
})?;
let mode = KKTMode::try_from(raw_kkt_mode).map_err(|_| KKTError::FrameDecodingError {
info: format!("Header - Invalid KKT Mode: {raw_kkt_mode}"),
})?;
let ciphersuite_bytes = header_bytes[2..6].try_into().map_err(|_| {
KKTError::CiphersuiteDecodingError {
info: format!(
"Incorrect Encoding Length: actual: 4 != expected: {CIPHERSUITE_ENCODING_LEN}",
),
}
})?;
Ok(KKTContext {
version: kkt_version,
status,
mode,
role,
ciphersuite: Ciphersuite::decode(ciphersuite_bytes)?,
message_sequence: message_sequence_counter,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kkt_context_encoding() {
let valid_context = KKTContext::new(
KKTRole::Initiator,
KKTMode::Mutual,
Ciphersuite::decode([255, 1, 0, 0]).unwrap(),
)
.unwrap();
let encoded = valid_context.encode().unwrap();
let decoded = KKTContext::try_decode(encoded).unwrap();
assert_eq!(decoded, valid_context);
}
}
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2025-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::kkt::KKT_INITIAL_FRAME_AAD;
use crate::{
ciphersuite::CURVE25519_KEY_LEN, context::KKTContext, error::KKTError, frame::KKTFrame,
};
use blake3::Hasher;
use libcrux_chacha20poly1305::{NONCE_LEN, TAG_LEN};
use nym_crypto::asymmetric::x25519;
use rand::{CryptoRng, RngCore};
use zeroize::Zeroize;
#[derive(Clone, Copy, Zeroize)]
pub struct KKTSessionSecret([u8; 32]);
impl KKTSessionSecret {
pub fn new<R>(rng: &mut R, remote_public_key: &x25519::PublicKey) -> (Self, x25519::PublicKey)
where
R: RngCore + CryptoRng,
{
let mut private_key_bytes = [0u8; x25519::PRIVATE_KEY_SIZE];
rng.fill_bytes(&mut private_key_bytes);
let ephemeral_private_key = x25519::PrivateKey::from_secret(private_key_bytes);
let ephemeral_public_key = x25519::PublicKey::from(&ephemeral_private_key);
(
Self::derive(&ephemeral_private_key, remote_public_key),
ephemeral_public_key,
)
}
pub fn from_bytes(secret: [u8; 32]) -> Self {
Self(secret)
}
fn try_derive(private_key: &x25519::PrivateKey, public_key: &[u8]) -> Result<Self, KKTError> {
let mut pub_key: [u8; 32] = [0u8; 32];
pub_key.copy_from_slice(&public_key[0..CURVE25519_KEY_LEN]);
// Todo: check validity of pk...
let pk = x25519::PublicKey::from(pub_key);
Ok(Self::derive(private_key, &pk))
}
pub fn derive(private_key: &x25519::PrivateKey, public_key: &x25519::PublicKey) -> Self {
let mut shared_secret = private_key.diffie_hellman(public_key);
let mut hasher = Hasher::new();
hasher.update(&shared_secret);
shared_secret.zeroize();
Self(hasher.finalize().as_bytes().to_owned())
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
pub fn encrypt_initial_kkt_frame<R>(
rng: &mut R,
remote_public_key: &x25519::PublicKey,
kkt_frame: &KKTFrame,
) -> Result<(KKTSessionSecret, Vec<u8>), KKTError>
where
R: CryptoRng + RngCore,
{
let (session_secret_key, ephemeral_public_key) = KKTSessionSecret::new(rng, remote_public_key);
let mut encrypted_frame =
encrypt_kkt_frame(rng, &session_secret_key, kkt_frame, KKT_INITIAL_FRAME_AAD)?;
let mut output_buffer = Vec::with_capacity(encrypted_frame.len() + CURVE25519_KEY_LEN);
output_buffer.extend_from_slice(ephemeral_public_key.as_bytes());
output_buffer.append(&mut encrypted_frame);
// [ 32 | 12 | ciphertext | 16];
// [eph_pub_key | nonce | ciphertext | tag];
Ok((session_secret_key, output_buffer))
}
pub fn decrypt_initial_kkt_frame(
responder_private_key: &x25519::PrivateKey,
encrypted_frame_bytes: &[u8],
) -> Result<(KKTSessionSecret, KKTFrame, KKTContext), KKTError> {
if encrypted_frame_bytes.len() < CURVE25519_KEY_LEN + TAG_LEN + NONCE_LEN {
Err(KKTError::AEADError {
info: "Encrypted KKT Frame is too short.",
})
} else {
let shared_secret = KKTSessionSecret::try_derive(
responder_private_key,
&encrypted_frame_bytes[0..CURVE25519_KEY_LEN],
)?;
let (kkt_frame, kkt_context) = decrypt_kkt_frame(
&shared_secret,
&encrypted_frame_bytes[CURVE25519_KEY_LEN..],
KKT_INITIAL_FRAME_AAD,
)?;
Ok((shared_secret, kkt_frame, kkt_context))
}
}
pub fn encrypt_kkt_frame<R>(
rng: &mut R,
secret_key: &KKTSessionSecret,
kkt_frame: &KKTFrame,
aad: &[u8],
) -> Result<Vec<u8>, KKTError>
where
R: CryptoRng + RngCore,
{
let kkt_frame_bytes = kkt_frame.to_bytes();
// generate nonce
let mut nonce: [u8; NONCE_LEN] = [0u8; NONCE_LEN];
rng.fill_bytes(&mut nonce);
let mut ciphertext = encrypt(secret_key.as_bytes(), &kkt_frame_bytes, aad, &nonce)?;
// [ 12 | ciphertext | 16];
// [nonce | ciphertext | tag];
let mut output_buffer: Vec<u8> =
Vec::with_capacity(NONCE_LEN + kkt_frame_bytes.len() + TAG_LEN);
output_buffer.extend_from_slice(&nonce);
output_buffer.append(&mut ciphertext);
Ok(output_buffer)
}
// kkt_frame_bytes should look like this
// [ 12 | ciphertext | 16];
// [nonce | ciphertext | tag];
pub fn decrypt_kkt_frame(
secret_key: &KKTSessionSecret,
kkt_frame_bytes: &[u8],
aad: &[u8],
) -> Result<(KKTFrame, KKTContext), KKTError> {
let mut nonce: [u8; NONCE_LEN] = [0u8; NONCE_LEN];
nonce.copy_from_slice(&kkt_frame_bytes[0..NONCE_LEN]);
let plaintext = decrypt(
secret_key.as_bytes(),
&kkt_frame_bytes[NONCE_LEN..],
aad,
&nonce,
)?;
KKTFrame::from_bytes(&plaintext)
}
fn encrypt(
secret_key: &[u8; 32],
plaintext: &[u8],
aad: &[u8],
nonce: &[u8; NONCE_LEN],
) -> Result<Vec<u8>, KKTError> {
let mut output_buffer = vec![0; plaintext.len() + TAG_LEN];
libcrux_chacha20poly1305::encrypt(secret_key, plaintext, &mut output_buffer, aad, nonce)?;
Ok(output_buffer)
}
fn decrypt(
secret_key: &[u8; 32],
ciphertext: &[u8],
aad: &[u8],
nonce: &[u8; NONCE_LEN],
) -> Result<Vec<u8>, KKTError> {
let mut output_buffer = vec![0; ciphertext.len() - TAG_LEN];
libcrux_chacha20poly1305::decrypt(secret_key, &mut output_buffer, ciphertext, aad, nonce)?;
Ok(output_buffer)
}
#[cfg(test)]
mod test {
use crate::ciphersuite::Ciphersuite;
use crate::context::{KKTContext, KKTMode, KKTRole};
use crate::encryption::{decrypt_kkt_frame, encrypt_kkt_frame};
use crate::frame::{KKT_SESSION_ID_LEN, KKTFrame};
use crate::{
ciphersuite::HASH_LEN_256,
encryption::{KKTSessionSecret, decrypt, encrypt},
key_utils::generate_keypair_x25519,
};
use rand::{RngCore, SeedableRng, rng};
use rand_chacha::ChaCha20Rng;
#[test]
fn test_keygen() {
let mut rng = rng();
let responder_x25519_keypair = generate_keypair_x25519(&mut rng);
let (session_secret_key, ephemeral_public_key) =
KKTSessionSecret::new(&mut rng, responder_x25519_keypair.public_key());
let shared_secret = KKTSessionSecret::try_derive(
responder_x25519_keypair.private_key(),
ephemeral_public_key.as_bytes().as_slice(),
)
.unwrap();
assert_eq!(shared_secret.as_bytes(), session_secret_key.as_bytes())
}
#[test]
fn test_encryption() {
let mut rng = rng();
let mut secret_key = [0u8; HASH_LEN_256];
rng.fill_bytes(&mut secret_key);
let mut plaintext = vec![0; 100];
rng.fill_bytes(&mut plaintext);
let mut nonce = [0; 12];
rng.fill_bytes(&mut nonce);
let mut aad = vec![0; 124];
rng.fill_bytes(&mut aad);
let ciphertext = encrypt(&secret_key, &plaintext, &aad, &nonce).unwrap();
let o_plaintext = decrypt(&secret_key, &ciphertext, &aad, &nonce).unwrap();
assert_eq!(o_plaintext, plaintext)
}
#[test]
fn kkt_frame_encryption() -> anyhow::Result<()> {
let mut rng = ChaCha20Rng::seed_from_u64(42);
let session_key = KKTSessionSecret::from_bytes([42u8; 32]);
let aad = b"my-amazing-aad";
let valid_context = KKTContext::new(
KKTRole::Initiator,
KKTMode::Mutual,
Ciphersuite::decode([255, 1, 0, 0])?,
)?;
let dummy_frame = KKTFrame::new(
valid_context.encode()?,
&[2u8; 32],
[3u8; KKT_SESSION_ID_LEN],
&[4u8; 64],
);
let ciphertext = encrypt_kkt_frame(&mut rng, &session_key, &dummy_frame, aad.as_slice())?;
let (frame, context) = decrypt_kkt_frame(&session_key, &ciphertext, aad.as_slice())?;
assert_eq!(dummy_frame, frame);
assert_eq!(context, valid_context);
Ok(())
}
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt::Debug;
use thiserror::Error;
use crate::context::KKTStatus;
#[derive(Error, Debug)]
pub enum KKTError {
#[error("Signature constructor error")]
SigConstructorError,
#[error("Signature verification error")]
SigVerifError,
#[error("Ciphersuite Decoding Error: {}", info)]
CiphersuiteDecodingError { info: String },
#[error("KEM mapping failure: {}", info)]
KEMMapping { info: &'static str },
#[error("Insecure Encapsulation Key Hash Length")]
InsecureHashLen,
#[error("KKT Frame Decoding Error: {}", info)]
FrameDecodingError { info: String },
#[error("KKT Frame Encoding Error: {}", info)]
FrameEncodingError { info: String },
#[error("KKT Incompatibility Error: {}", info)]
IncompatibilityError { info: &'static str },
#[error("KKT Responder Flagged Error: {}", status)]
ResponderFlaggedError { status: KKTStatus },
#[error("KKT Message Count Limit Reached")]
MessageCountLimitReached,
#[error("PSQ KEM Error: {}", info)]
KEMError { info: &'static str },
#[error("Local Function Input Error: {}", info)]
FunctionInputError { info: &'static str },
#[error("{}", info)]
X25519Error { info: &'static str },
#[error("{}", info)]
AEADError { info: &'static str },
#[error("Generic libcrux error")]
LibcruxError,
}
impl From<libcrux_kem::Error> for KKTError {
fn from(err: libcrux_kem::Error) -> Self {
match err {
libcrux_kem::Error::EcDhError(_) => KKTError::KEMError { info: "ECDH Error" },
libcrux_kem::Error::KeyGen => KKTError::KEMError {
info: "Key Generation Error",
},
libcrux_kem::Error::Encapsulate => KKTError::KEMError {
info: "Encapsulation Error",
},
libcrux_kem::Error::Decapsulate => KKTError::KEMError {
info: "Decapsulation Error",
},
libcrux_kem::Error::UnsupportedAlgorithm => KKTError::KEMError {
info: "libcrux Unsupported Algorithm",
},
libcrux_kem::Error::InvalidPrivateKey => KKTError::KEMError {
info: "Invalid Private Key",
},
libcrux_kem::Error::InvalidPublicKey => KKTError::KEMError {
info: "Invalid Public Key",
},
libcrux_kem::Error::InvalidCiphertext => KKTError::KEMError {
info: "Invalid Ciphertext",
},
}
}
}
impl From<libcrux_ecdh::Error> for KKTError {
fn from(err: libcrux_ecdh::Error) -> Self {
match err {
libcrux_ecdh::Error::InvalidPoint => KKTError::KEMError {
info: "Invalid Remote Public Key",
},
_ => KKTError::LibcruxError,
}
}
}
impl From<libcrux_chacha20poly1305::AeadError> for KKTError {
fn from(err: libcrux_chacha20poly1305::AeadError) -> Self {
KKTError::KEMError {
info: match err {
libcrux_chacha20poly1305::AeadError::PlaintextTooLarge => {
"Plaintext is longer than u32::MAX"
}
libcrux_chacha20poly1305::AeadError::CiphertextTooLarge => {
"Ciphertext is longer than u32::MAX"
}
libcrux_chacha20poly1305::AeadError::AadTooLarge => "Aad is longer than u32::MAX",
libcrux_chacha20poly1305::AeadError::CiphertextTooShort => {
"The provided destination ciphertext does not fit the ciphertext and tag"
}
libcrux_chacha20poly1305::AeadError::PlaintextTooShort => {
"The provided destination plaintext is too short to fit the decrypted plaintext"
}
libcrux_chacha20poly1305::AeadError::InvalidCiphertext => {
"The ciphertext is not a valid encryption under the given key and nonce."
}
},
}
}
}
+155
View File
@@ -0,0 +1,155 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// | 0 | 1 | 2, 3, 4, 5 | 6 | 7
// [0] => KKT version (4 bits) + Message Sequence Count (4 bits)
// [1] => Status (3 bits) + Mode (3 bits) + Role (2 bits)
// [2..=5] => Ciphersuite
// [6] => Reserved
use crate::{
context::{KKT_CONTEXT_LEN, KKTContext},
error::KKTError,
};
pub const KKT_SESSION_ID_LEN: usize = 16;
pub type KKTSessionId = [u8; KKT_SESSION_ID_LEN];
#[derive(Debug, PartialEq, Clone)]
pub struct KKTFrame {
context: [u8; KKT_CONTEXT_LEN],
session_id: KKTSessionId,
body: Vec<u8>,
signature: Vec<u8>,
}
// if oneway and message coming from initiator => body is empty, signature contains signature of context + session id (64 bytes).
// if message coming from anonymous initiator => body is empty, there is no signature.
// if mutual and message coming from initiator => body has the initiator's kem public key and the signature is over the context + body + session_id.
// if coming from responder => body has the responder's kem public key and the signature is over the context + body + session_id.
impl KKTFrame {
pub fn new(
context: [u8; KKT_CONTEXT_LEN],
body: &[u8],
session_id: [u8; KKT_SESSION_ID_LEN],
signature: &[u8],
) -> Self {
Self {
context,
body: Vec::from(body),
session_id,
signature: Vec::from(signature),
}
}
pub fn context_ref(&self) -> &[u8] {
&self.context
}
pub fn context(&self) -> Result<KKTContext, KKTError> {
KKTContext::try_decode(self.context)
}
pub fn signature_ref(&self) -> &[u8] {
&self.signature
}
pub fn body_ref(&self) -> &[u8] {
&self.body
}
pub fn session_id_ref(&self) -> &[u8] {
&self.session_id
}
pub fn session_id(&self) -> [u8; KKT_SESSION_ID_LEN] {
self.session_id
}
pub fn signature_mut(&mut self) -> &mut [u8] {
&mut self.signature
}
pub fn body_mut(&mut self) -> &mut [u8] {
&mut self.body
}
pub fn session_id_mut(&mut self) -> &mut [u8] {
&mut self.session_id
}
pub fn frame_length(&self) -> usize {
self.context.len() + self.session_id.len() + self.body.len() + self.signature.len()
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(self.frame_length());
bytes.extend_from_slice(&self.context);
bytes.extend_from_slice(&self.body);
bytes.extend_from_slice(&self.session_id);
bytes.extend_from_slice(&self.signature);
bytes
}
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, KKTContext), KKTError> {
let len = bytes.len();
if bytes.len() < KKT_CONTEXT_LEN {
return Err(KKTError::FrameDecodingError {
info: format!(
"Frame is shorter than expected context length: actual {len} != expected {KKT_CONTEXT_LEN}",
),
});
}
// SAFETY: we're using exactly KKT_CONTEXT_LEN bytes
#[allow(clippy::unwrap_used)]
let context_bytes = bytes[0..KKT_CONTEXT_LEN].try_into().unwrap();
let context = KKTContext::try_decode(context_bytes)?;
if bytes.len() != context.full_message_len() {
return Err(KKTError::FrameDecodingError {
info: format!(
"Frame is shorter than expected: actual {len} != expected {}",
context.full_message_len()
),
});
}
let mut body = Vec::new();
let mut signature = Vec::new();
// decode body
if context.body_len() > 0 {
let body_bytes = &bytes[KKT_CONTEXT_LEN..KKT_CONTEXT_LEN + context.body_len()];
body.extend_from_slice(body_bytes);
}
let session_bytes = &bytes[KKT_CONTEXT_LEN + context.body_len()
..KKT_CONTEXT_LEN + context.body_len() + KKT_SESSION_ID_LEN];
// SAFETY: we're using exactly KKT_SESSION_ID_LEN bytes and we checked for sufficient bytes
#[allow(clippy::unwrap_used)]
let session_id = session_bytes.try_into().unwrap();
// // old code left for reference if session id becomes variable in length:
// if context.session_id_len() > 0 {
// session_id.extend_from_slice(
// &bytes[KKT_CONTEXT_LEN + context.body_len()
// ..KKT_CONTEXT_LEN + context.body_len() + context.session_id_len()],
// );
// }
// decode signature
if context.signature_len() > 0 {
let signature_bytes = &bytes[KKT_CONTEXT_LEN + context.body_len() + KKT_SESSION_ID_LEN
..KKT_CONTEXT_LEN
+ context.body_len()
+ KKT_SESSION_ID_LEN
+ context.signature_len()];
signature.extend_from_slice(signature_bytes);
}
Ok((
KKTFrame::new(context_bytes, &body, session_id, &signature),
context,
))
}
}
+133
View File
@@ -0,0 +1,133 @@
use crate::ciphersuite::HashFunction;
use classic_mceliece_rust::keypair_boxed;
use libcrux_sha3;
use rand::{CryptoRng, RngCore};
pub fn generate_keypair_ed25519<R>(
rng: &mut R,
index: Option<u32>,
) -> nym_crypto::asymmetric::ed25519::KeyPair
where
R: RngCore + CryptoRng,
{
let mut secret_initiator: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut secret_initiator);
nym_crypto::asymmetric::ed25519::KeyPair::from_secret(secret_initiator, index.unwrap_or(0))
}
pub fn generate_keypair_x25519<R>(rng: &mut R) -> nym_crypto::asymmetric::x25519::KeyPair
where
R: RngCore + CryptoRng,
{
let mut secret_initiator: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut secret_initiator);
let private_key = nym_crypto::asymmetric::x25519::PrivateKey::from_secret(secret_initiator);
private_key.into()
}
// (decapsulation_key, encapsulation_key)
pub fn generate_keypair_libcrux<R>(
rng: &mut R,
kem: crate::ciphersuite::KEM,
) -> Result<(libcrux_kem::PrivateKey, libcrux_kem::PublicKey), crate::error::KKTError>
where
R: RngCore + CryptoRng,
{
match kem {
crate::ciphersuite::KEM::MlKem768 => {
Ok(libcrux_kem::key_gen(libcrux_kem::Algorithm::MlKem768, rng)?)
}
crate::ciphersuite::KEM::XWing => Ok(libcrux_kem::key_gen(
libcrux_kem::Algorithm::XWingKemDraft06,
rng,
)?),
crate::ciphersuite::KEM::X25519 => {
Ok(libcrux_kem::key_gen(libcrux_kem::Algorithm::X25519, rng)?)
}
_ => Err(crate::error::KKTError::KEMError {
info: "Key Generation Error: Unsupported Libcrux Algorithm",
}),
}
}
// (decapsulation_key, encapsulation_key)
pub fn generate_keypair_mceliece<'a, R>(
rng: &mut R,
) -> (
classic_mceliece_rust::SecretKey<'a>,
classic_mceliece_rust::PublicKey<'a>,
)
where
// this is annoying because mceliece lib uses rand 0.8.5...
R: RngCore + CryptoRng,
{
let (encapsulation_key, decapsulation_key) = keypair_boxed(rng);
(decapsulation_key, encapsulation_key)
}
pub fn hash_key_bytes(
hash_function: &HashFunction,
hash_length: usize,
key_bytes: &[u8],
) -> Vec<u8> {
let mut hashed_key: Vec<u8> = vec![0u8; hash_length];
match hash_function {
HashFunction::Blake3 => {
let mut hasher = blake3::Hasher::new();
hasher.update(key_bytes);
hasher.finalize_xof().fill(&mut hashed_key);
hasher.reset();
}
HashFunction::SHAKE256 => {
libcrux_sha3::shake256_ema(&mut hashed_key, key_bytes);
}
HashFunction::SHAKE128 => {
libcrux_sha3::shake128_ema(&mut hashed_key, key_bytes);
}
HashFunction::SHA256 => {
libcrux_sha3::sha256_ema(&mut hashed_key, key_bytes);
}
}
hashed_key
}
/// This does NOT run in constant time.
// It's fine for KKT since we are comparing hashes.
fn compare_hashes(a: &[u8], b: &[u8]) -> bool {
a == b
}
pub fn validate_encapsulation_key(
hash_function: &HashFunction,
hash_length: usize,
encapsulation_key: &[u8],
expected_hash_bytes: &[u8],
) -> bool {
compare_hashes(
&hash_encapsulation_key(hash_function, hash_length, encapsulation_key),
expected_hash_bytes,
)
}
pub fn validate_key_bytes(
hash_function: &HashFunction,
hash_length: usize,
key_bytes: &[u8],
expected_hash_bytes: &[u8],
) -> bool {
compare_hashes(
&hash_key_bytes(hash_function, hash_length, key_bytes),
expected_hash_bytes,
)
}
pub fn hash_encapsulation_key(
hash_function: &HashFunction,
hash_length: usize,
encapsulation_key: &[u8],
) -> Vec<u8> {
hash_key_bytes(hash_function, hash_length, encapsulation_key)
}
+453
View File
@@ -0,0 +1,453 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Convenience wrappers around KKT protocol functions for easier integration.
//!
//! This module provides simplified APIs for the common use case of exchanging
//! KEM public keys between a client (initiator) and gateway (responder).
//!
//! The underlying KKT protocol is implemented in the `session` module.
use nym_crypto::asymmetric::{ed25519, x25519};
use rand::{CryptoRng, RngCore};
use crate::{
ciphersuite::{Ciphersuite, EncapsulationKey},
context::{KKTContext, KKTMode},
encryption::{decrypt_initial_kkt_frame, decrypt_kkt_frame, encrypt_kkt_frame},
error::KKTError,
};
// Re-export core session functions for advanced use cases
pub use crate::session::{
anonymous_initiator_process, initiator_ingest_response, initiator_process,
responder_ingest_message, responder_process,
};
use crate::encryption::{KKTSessionSecret, encrypt_initial_kkt_frame};
use crate::frame::KKTFrame;
pub(crate) const KKT_RESPONSE_AAD: &[u8] = b"KKT_Response";
pub(crate) const KKT_INITIAL_FRAME_AAD: &[u8] = b"KKT_INITIAL_FRAME";
/// Perform an *Encrypted* request for a KEM public key from a responder (OneWay mode).
///
/// This is the client-side operation that initiates a KKT exchange.
/// The request will be signed with the provided signing key.
///
/// # Arguments
/// * `rng` - Random number generator
/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms)
/// * `signing_key` - Client's Ed25519 signing key for authentication
/// * `responder_dh_public_key` - Responder's long-term x25519 Diffie-Hellman public key
///
/// # Returns
/// * `KKTSessionSecret` - Session Secret Key to use when decrypting responses
/// * `KKTContext` - Context to use when validating the response
/// * `Vec<u8>` - Contains the client's ephemeral public key and encrypted and signed bytes to send to responder
///
/// # Example
/// ```ignore
/// let (session_secret, context, request_frame) = request_kem_key(
/// &mut rng,
/// ciphersuite,
/// client_signing_key,
/// responder_dh_public_key,
/// )?;
/// // Send request_frame to gateway
/// ```
pub fn request_kem_key<R: CryptoRng + RngCore>(
rng: &mut R,
ciphersuite: Ciphersuite,
signing_key: &ed25519::PrivateKey,
responder_dh_public_key: &x25519::PublicKey,
) -> Result<(KKTSessionSecret, KKTContext, Vec<u8>), KKTError> {
// OneWay mode: client only wants responder's KEM key
// None: client doesn't send their own KEM key
let (initiator_context, initiator_frame) =
initiator_process(rng, KKTMode::OneWay, ciphersuite, signing_key, None)?;
// Generate the session's shared secret and encrypt the Initiator's request
let (session_secret, encrypted_request_bytes) =
encrypt_initial_kkt_frame(rng, responder_dh_public_key, &initiator_frame)?;
Ok((session_secret, initiator_context, encrypted_request_bytes))
}
/// Decrypt, validate an *Encrypted* KKT response and extract the responder's KEM public key.
///
/// This is the client-side operation that processes the gateway's response.
/// It verifies the signature and validates the key hash against the expected value
/// (typically retrieved from a directory service).
///
/// # Arguments
/// * `context` - Context from the initial request
/// * `session_secret` - Session Secret Key (generated with request)
/// * `responder_vk` - Responder's Ed25519 verification key (from directory)
/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory)
/// * `response_bytes` - Serialized response frame from responder
///
/// # Returns
/// * `EncapsulationKey` - Authenticated KEM public key of the responder
///
/// # Example
/// ```ignore
/// let gateway_kem_key = validate_kem_response(
/// &mut context,
/// &session_secret,
/// &gateway_verification_key,
/// &expected_hash_from_directory,
/// &response_bytes,
/// )?;
/// // Use gateway_kem_key for PSQ
/// ```
pub fn validate_kem_response<'a>(
context: &mut KKTContext,
session_secret: &KKTSessionSecret,
responder_vk: &ed25519::PublicKey,
expected_key_hash: &[u8],
encrypted_response_bytes: &[u8],
) -> Result<EncapsulationKey<'a>, KKTError> {
let (responder_frame, responder_context) =
decrypt_kkt_response_frame(session_secret, encrypted_response_bytes)?;
initiator_ingest_response(
context,
&responder_frame,
&responder_context,
responder_vk,
expected_key_hash,
)
}
/// Decrypts and validates an *Encrypted* KKT response
///
/// This is the client-side operation that processes the gateway's response.
pub fn decrypt_kkt_response_frame(
session_secret: &KKTSessionSecret,
frame_ciphertext: &[u8],
) -> Result<(KKTFrame, KKTContext), KKTError> {
decrypt_kkt_frame(session_secret, frame_ciphertext, KKT_RESPONSE_AAD)
}
/// Handle an *Encrypted* KKT request and generate a signed response with the responder's KEM key.
///
/// This is the gateway-side operation that processes a client's KKT request.
/// It validates the request signature (if authenticated) and responds with
/// the gateway's KEM public key, signed for authenticity.
///
/// # Arguments
/// * `encrypted_request_bytes` - encrypted KEM request
/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous)
/// * `responder_signing_key` - Gateway's Ed25519 signing key
/// * `responder_dh_public_key` - Gateway's long-term x25519 Diffie-Hellman private key
/// * `responder_kem_key` - Gateway's KEM public key to send
///
/// # Returns
/// * `KKTFrame` - Signed response frame containing the KEM public key
///
/// # Example
/// ```ignore
/// let response_frame = handle_kem_request(
/// &request_frame,
/// Some(client_verification_key), // or None for anonymous
/// gateway_signing_key,
/// &gateway_kem_public_key,
/// )?;
/// // Send response_frame back to client
/// ```
pub fn handle_kem_request<'a, R>(
rng: &mut R,
encrypted_request_bytes: &[u8],
initiator_vk: Option<&ed25519::PublicKey>,
responder_signing_key: &ed25519::PrivateKey,
responder_dh_private_key: &x25519::PrivateKey,
responder_kem_key: &EncapsulationKey<'a>,
) -> Result<Vec<u8>, KKTError>
where
R: RngCore + CryptoRng,
{
// Compute the session's shared secret, decrypt and parse context from the request frame
let (session_secret, request_frame, initiator_context) =
decrypt_initial_kkt_frame(responder_dh_private_key, encrypted_request_bytes)?;
// Validate the request (verifies signature if initiator_vk provided)
let (mut response_context, _) = responder_ingest_message(
&initiator_context,
initiator_vk,
None, // Not checking initiator's KEM key in OneWay mode
&request_frame,
)?;
// Generate signed response with our KEM public key
let responder_frame = responder_process(
&mut response_context,
request_frame.session_id(),
responder_signing_key,
responder_kem_key,
)?;
// Encrypt the responder's response with the session's shared secret
encrypt_kkt_frame(rng, &session_secret, &responder_frame, KKT_RESPONSE_AAD)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ciphersuite::{HashFunction, KEM, SignatureScheme},
key_utils::{generate_keypair_libcrux, hash_encapsulation_key},
};
fn random_x25519_key() -> x25519::PrivateKey {
let mut bytes = [0u8; 32];
let mut rng = rand::rng();
rng.fill_bytes(&mut bytes);
x25519::PrivateKey::from_secret(bytes)
}
#[test]
fn test_kkt_wrappers_oneway_authenticated() {
let mut rng = rand::rng();
// Generate Ed25519 keypairs for both parties
let mut initiator_secret = [0u8; 32];
rng.fill_bytes(&mut initiator_secret);
let ed25519_init = ed25519::KeyPair::from_secret(initiator_secret, 0);
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let ed25519_resp = ed25519::KeyPair::from_secret(responder_secret, 1);
let x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
// Generate responder's KEM keypair (X25519 for testing)
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
// Create ciphersuite
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
// Hash the KEM key (simulating directory storage)
let key_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&responder_kem_key.encode(),
);
// Client: Request KEM key
let (session_key, mut context, request_frame_ciphertext) = request_kem_key(
&mut rng,
ciphersuite,
ed25519_init.private_key(),
&x25519_resp_pub,
)
.unwrap();
// Gateway: Handle request
let response_frame_ciphertext = handle_kem_request(
&mut rng,
&request_frame_ciphertext,
Some(ed25519_init.public_key()), // Authenticated
ed25519_resp.private_key(),
&x25519_resp_priv,
&responder_kem_key,
)
.unwrap();
// Client: Validate response
let obtained_key = validate_kem_response(
&mut context,
&session_key,
ed25519_resp.public_key(),
&key_hash,
&response_frame_ciphertext,
)
.unwrap();
// Verify we got the correct KEM key
assert_eq!(obtained_key.encode(), responder_kem_key.encode());
}
#[test]
fn test_kkt_wrappers_anonymous() {
let mut rng = rand::rng();
// Only responder has keys
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1);
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
let x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let key_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&responder_kem_key.encode(),
);
// Anonymous initiator
let (mut context, request_frame) =
anonymous_initiator_process(&mut rng, ciphersuite).unwrap();
// Generate the session's shared secret and encrypt the Initiator's request
let (session_secret, encrypted_request_bytes) =
encrypt_initial_kkt_frame(&mut rng, &x25519_resp_pub, &request_frame).unwrap();
// Gateway: Handle anonymous request
let response_frame = handle_kem_request(
&mut rng,
&encrypted_request_bytes,
None, // Anonymous - no verification key
responder_keypair.private_key(),
&x25519_resp_priv,
&responder_kem_key,
)
.unwrap();
// Initiator: Validate response
let obtained_key = validate_kem_response(
&mut context,
&session_secret,
responder_keypair.public_key(),
&key_hash,
&response_frame,
)
.unwrap();
assert_eq!(obtained_key.encode(), responder_kem_key.encode());
}
#[test]
fn test_invalid_signature_rejected() {
let mut rng = rand::rng();
let mut initiator_secret = [0u8; 32];
rng.fill_bytes(&mut initiator_secret);
let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0);
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1);
let x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
// Different keypair for wrong signature
let mut wrong_secret = [0u8; 32];
rng.fill_bytes(&mut wrong_secret);
let wrong_keypair = ed25519::KeyPair::from_secret(wrong_secret, 2);
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (_session_key, _context, request_frame_ciphertext) = request_kem_key(
&mut rng,
ciphersuite,
initiator_keypair.private_key(),
&x25519_resp_pub,
)
.unwrap();
// Gateway handles request but we provide WRONG verification key
let result = handle_kem_request(
&mut rng,
&request_frame_ciphertext,
Some(wrong_keypair.public_key()), // Wrong key!
responder_keypair.private_key(),
&x25519_resp_priv,
&responder_kem_key,
);
// Should fail signature verification
assert!(result.is_err());
}
#[test]
fn test_hash_mismatch_rejected() {
let mut rng = rand::rng();
let mut initiator_secret = [0u8; 32];
rng.fill_bytes(&mut initiator_secret);
let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0);
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1);
let x25519_resp_priv = random_x25519_key();
let x25519_resp_pub = x25519::PublicKey::from(&x25519_resp_priv);
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
// Use WRONG hash
let wrong_hash = [0u8; 32];
let (session_key, mut context, request_frame) = request_kem_key(
&mut rng,
ciphersuite,
initiator_keypair.private_key(),
&x25519_resp_pub,
)
.unwrap();
let response_frame = handle_kem_request(
&mut rng,
&request_frame,
Some(initiator_keypair.public_key()),
responder_keypair.private_key(),
&x25519_resp_priv,
&responder_kem_key,
)
.unwrap();
// Client validates with WRONG hash
let result = validate_kem_response(
&mut context,
&session_key,
responder_keypair.public_key(),
&wrong_hash, // Wrong!
&response_frame,
);
// Should fail hash validation
assert!(result.is_err());
}
}
+497
View File
@@ -0,0 +1,497 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod ciphersuite;
pub mod context;
pub mod encryption;
pub mod error;
pub mod frame;
pub mod key_utils;
pub mod kkt;
pub mod session;
// This must be less than 4 bits
pub const KKT_VERSION: u8 = 1;
const _: () = assert!(KKT_VERSION < 1 << 4);
#[cfg(test)]
mod test {
use crate::kkt::KKT_RESPONSE_AAD;
use crate::{
ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM},
encryption::{
decrypt_initial_kkt_frame, decrypt_kkt_frame, encrypt_initial_kkt_frame,
encrypt_kkt_frame,
},
frame::KKTFrame,
key_utils::{
generate_keypair_ed25519, generate_keypair_libcrux, generate_keypair_mceliece,
generate_keypair_x25519, hash_encapsulation_key,
},
session::{
anonymous_initiator_process, initiator_ingest_response, initiator_process,
responder_ingest_message, responder_process,
},
};
#[test]
fn test_kkt_psq_e2e_clear() {
let mut rng = rand::rng();
// generate ed25519 keys
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::SHAKE128,
HashFunction::SHAKE256,
] {
let ciphersuite = Ciphersuite::resolve_ciphersuite(
kem,
hash_function,
crate::ciphersuite::SignatureScheme::Ed25519,
None,
)
.unwrap();
// generate kem public keys
let (responder_kem_public_key, initiator_kem_public_key) = match kem {
KEM::MlKem768 => (
EncapsulationKey::MlKem768(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
EncapsulationKey::MlKem768(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
),
KEM::XWing => (
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
),
KEM::X25519 => (
EncapsulationKey::X25519(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
EncapsulationKey::X25519(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
),
KEM::McEliece => (
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
),
};
let i_kem_key_bytes = initiator_kem_public_key.encode();
let r_kem_key_bytes = responder_kem_public_key.encode();
let i_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&i_kem_key_bytes,
);
let r_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&r_kem_key_bytes,
);
// Anonymous Initiator, OneWay
{
let (mut i_context, i_frame) =
anonymous_initiator_process(&mut rng, ciphersuite).unwrap();
let i_frame_bytes = i_frame.to_bytes();
let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap();
let (mut r_context, _) =
responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap();
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
let r_bytes = r_frame.to_bytes();
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, OneWay
{
let (mut i_context, i_frame) = initiator_process(
&mut rng,
crate::context::KKTMode::OneWay,
ciphersuite,
initiator_ed25519_keypair.private_key(),
None,
)
.unwrap();
let i_frame_bytes = i_frame.to_bytes();
let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap();
let (mut r_context, r_obtained_key) = responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
None,
&i_frame_r,
)
.unwrap();
assert!(r_obtained_key.is_none());
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
let r_bytes = r_frame.to_bytes();
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, Mutual
{
let (mut i_context, i_frame) = initiator_process(
&mut rng,
crate::context::KKTMode::Mutual,
ciphersuite,
initiator_ed25519_keypair.private_key(),
Some(&initiator_kem_public_key),
)
.unwrap();
let i_frame_bytes = i_frame.to_bytes();
let (i_frame_r, r_context) = KKTFrame::from_bytes(&i_frame_bytes).unwrap();
let (mut r_context, r_obtained_key) = responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
Some(&i_dir_hash),
&i_frame_r,
)
.unwrap();
assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
let r_bytes = r_frame.to_bytes();
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
}
}
}
#[test]
fn test_kkt_psq_e2e_encrypted() {
let mut rng = rand::rng();
// generate ed25519 keys
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
// generate responder x25519 keys
let responder_x25519_keypair = generate_keypair_x25519(&mut rng);
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::SHAKE128,
HashFunction::SHAKE256,
] {
let ciphersuite = Ciphersuite::resolve_ciphersuite(
kem,
hash_function,
crate::ciphersuite::SignatureScheme::Ed25519,
None,
)
.unwrap();
// generate kem public keys
let (responder_kem_public_key, initiator_kem_public_key) = match kem {
KEM::MlKem768 => (
EncapsulationKey::MlKem768(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
EncapsulationKey::MlKem768(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
),
KEM::XWing => (
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
),
KEM::X25519 => (
EncapsulationKey::X25519(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
EncapsulationKey::X25519(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
),
KEM::McEliece => (
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
),
};
let i_kem_key_bytes = initiator_kem_public_key.encode();
let r_kem_key_bytes = responder_kem_public_key.encode();
let i_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&i_kem_key_bytes,
);
let r_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&r_kem_key_bytes,
);
// Anonymous Initiator, OneWay
{
let (mut i_context, i_frame) =
anonymous_initiator_process(&mut rng, ciphersuite).unwrap();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
.unwrap();
// decryption - initiator frame
let (r_session_secret, i_frame_r, i_context_r) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_bytes)
.unwrap();
let (mut r_context, _) =
responder_ingest_message(&i_context_r, None, None, &i_frame_r).unwrap();
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
// decryption - responder frame
let (i_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, OneWay
{
let (mut i_context, i_frame) = initiator_process(
&mut rng,
crate::context::KKTMode::OneWay,
ciphersuite,
initiator_ed25519_keypair.private_key(),
None,
)
.unwrap();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
.unwrap();
// decryption - initiator frame
let (r_session_secret, i_frame_r, r_context) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_bytes)
.unwrap();
let (mut r_context, r_obtained_key) = responder_ingest_message(
&r_context,
Some(initiator_ed25519_keypair.public_key()),
None,
&i_frame_r,
)
.unwrap();
assert!(r_obtained_key.is_none());
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
// decryption - responder frame
let (i_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, Mutual
{
let (mut i_context, i_frame) = initiator_process(
&mut rng,
crate::context::KKTMode::Mutual,
ciphersuite,
initiator_ed25519_keypair.private_key(),
Some(&initiator_kem_public_key),
)
.unwrap();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
.unwrap();
// decryption - initiator frame
let (r_session_secret, i_frame_r, i_context_r) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_bytes)
.unwrap();
let (mut r_context, r_obtained_key) = responder_ingest_message(
&i_context_r,
Some(initiator_ed25519_keypair.public_key()),
Some(&i_dir_hash),
&i_frame_r,
)
.unwrap();
assert_eq!(r_obtained_key.unwrap().encode(), i_kem_key_bytes);
let r_frame = responder_process(
&mut r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
// decryption - responder frame
let (i_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let i_obtained_key = initiator_ingest_response(
&mut i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
}
}
}
}
+230
View File
@@ -0,0 +1,230 @@
use nym_crypto::asymmetric::ed25519::{self, Signature};
use rand::{CryptoRng, RngCore};
use crate::frame::KKTSessionId;
use crate::{
ciphersuite::{Ciphersuite, EncapsulationKey},
context::{KKTContext, KKTMode, KKTRole, KKTStatus},
error::KKTError,
frame::{KKT_SESSION_ID_LEN, KKTFrame},
key_utils::validate_encapsulation_key,
};
pub fn initiator_process<'a, R>(
rng: &mut R,
mode: KKTMode,
ciphersuite: Ciphersuite,
signing_key: &ed25519::PrivateKey,
own_encapsulation_key: Option<&EncapsulationKey<'a>>,
) -> Result<(KKTContext, KKTFrame), KKTError>
where
R: CryptoRng + RngCore,
{
let context = KKTContext::new(KKTRole::Initiator, mode, ciphersuite)?;
let context_bytes = context.encode()?;
let mut session_id = [0; KKT_SESSION_ID_LEN];
// Generate Session ID
rng.fill_bytes(&mut session_id);
let body: &[u8] = match mode {
KKTMode::OneWay => &[],
KKTMode::Mutual => match own_encapsulation_key {
Some(encaps_key) => &encaps_key.encode(),
// Missing key
None => {
return Err(KKTError::FunctionInputError {
info: "KEM Key Not Provided",
});
}
},
};
let mut bytes_to_sign =
Vec::with_capacity(context.full_message_len() - context.signature_len());
bytes_to_sign.extend_from_slice(&context_bytes);
bytes_to_sign.extend_from_slice(body);
bytes_to_sign.extend_from_slice(&session_id);
let signature = signing_key.sign(bytes_to_sign).to_bytes();
Ok((
context,
KKTFrame::new(context_bytes, body, session_id, &signature),
))
}
pub fn anonymous_initiator_process<R>(
rng: &mut R,
ciphersuite: Ciphersuite,
) -> Result<(KKTContext, KKTFrame), KKTError>
where
R: CryptoRng + RngCore,
{
let context = KKTContext::new(KKTRole::AnonymousInitiator, KKTMode::OneWay, ciphersuite)?;
let context_bytes = context.encode()?;
let mut session_id = [0u8; KKT_SESSION_ID_LEN];
rng.fill_bytes(&mut session_id);
Ok((context, KKTFrame::new(context_bytes, &[], session_id, &[])))
}
pub fn initiator_ingest_response<'a>(
own_context: &mut KKTContext,
remote_frame: &KKTFrame,
remote_context: &KKTContext,
remote_verification_key: &ed25519::PublicKey,
expected_hash: &[u8],
) -> Result<EncapsulationKey<'a>, KKTError> {
check_compatibility(own_context, remote_context)?;
match remote_context.status() {
KKTStatus::Ok => {
let mut bytes_to_verify: Vec<u8> = Vec::with_capacity(
remote_context.full_message_len() - remote_context.signature_len(),
);
bytes_to_verify.extend_from_slice(&remote_context.encode()?);
bytes_to_verify.extend_from_slice(remote_frame.body_ref());
bytes_to_verify.extend_from_slice(remote_frame.session_id_ref());
match Signature::from_bytes(remote_frame.signature_ref()) {
Ok(sig) => match remote_verification_key.verify(bytes_to_verify, &sig) {
Ok(()) => {
let received_encapsulation_key = EncapsulationKey::decode(
own_context.ciphersuite().kem(),
remote_frame.body_ref(),
)?;
match validate_encapsulation_key(
&own_context.ciphersuite().hash_function(),
own_context.ciphersuite().hash_len(),
remote_frame.body_ref(),
expected_hash,
) {
true => Ok(received_encapsulation_key),
// The key does not match the hash obtained from the directory
false => Err(KKTError::KEMError {
info: "Hash of received encapsulation key does not match the value stored on the directory.",
}),
}
}
Err(_) => Err(KKTError::SigVerifError),
},
Err(_) => Err(KKTError::SigConstructorError),
}
}
_ => Err(KKTError::ResponderFlaggedError {
status: remote_context.status(),
}),
}
}
// todo: figure out how to handle errors using status codes
pub fn responder_ingest_message<'a>(
remote_context: &KKTContext,
remote_verification_key: Option<&ed25519::PublicKey>,
expected_hash: Option<&[u8]>,
remote_frame: &KKTFrame,
) -> Result<(KKTContext, Option<EncapsulationKey<'a>>), KKTError> {
let own_context = remote_context.derive_responder_header()?;
match remote_context.role() {
KKTRole::AnonymousInitiator => Ok((own_context, None)),
KKTRole::Initiator => {
match remote_verification_key {
Some(remote_verif_key) => {
let mut bytes_to_verify: Vec<u8> = Vec::with_capacity(
own_context.full_message_len() - own_context.signature_len(),
);
bytes_to_verify.extend_from_slice(remote_frame.context_ref());
bytes_to_verify.extend_from_slice(remote_frame.body_ref());
bytes_to_verify.extend_from_slice(remote_frame.session_id_ref());
match Signature::from_bytes(remote_frame.signature_ref()) {
Ok(sig) => match remote_verif_key.verify(bytes_to_verify, &sig) {
Ok(()) => {
// using own_context here because maybe for whatever reason we want to ignore the remote kem key
match own_context.mode() {
KKTMode::OneWay => Ok((own_context, None)),
KKTMode::Mutual => {
match expected_hash {
Some(expected_hash) => {
let received_encapsulation_key =
EncapsulationKey::decode(
own_context.ciphersuite().kem(),
remote_frame.body_ref(),
)?;
if validate_encapsulation_key(
&own_context.ciphersuite().hash_function(),
own_context.ciphersuite().hash_len(),
remote_frame.body_ref(),
expected_hash,
) {
Ok((
own_context,
Some(received_encapsulation_key),
))
}
// The key does not match the hash obtained from the directory
else {
Err(KKTError::KEMError {
info: "Hash of received encapsulation key does not match the value stored on the directory.",
})
}
}
None => Err(KKTError::FunctionInputError {
info: "Expected hash of the remote encapsulation key is not provided.",
}),
}
}
}
}
Err(_) => Err(KKTError::SigVerifError),
},
Err(_) => Err(KKTError::SigConstructorError),
}
}
None => Err(KKTError::FunctionInputError {
info: "Remote Signature Verification Key Not Provided",
}),
}
}
KKTRole::Responder => Err(KKTError::IncompatibilityError {
info: "Responder received a request from another responder.",
}),
}
}
pub fn responder_process<'a>(
own_context: &mut KKTContext,
session_id: KKTSessionId,
signing_key: &ed25519::PrivateKey,
encapsulation_key: &EncapsulationKey<'a>,
) -> Result<KKTFrame, KKTError> {
let body = encapsulation_key.encode();
let context_bytes = own_context.encode()?;
let mut bytes_to_sign =
Vec::with_capacity(own_context.full_message_len() - own_context.signature_len());
bytes_to_sign.extend_from_slice(&own_context.encode()?);
bytes_to_sign.extend_from_slice(&body);
bytes_to_sign.extend_from_slice(&session_id);
let signature = signing_key.sign(bytes_to_sign).to_bytes();
Ok(KKTFrame::new(context_bytes, &body, session_id, &signature))
}
fn check_compatibility(
_own_context: &KKTContext,
_remote_context: &KKTContext,
) -> Result<(), KKTError> {
// todo: check ciphersuite/context compatibility
Ok(())
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "nym-lp-common"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[dependencies]
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt;
use std::fmt::Write;
pub fn format_debug_bytes(bytes: &[u8]) -> Result<String, fmt::Error> {
let mut out = String::new();
const LINE_LEN: usize = 16;
for (i, chunk) in bytes.chunks(LINE_LEN).enumerate() {
let line_prefix = format!("[{}:{}]", 1 + i * LINE_LEN, i * LINE_LEN + chunk.len());
write!(out, "{line_prefix:12}")?;
let mut line = String::new();
for b in chunk {
line.push_str(format!("{:02x} ", b).as_str());
}
write!(
out,
"{line:48} {}",
chunk
.iter()
.map(|&b| b as char)
.map(|c| if c.is_alphanumeric() { c } else { '.' })
.collect::<String>()
)?;
writeln!(out)?;
}
Ok(out)
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "nym-lp-transport"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
tokio = { workspace = true, features = ["net"] }
nym-test-utils = { path = "../test-utils", optional = true }
[features]
io-mocks = ["nym-test-utils"]
[lints]
workspace = true
+4
View File
@@ -0,0 +1,4 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod traits;
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#[cfg(feature = "io-mocks")]
use nym_test_utils::mocks::async_read_write::MockIOStream;
use std::net::SocketAddr;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
// only used in internal code (and tests)
#[allow(async_fn_in_trait)]
pub trait LpTransport: AsyncRead + AsyncWrite + Sized {
async fn connect(endpoint: SocketAddr) -> std::io::Result<Self>;
fn set_no_delay(&mut self, nodelay: bool) -> std::io::Result<()>;
}
impl LpTransport for TcpStream {
async fn connect(endpoint: SocketAddr) -> std::io::Result<Self> {
TcpStream::connect(endpoint).await
}
fn set_no_delay(&mut self, nodelay: bool) -> std::io::Result<()> {
// Set TCP_NODELAY for low latency
self.set_nodelay(nodelay)
}
}
#[cfg(feature = "io-mocks")]
impl LpTransport for MockIOStream {
async fn connect(_endpoint: SocketAddr) -> std::io::Result<Self> {
Ok(MockIOStream::default())
}
fn set_no_delay(&mut self, _nodelay: bool) -> std::io::Result<()> {
Ok(())
}
}
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "nym-lp"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[dependencies]
thiserror = { workspace = true }
parking_lot = { workspace = true }
snow = { workspace = true }
bs58 = { workspace = true }
serde = { workspace = true }
bytes = { workspace = true }
dashmap = { workspace = true }
sha2 = { workspace = true }
tracing = { workspace = true }
rand = { workspace = true }
# rand 0.9 for KKT integration (nym-kkt uses rand 0.9)
rand09 = { package = "rand", version = "0.9.2" }
nym-crypto = { path = "../crypto", features = ["hashing", "asymmetric"] }
nym-kkt = { path = "../nym-kkt" }
nym-lp-common = { path = "../nym-lp-common" }
# libcrux dependencies for PSQ (Post-Quantum PSK derivation)
libcrux-psq = { git = "https://github.com/cryspen/libcrux", features = [
"test-utils",
] }
libcrux-kem = { git = "https://github.com/cryspen/libcrux" }
libcrux-traits = { git = "https://github.com/cryspen/libcrux" }
tls_codec = { workspace = true }
num_enum = { workspace = true }
chacha20poly1305 = { workspace = true }
zeroize = { workspace = true, features = ["zeroize_derive"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand_chacha = "0.3"
nym-crypto = { path = "../crypto", features = ["rand"] }
[[bench]]
name = "replay_protection"
harness = false
+365
View File
@@ -0,0 +1,365 @@
# LP Protocol Design
## Overview
The Lewes Protocol (LP) provides authenticated, encrypted sessions with replay protection. Key design principles:
1. **Unified packet structure** - Same format for all packet types
2. **Receiver index** - Client-proposed session identifier (replaces computed session_id)
3. **Opportunistic encryption** - Header authentication and payload encryption as soon as PSK is available
4. **WireGuard-inspired simplicity** - Minimal header, clear security model
## Packet Structure
### Unified Format (v2)
All packets share the same outer structure - cleartext fields are always first:
```
┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐
│ receiver_index │ counter │ version │ reserved │ payload │ trailer │
│ 4B │ 8B │ 1B │ 3B │ variable │ 16B │
└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘
│←── 12B outer header ────┤│←── inner (cleartext or encrypted) ──────┤│─ 16B ──┤
```
**Total overhead:** 32 bytes (12B outer + 4B inner prefix + 16B trailer)
Key properties:
- **Outer header** (12 bytes): Always cleartext, used for routing before session lookup
- **Inner content**: Cleartext before PSK, encrypted after PSK
- **No disambiguation needed**: Format is identical for both modes
### Field Descriptions
**Outer Header** (always cleartext, 12 bytes):
| Field | Size | Description |
|-------|------|-------------|
| receiver_index | 4 bytes | Session identifier, proposed by client (routing key) |
| counter | 8 bytes | Monotonic counter, used as AEAD nonce and for replay protection |
**Inner Content** (cleartext or encrypted):
| Field | Size | Description |
|-------|------|-------------|
| version | 1 byte | Protocol version |
| reserved | 3 bytes | Reserved for future use |
| payload | variable | Message type (2B) + content |
| trailer | 16 bytes | Zeros (no PSK) or AEAD Poly1305 tag (with PSK) |
### Wire Format
Length-prefixed over TCP:
```
┌────────────────────┬─────────────────────────────────────────────────────┐
│ length (4B BE u32) │ LpPacket │
└────────────────────┴─────────────────────────────────────────────────────┘
```
## Message Types
| Type | Value | Description |
|------|-------|-------------|
| Busy | 0x0000 | Server congestion signal |
| Handshake | 0x0001 | Noise protocol messages |
| EncryptedData | 0x0002 | Encrypted application data |
| ClientHello | 0x0003 | Initial session setup |
| KKTRequest | 0x0004 | KEM key transfer request |
| KKTResponse | 0x0005 | KEM key transfer response |
| ForwardPacket | 0x0006 | Nested session forwarding |
| Collision | 0x0007 | Receiver index collision |
| Ack | 0x0008 | Gateway confirms receipt of message |
### Planned Message Types (not yet implemented)
| Type | Value | Description |
|------|-------|-------------|
| SubsessionRequest | 0x0009 | Client requests new subsession |
| SubsessionKK1 | 0x000A | KK handshake msg 1 (responder → initiator) |
| SubsessionKK2 | 0x000B | KK handshake msg 2 (initiator → responder) |
| SubsessionReady | 0x000C | Subsession established confirmation |
## Receiver Index
### Assignment
The client generates a random 4-byte receiver_index and includes it in ClientHello. The gateway uses this as the session lookup key. This replaces the previous approach of computing a deterministic session_id from both parties' keys.
### Collision Handling
With 4 bytes (2^32 values), collision probability is negligible:
| Active Sessions | Collision Probability |
|-----------------|----------------------|
| 10,000 | ~0.001% |
| 100,000 | ~0.1% |
If collision detected, gateway rejects ClientHello and client retries with new index.
## Opportunistic Encryption
### Principle
As soon as PSK is derived (after processing Noise msg 1 with PSQ), all subsequent packets use outer AEAD encryption:
- **Header**: Authenticated as associated data (AD)
- **Payload**: Encrypted (message type + content)
- **Trailer**: AEAD tag
### Timeline
| Packet | PSK Available | Header | Payload | Trailer |
|--------|---------------|--------|---------|---------|
| ClientHello | No | Clear | Clear | Zeros |
| Ack | No | Clear | Clear | Zeros |
| KKTRequest | No | Clear | Clear | Zeros |
| KKTResponse | No | Clear | Clear | Zeros |
| Noise msg 1 | No | Clear | Clear | Zeros |
| | | **PSK derived** | | |
| Noise msg 2 | Yes | Authenticated | Encrypted | Tag |
| Noise msg 3 | Yes | Authenticated | Encrypted | Tag |
| Data | Yes | Authenticated | Encrypted | Tag |
### Encryption Scheme
- **AEAD**: ChaCha20-Poly1305
- **Key**: outer_key = KDF(PSK, "lp-outer-aead") - derived from PSK, not PSK itself
- **Nonce**: counter (8 bytes, zero-padded to 12 bytes)
- **AAD**: receiver_index ‖ counter (12 bytes) - the outer header
- **Encrypted**: version ‖ reserved ‖ message_type ‖ content
Note: PSK is used as-is for Noise (which does internal key derivation). The outer_key derivation avoids key reuse between the two encryption layers.
### Before PSK
```
┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐
│ receiver_index │ counter │ version │ reserved │ payload │ 00...00 │
│ │ │ │ │ (plaintext) │ │
└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘
│←── 12B outer ──────────┤│←────────────── cleartext inner ──────────┤│─zeros──┤
```
### After PSK
```
┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐
│ receiver_index │ counter │ version │ reserved │ payload │ tag │
│ │ │ (enc) │ (enc) │ (encrypted) │ │
└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘
│←── 12B outer (AAD) ────┤│←────────── encrypted inner ──────────────┤│─ tag ──┤
```
## Handshake Flow
Each arrow represents a separate TCP connection (packet-per-connection model).
```
Client Gateway
│ │
│ [hdr][ClientHello][zeros] │
│──────────────────────────────────────►│ store state[receiver_index]
│ │
│ [hdr][Ack][zeros] │
│◄──────────────────────────────────────│ confirm ClientHello
│ │
│ [hdr][KKTRequest][zeros] │
│──────────────────────────────────────►│
│ │
│ [hdr][KKTResponse][zeros] │
│◄──────────────────────────────────────│
│ │
│ [hdr][Noise1+PSQ][zeros] │
│──────────────────────────────────────►│ derive PSK
│ │
│ [hdr][encrypted Noise2][tag] │ ← authenticated
│◄──────────────────────────────────────│
│ │
│ [hdr][encrypted Noise3][tag] │ ← authenticated
│──────────────────────────────────────►│
│ │
│ ════════ Session Established ═════════│
│ │
│ [hdr][encrypted Data][tag] │
│◄─────────────────────────────────────►│
```
## Data Packet Encryption
Data packets have two encryption layers:
```
Application Data
┌─────────────────────┐
│ Noise encrypt │ Inner layer (forward secrecy, ratcheting)
│ (session keys) │
└─────────────────────┘
┌─────────────────────┐
│ PSK AEAD │ Outer layer (header auth, payload encryption)
│ (pre-shared key) │
└─────────────────────┘
Wire: [header][encrypted payload][tag]
```
### What Outer AEAD Encrypts
The outer AEAD encrypts: message_type (2B) + message content
This hides the message type from observers after PSK is available.
## Subsessions and Rekeying
Subsessions enable **forward secrecy** through periodic rekeying and **channel multiplexing** for independent encrypted streams.
### Design Principles
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Key derivation | Noise KK handshake | Clean crypto, both parties already authenticated |
| Initiation channel | Tunneled through parent | Already authenticated, no proof-of-ownership needed |
| Hierarchy | Promotion model (chain) | Simpler than tree, natural for rekeying |
| Old session after promotion | Read-only until TTL | Drains in-flight packets, provides grace period |
### Noise KK Pattern
Subsessions use `Noise_KK_25519_ChaChaPoly_SHA256`:
- **KK** = Both parties already know each other's static keys
- **2 messages** to complete (vs 3 for XKpsk3)
- **No PSK needed** - already authenticated via parent session
### Promotion Model
When a subsession is created, it becomes the new "master" and the old session becomes read-only:
```
Session A (master) → Session B created → A demoted, B is master
A: read-only until TTL
```
This creates a chain (A → B → C) but maintains only one level of nesting conceptually. Each promotion replaces the previous master.
### Protocol Flow
```
Client Gateway
│ │
│═══════ Parent Session (A) ════════│ Transport mode
│ │
│──[SubsessionRequest{idx=B}]──────►│ Encrypted in parent
│ │ Gateway creates KK responder
│◄──[SubsessionKK1{idx=B, e}]───────│ KK handshake msg 1
│──[SubsessionKK2{idx=B, e,ee,se}]─►│ KK handshake msg 2
│◄──[SubsessionReady{idx=B}]────────│ Subsession established
│ │
│ Session A: read-only (receive) │
│═══════ Session B (new master) ════│ New Transport mode
```
### Session State Transitions
```
Parent Session (A):
Transport → ReadOnlyTransport (on subsession creation)
ReadOnlyTransport → (expires via TTL cleanup)
Subsession (B):
(created) → KKHandshaking → Transport (becomes new master)
```
### Read-Only Session Semantics
After demotion:
- **Can receive**: Decrypt and process incoming packets (drain in-flight)
- **Cannot send**: Encryption blocked, returns error
- **Cleaned up**: Via normal TTL expiration
### Message Formats
```rust
SubsessionRequestData {
new_receiver_index: u32, // Client-proposed index for subsession
}
SubsessionKK1Data {
new_receiver_index: u32,
kk_message: Vec<u8>, // Noise KK message 1
}
SubsessionKK2Data {
new_receiver_index: u32,
kk_message: Vec<u8>, // Noise KK message 2
}
SubsessionReadyData {
new_receiver_index: u32,
}
```
### Counter Independence
- Each session has independent counters
- Subsession starts at counter 0
- No counter coordination needed between parent and subsession
### Failure Handling
| Scenario | Action |
|----------|--------|
| KK handshake fails | Discard attempt, keep using parent |
| Receiver index collision | Retry with new receiver_index |
| Parent session not found | Return error, client reconnects |
### Security Benefits
1. **Forward secrecy**: Compromise of current keys doesn't expose past traffic
2. **Key rotation**: Periodic rekeying limits exposure window
3. **Channel isolation**: Independent streams can't cross-decrypt
## Security Properties
### Always Visible to Observer
Only the outer header (12 bytes) is visible after PSK establishment:
- Receiver index (4 bytes) - opaque, unlinkable to identity
- Counter (8 bytes) - reveals packet ordering
- Packet size
Note: Before PSK, version, reserved, and message type are also visible.
### Protected After PSK
- Outer header integrity (authenticated via AEAD AAD)
- Inner content confidentiality (encrypted):
- Protocol version
- Reserved field
- Message type
- Payload
- Application data (double encrypted: outer AEAD + inner Noise)
### Cryptographic Guarantees
| Property | Mechanism |
|----------|-----------|
| Confidentiality | ChaCha20 (outer) + Noise ChaCha20 (inner) |
| Integrity | Poly1305 (outer) + Noise Poly1305 (inner) |
| Replay protection | Counter validation (before decryption) |
| Forward secrecy | Noise session keys (inner) + subsession rekeying |
| Header authentication | AEAD associated data |
| Key rotation | Periodic subsession creation (Noise KK) |
## References
- WireGuard Protocol - Inspiration for receiver_index and packet simplicity
- Noise Protocol Framework - Inner encryption layer, KK pattern for subsessions
- RFC 8439 ChaCha20-Poly1305 - AEAD cipher
- Noise Explorer KK - https://noiseexplorer.com/patterns/KK/
+309
View File
@@ -0,0 +1,309 @@
# Nym Lewes Protocol
The Lewes Protocol (LP) is a secure network communication protocol implemented in Rust. It provides authenticated, encrypted sessions with replay protection and supports nested session forwarding for privacy-preserving multi-hop connections.
## Architecture Overview
```
┌─────────────────┐ ┌────────────────┐ ┌───────────────┐
│ Transport Layer │◄───►│ LP Session │◄───►│ LP Codec │
│ (TCP) │ │ - State machine│ │ - Serialize │
└─────────────────┘ │ - Noise crypto │ │ - Deserialize │
│ - Replay prot. │ └───────────────┘
└────────────────┘
```
## Packet Structure
The protocol uses a length-prefixed packet format over TCP:
```
Wire Format:
┌────────────────────┬─────────────────────────────────────────┐
│ Length (4B BE u32) │ LpPacket │
└────────────────────┴─────────────────────────────────────────┘
LpPacket:
┌──────────────────┬───────────────────┬──────────────────┐
│ Header (16B) │ Message │ Trailer (16B) │
├──────────────────┼───────────────────┼──────────────────┤
│ Version (1B) │ Type (2B LE u16) │ Reserved │
│ Reserved (3B) │ Content (var) │ (16 bytes) │
│ SessionID (4B LE)│ │ │
│ Counter (8B LE) │ │ │
└──────────────────┴───────────────────┴──────────────────┘
```
- **Header**: Protocol version (1), session identifier, monotonic counter
- **Message**: Type discriminant + variable-length content
- **Trailer**: Reserved for future use (16 bytes)
## Message Types
| Type | Value | Purpose |
|------|-------|---------|
| `Busy` | 0x0000 | Server congestion signal |
| `Handshake` | 0x0001 | Noise protocol handshake messages |
| `EncryptedData` | 0x0002 | Encrypted application data |
| `ClientHello` | 0x0003 | Initial session negotiation |
| `KKTRequest` | 0x0004 | KEM Key Transfer request |
| `KKTResponse` | 0x0005 | KEM Key Transfer response |
| `ForwardPacket` | 0x0006 | Nested session forwarding |
## Session Establishment
### Session ID
Sessions are identified by a deterministic 32-bit ID computed from both parties' X25519 public keys:
```
session_id = make_lp_id(client_x25519_pub, gateway_x25519_pub)
```
The computation is order-independent, allowing both sides to derive the same ID independently.
**BOOTSTRAP_SESSION_ID (0)**: A special session ID used only for the initial `ClientHello` packet, since neither side can compute the final ID until both X25519 keys are known.
### Handshake Flow
```
┌────────┐ ┌─────────┐
│ Client │ │ Gateway │
└───┬────┘ └────┬────┘
│ │
│ 1. ClientHello (session_id=0) │
│ [client_x25519, client_ed25519, salt]│
│───────────────────────────────────────►│
│ │ (computes session_id)
│ │ (stores state machine)
│ │
│ 2. KKTRequest (session_id=N) │
│ [signed request for KEM key] │
│───────────────────────────────────────►│
│ │
│ 3. KKTResponse │
│ [gateway KEM key + signature] │
│◄───────────────────────────────────────│
│ │
│ 4. Noise Handshake Msg 1 │
│ [PSQ payload + noise message] │
│───────────────────────────────────────►│
│ │ (derives PSK from PSQ)
│ 5. Noise Handshake Msg 2 │
│ [PSK handle + noise message] │
│◄───────────────────────────────────────│
│ │
│ 6. Noise Handshake Msg 3 │
│───────────────────────────────────────►│
│ │
│ ═══════ Session Established ═══════ │
│ │
│ 7. EncryptedData │
│ [encrypted application data] │
│◄──────────────────────────────────────►│
│ │
```
### ClientHello Data
```rust
struct ClientHelloData {
client_lp_public_key: [u8; 32], // X25519 (derived from Ed25519)
client_ed25519_public_key: [u8; 32], // For authentication
salt: [u8; 32], // timestamp (8B) + nonce (24B)
}
```
## Packet-Per-Connection Model
The gateway processes **exactly one packet per TCP connection**, then closes. State persists between connections via in-memory maps:
```
TCP Connect → Receive Packet → Process → Send Response → TCP Close
```
**State Storage:**
- `handshake_states`: Maps `session_id → LpStateMachine` (during handshake)
- `session_states`: Maps `session_id → LpSession` (after handshake complete)
Both maps use TTL-based cleanup to remove stale entries (default: 5 min handshake, 1 hour session).
### Gateway Packet Routing
```
Packet Received
├─► session_id == 0 (BOOTSTRAP)
│ └─► handle_client_hello()
│ └─► Create state machine, store in handshake_states
├─► session_id in handshake_states
│ └─► handle_handshake_packet()
│ └─► Process KKT/Noise, move to session_states when complete
└─► session_id in session_states
└─► handle_transport_packet()
└─► Decrypt, process registration or forwarding
```
## Session Forwarding
Forwarding enables a client to establish an independent session with an exit gateway through an entry gateway, providing network-level privacy.
### Architecture
```
┌──────────┐
│ Client │
└────┬─────┘
│ Outer LP Session (established, encrypted)
┌────────────────┐
│ Entry Gateway │ Sees: Client IP
│ │ Doesn't see: Exit destination
└────────┬───────┘
│ Forwards inner packets (TCP)
┌────────────────┐
│ Exit Gateway │ Sees: Entry Gateway IP
│ │ Doesn't see: Client IP
└────────────────┘
```
### ForwardPacket Message
```rust
struct ForwardPacketData {
target_gateway_identity: [u8; 32], // Exit gateway's Ed25519 key
target_lp_address: String, // e.g., "2.2.2.2:41264"
inner_packet_bytes: Vec<u8>, // Complete LP packet for exit
}
```
### Forwarding Flow
1. **Client** establishes outer LP session with entry gateway
2. **Client** creates `ClientHello` packet for exit gateway
3. **Client** wraps inner packet in `ForwardPacketData`:
- Sets `target_gateway_identity` to exit's Ed25519 key
- Sets `target_lp_address` to exit's LP listener address
- Serializes complete LP packet as `inner_packet_bytes`
4. **Client** encrypts `ForwardPacketData` using outer session
5. **Client** sends as `EncryptedData` to entry gateway
6. **Entry Gateway** decrypts, sees `ForwardPacketData`
7. **Entry Gateway** connects to exit gateway (new TCP)
8. **Entry Gateway** sends `inner_packet_bytes` directly
9. **Entry Gateway** receives exit's response
10. **Entry Gateway** encrypts response using outer session
11. **Entry Gateway** sends encrypted response to client
12. **Client** decrypts response, processes in inner session state
### NestedLpSession
The `NestedLpSession` struct manages the inner session from the client's perspective:
```rust
struct NestedLpSession {
exit_identity: [u8; 32], // Exit gateway Ed25519
exit_address: String, // Exit LP address
client_keypair: Arc<ed25519::KeyPair>,
exit_public_key: ed25519::PublicKey,
state_machine: Option<LpStateMachine>,
}
```
**Usage:**
```rust
// Create nested session targeting exit gateway
let nested = NestedLpSession::new(exit_identity, exit_address, keypair, exit_pubkey);
// Perform handshake through outer session
nested.handshake_and_register(&mut outer_client).await?;
// Inner session now established with exit gateway
```
## State Machine States
```
ReadyToHandshake
KKTExchange ◄─── KKTRequest/KKTResponse
Handshaking ◄─── Noise messages + PSQ
Transport ◄─── EncryptedData
Closed
```
## Cryptography
### Key Types
- **Ed25519**: Identity keys, signing
- **X25519**: Key exchange (derived from Ed25519 via RFC 7748)
### Noise Protocol
- Pattern: `Noise_XKpsk3_25519_ChaChaPoly_SHA256`
- Provides: Forward secrecy, mutual authentication, PSK binding
### PSK Derivation (PSQ)
The Pre-Shared Key is derived via Post-Quantum Secure Key Exchange:
1. Client encapsulates using authenticated KEM key from KKT
2. Produces 32-byte PSK + ciphertext
3. Gateway decapsulates to derive same PSK
4. PSK injected into Noise at position 3
### Replay Protection
- **Monotonic counter**: Each packet has incrementing 64-bit counter
- **Sliding window**: Bitmap tracks received counters (1024 packet window)
- **SIMD optimized**: Branchless validation for constant-time operation
```rust
// Validation flow
validator.will_accept_branchless(counter) // Check before decrypt
validator.mark_did_receive_branchless(counter) // Mark after decrypt
```
## Sessions
### LpSession Fields
```rust
struct LpSession {
id: u32, // Session identifier
is_initiator: bool, // Client or gateway role
noise_state: NoiseState, // Noise transport state
kkt_state: KktState, // KKT exchange progress
psq_state: PsqState, // PSQ handshake progress
psk_handle: Option<Vec<u8>>,// PSK handle from responder
sending_counter: AtomicU64, // Outgoing packet counter
receiving_counter: Validator, // Replay protection
psk_injected: AtomicBool, // Safety: real PSK injected?
}
```
### PSK Safety
Sessions initialize with a dummy PSK. The `psk_injected` flag must be `true` before `encrypt_data()` or `decrypt_data()` will operate, preventing accidental use of the insecure dummy.
## File Structure
```
common/nym-lp/src/
├── lib.rs # Module exports
├── message.rs # LpMessage enum, ClientHelloData, ForwardPacketData
├── packet.rs # LpPacket, LpHeader, BOOTSTRAP_SESSION_ID
├── codec.rs # Serialization/deserialization
├── session.rs # LpSession, cryptographic operations
├── state_machine.rs # LpStateMachine, state transitions
├── psk.rs # PSK derivation utilities
└── error.rs # Error types
```
+238
View File
@@ -0,0 +1,238 @@
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
use nym_lp::replay::ReceivingKeyCounterValidator;
use parking_lot::Mutex;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::sync::Arc;
fn bench_sequential_counters(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_sequential");
group.sample_size(1000);
for size in [100, 1000, 10000] {
group.throughput(Throughput::Elements(size));
group.bench_with_input(
BenchmarkId::new("sequential_counters", size),
&size,
|b, &size| {
let validator = ReceivingKeyCounterValidator::default();
let counters: Vec<u64> = (0..size).collect();
b.iter(|| {
let mut validator = validator.clone();
for &counter in &counters {
let _ = black_box(validator.will_accept_branchless(counter));
let _ = black_box(validator.mark_did_receive_branchless(counter));
}
});
},
);
}
group.finish();
}
fn bench_out_of_order_counters(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_out_of_order");
group.sample_size(1000);
for size in [100, 1000, 10000] {
group.throughput(Throughput::Elements(size as u64));
group.bench_with_input(
BenchmarkId::new("out_of_order_counters", size),
&size,
|b, &size| {
let validator = ReceivingKeyCounterValidator::default();
// Create random counters within a valid window
let mut rng = ChaCha8Rng::seed_from_u64(42);
let counters: Vec<u64> = (0..size).map(|_| rng.gen_range(0..1024)).collect();
b.iter(|| {
let mut validator = validator.clone();
for &counter in &counters {
let _ = black_box(validator.will_accept_branchless(counter));
let _ = black_box(validator.mark_did_receive_branchless(counter));
}
});
},
);
}
group.finish();
}
fn bench_thread_safety(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_thread_safety");
group.sample_size(1000);
for size in [100, 1000, 10000] {
group.throughput(Throughput::Elements(size));
group.bench_with_input(
BenchmarkId::new("thread_safe_validator", size),
&size,
|b, &size| {
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
let counters: Vec<u64> = (0..size).collect();
b.iter(|| {
for &counter in &counters {
let result = {
let guard = validator.lock();
black_box(guard.will_accept_branchless(counter))
};
if result.is_ok() {
let mut guard = validator.lock();
let _ = black_box(guard.mark_did_receive_branchless(counter));
}
}
});
},
);
}
group.finish();
}
fn bench_window_sliding(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_window_sliding");
group.sample_size(100);
for window_size in [128, 512, 1024] {
group.throughput(Throughput::Elements(window_size));
group.bench_with_input(
BenchmarkId::new("window_sliding", window_size),
&window_size,
|b, &window_size| {
b.iter(|| {
let mut validator = ReceivingKeyCounterValidator::default();
// First fill the window with sequential packets
for i in 0..window_size {
let _ = black_box(validator.mark_did_receive_branchless(i));
}
// Then jump ahead to force window sliding
let _ = black_box(validator.mark_did_receive_branchless(window_size * 3));
// Try some packets in the new window
for i in (window_size * 2 + 1)..(window_size * 3) {
let _ = black_box(validator.will_accept_branchless(i));
}
});
},
);
}
group.finish();
}
/// Benchmark operations that would benefit from SIMD optimization
fn bench_core_operations(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_core_operations");
group.sample_size(1000);
// Create validators with different states
let empty_validator = ReceivingKeyCounterValidator::default();
let mut half_full_validator = ReceivingKeyCounterValidator::default();
let mut full_validator = ReceivingKeyCounterValidator::default();
// Fill validators with different patterns
for i in 0..512 {
half_full_validator.mark_did_receive_branchless(i).unwrap();
}
for i in 0..1024 {
full_validator.mark_did_receive_branchless(i).unwrap();
}
// Benchmark clearing operations
group.bench_function("clear_empty_window", |b| {
b.iter(|| {
let mut validator = empty_validator.clone();
// Force window sliding that will clear bitmap
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
black_box(());
})
});
group.bench_function("clear_half_full_window", |b| {
b.iter(|| {
let mut validator = half_full_validator.clone();
// Force window sliding that will clear bitmap
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
black_box(());
})
});
group.bench_function("clear_full_window", |b| {
b.iter(|| {
let mut validator = full_validator.clone();
// Force window sliding that will clear bitmap
let _: () = validator.mark_did_receive_branchless(2000).unwrap();
black_box(());
})
});
group.finish();
}
/// Benchmark thread safety with different thread counts
fn bench_concurrency_scaling(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_concurrency_scaling");
group.sample_size(50);
for thread_count in [1, 2, 4, 8] {
group.bench_with_input(
BenchmarkId::new("mutex_threads", thread_count),
&thread_count,
|b, &thread_count| {
b.iter(|| {
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
let mut handles = Vec::new();
for t in 0..thread_count {
let validator_clone = Arc::clone(&validator);
let handle = std::thread::spawn(move || {
let mut success_count = 0;
for i in 0..100 {
let counter = t * 1000 + i;
let mut guard = validator_clone.lock();
if guard.mark_did_receive_branchless(counter as u64).is_ok() {
success_count += 1;
}
}
success_count
});
handles.push(handle);
}
let mut total = 0;
for handle in handles {
total += handle.join().unwrap();
}
black_box(total)
})
},
);
}
group.finish();
}
criterion_group!(
replay_benches,
bench_sequential_counters,
bench_out_of_order_counters,
bench_thread_safety,
bench_window_sliding,
bench_core_operations,
bench_concurrency_scaling
);
criterion_main!(replay_benches);
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Configuration for LP protocol.
//!
//! LP security stack = KKT (key fetch) → PSQ (PQ PSK) → Noise (transport).
//! KEM algorithm selection affects only PSQ layer. Noise always uses X25519 DH.
//! Migration to PQ KEMs (MlKem768, XWing) requires only config change.
use nym_kkt::ciphersuite::KEM;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Default PSK time-to-live (1 hour, matches psk.rs implementation).
pub const DEFAULT_PSK_TTL_SECS: u64 = 3600;
/// Configuration for LP protocol.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpConfig {
/// KEM algorithm for PSQ key encapsulation.
/// X25519 = classical (testing), MlKem768 = PQ, XWing = hybrid.
#[serde(with = "kem_serde")]
pub kem_algorithm: KEM,
/// PSK time-to-live in seconds.
pub psk_ttl_secs: u64,
/// Enable KKT for authenticated key distribution.
pub enable_kkt: bool,
}
impl Default for LpConfig {
fn default() -> Self {
Self {
kem_algorithm: KEM::X25519,
psk_ttl_secs: DEFAULT_PSK_TTL_SECS,
enable_kkt: true,
}
}
}
impl LpConfig {
/// Returns PSK TTL as Duration.
pub fn psk_ttl(&self) -> Duration {
Duration::from_secs(self.psk_ttl_secs)
}
}
mod kem_serde {
use nym_kkt::ciphersuite::KEM;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(kem: &KEM, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match kem {
KEM::X25519 => "X25519",
KEM::MlKem768 => "MlKem768",
KEM::XWing => "XWing",
KEM::McEliece => "McEliece",
}
.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<KEM, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"X25519" => Ok(KEM::X25519),
"MlKem768" => Ok(KEM::MlKem768),
"XWing" => Ok(KEM::XWing),
"McEliece" => Ok(KEM::McEliece),
_ => Err(serde::de::Error::custom(format!("Unknown KEM: {}", s))),
}
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{noise_protocol::NoiseError, replay::ReplayError};
use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LpError {
#[error("IO Error: {0}")]
IoError(#[from] std::io::Error),
#[error("Snow Error: {0}")]
SnowKeyError(#[from] snow::Error),
#[error("Snow Pattern Error: {0}")]
SnowPatternError(String),
#[error("Noise Protocol Error: {0}")]
NoiseError(#[from] NoiseError),
#[error("Replay detected: {0}")]
Replay(#[from] ReplayError),
#[error("Invalid packet format: {0}")]
InvalidPacketFormat(String),
#[error("Invalid message type: {0}")]
InvalidMessageType(u32),
#[error("Payload too large: {0}")]
PayloadTooLarge(usize),
#[error("Insufficient buffer size provided")]
InsufficientBufferSize,
#[error("Attempted operation on closed session")]
SessionClosed,
#[error("Internal error: {0}")]
Internal(String),
#[error("Invalid state transition: tried input {input:?} in state {state:?}")]
InvalidStateTransition { state: String, input: String },
#[error("Invalid payload size: expected {expected}, got {actual}")]
InvalidPayloadSize { expected: usize, actual: usize },
#[error("Deserialization error: {0}")]
DeserializationError(String),
#[error("KKT protocol error: {0}")]
KKTError(String),
#[error(transparent)]
InvalidBase58String(#[from] bs58::decode::Error),
/// Session ID from incoming packet does not match any known session.
#[error("Received packet with unknown session ID: {0}")]
UnknownSessionId(u32),
/// Invalid state transition attempt in the state machine.
#[error("Invalid input '{input}' for current state '{state}'")]
InvalidStateTransitionAttempt { state: String, input: String },
/// Session is closed.
#[error("Session is closed")]
LpSessionClosed,
/// Session is processing an input event.
#[error("Session is processing an input event")]
LpSessionProcessing,
/// State machine not found.
#[error("State machine not found for lp_id: {lp_id}")]
StateMachineNotFound { lp_id: u32 },
/// Ed25519 to X25519 conversion error.
#[error("Ed25519 key conversion error: {0}")]
Ed25519RecoveryError(#[from] Ed25519RecoveryError),
/// Outer AEAD authentication tag verification failed.
#[error("AEAD authentication tag verification failed")]
AeadTagMismatch,
/// Received an LP packet with an incompatible, future, version
#[error("incompatible LP packet version. got: {got}, highest supported: {highest_supported}")]
IncompatibleFuturePacketVersion { got: u8, highest_supported: u8 },
/// Received an LP packet with an incompatible, legacy, version
#[error("incompatible LP packet version. got: {got}, lowest supported: {lowest_supported}")]
IncompatibleLegacyPacketVersion { got: u8, lowest_supported: u8 },
}
+499
View File
@@ -0,0 +1,499 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! KKT (Key Encapsulation Transport) orchestration for nym-lp sessions.
//!
//! This module provides functions to perform KKT key exchange before establishing
//! an nym-lp session. The KKT protocol allows secure distribution of post-quantum
//! KEM public keys, which are then used with PSQ to derive a strong pre-shared key
//! for the Noise protocol.
//!
//! # Protocol Flow
//!
//! 1. **Client (Initiator)**:
//! - Calls `create_request()` to generate a KKT request
//! - Sends `LpMessage::KKTRequest` to gateway
//! - Receives `LpMessage::KKTResponse` from gateway
//! - Calls `process_response()` to validate and extract gateway's KEM key
//!
//! 2. **Gateway (Responder)**:
//! - Receives `LpMessage::KKTRequest` from client
//! - Calls `handle_request()` to validate request and generate response
//! - Sends `LpMessage::KKTResponse` to client
//!
//! # Example
//!
//! ```ignore
//! use nym_lp::kkt_orchestrator::{create_request, process_response, handle_request};
//! use nym_lp::message::{KKTRequestData, KKTResponseData};
//! use nym_kkt::ciphersuite::{Ciphersuite, KEM, HashFunction, SignatureScheme, EncapsulationKey};
//!
//! // Setup ciphersuite
//! let ciphersuite = Ciphersuite::resolve_ciphersuite(
//! KEM::X25519,
//! HashFunction::Blake3,
//! SignatureScheme::Ed25519,
//! None,
//! ).unwrap();
//!
//! // Client: Create request
//! let (session_secret, client_context, request_data) = create_request(
//! ciphersuite,
//! &client_signing_key,
//! &responder_dh_public_key
//! ).unwrap();
//!
//! // Gateway: Handle request
//! let response_data = handle_request(
//! &request_data,
//! Some(&client_verification_key),
//! &gateway_signing_key,
//! &gateway_dh_private_key,
//! &gateway_kem_public_key,
//! ).unwrap();
//!
//! // Client: Process response
//! let gateway_kem_key = process_response(
//! client_context,
//! &session_secret,
//! &gateway_verification_key,
//! &expected_key_hash,
//! &response_data,
//! ).unwrap();
//! ```
use crate::LpError;
use crate::message::{KKTRequestData, KKTResponseData};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{Ciphersuite, EncapsulationKey};
use nym_kkt::context::KKTContext;
use nym_kkt::encryption::KKTSessionSecret;
use nym_kkt::kkt::{handle_kem_request, request_kem_key, validate_kem_response};
/// Creates a KKT request to obtain the responder's KEM public key.
///
/// This is called by the **client (initiator)** to begin the KKT exchange.
/// The returned context must be used when processing the response.
///
/// # Arguments
/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms)
/// * `signing_key` - Client's Ed25519 signing key for authentication
/// * `responder_dh_public_key` - Gateway's x25519 public key (from directory)
///
/// # Returns
/// * `KKTSessionSecret` - Session secret key to encrypt/decrypt KKT messages for this session
/// * `KKTContext` - Context to use when validating the response
/// * `KKTRequestData` - Serialized KKT request frame to send to gateway
///
/// # Errors
/// Returns `LpError::KKTError` if KKT request generation fails.
pub fn create_request(
ciphersuite: Ciphersuite,
signing_key: &ed25519::PrivateKey,
responder_dh_public_key: &x25519::PublicKey,
) -> Result<(KKTSessionSecret, KKTContext, KKTRequestData), LpError> {
// Note: Uses rand 0.9's thread_rng() to match nym-kkt's rand version
let mut rng = rand09::rng();
let (session_secret, context, request_bytes) =
request_kem_key(&mut rng, ciphersuite, signing_key, responder_dh_public_key)
.map_err(|e| LpError::KKTError(e.to_string()))?;
Ok((session_secret, context, KKTRequestData(request_bytes)))
}
/// Processes a KKT response and extracts the responder's KEM public key.
///
/// This is called by the **client (initiator)** after receiving a KKT response
/// from the gateway. It verifies the signature and validates the key hash.
///
/// # Arguments
/// * `context` - Context from the initial `create_request()` call
/// * `session_secret` - The KKT session secret key from the initial `create_request()` call
/// * `responder_vk` - Responder's Ed25519 verification key (from directory)
/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory)
/// * `response_data` - Serialized KKT response frame from responder
///
/// # Returns
/// * `EncapsulationKey` - Authenticated KEM public key of the responder
///
/// # Errors
/// Returns `LpError::KKTError` if:
/// - Response deserialization fails
/// - Signature verification fails
/// - Key hash doesn't match expected value
pub fn process_response<'a>(
mut context: KKTContext,
session_secret: &KKTSessionSecret,
responder_vk: &ed25519::PublicKey,
expected_key_hash: &[u8],
response_data: &KKTResponseData,
) -> Result<EncapsulationKey<'a>, LpError> {
validate_kem_response(
&mut context,
session_secret,
responder_vk,
expected_key_hash,
&response_data.0,
)
.map_err(|e| LpError::KKTError(e.to_string()))
}
/// Handles a KKT request and generates a signed response with the responder's KEM key.
///
/// This is called by the **gateway (responder)** when receiving a KKT request
/// from a client. It validates the request signature (if authenticated) and
/// responds with the gateway's KEM public key, signed for authenticity.
///
/// # Arguments
/// * `request_data` - Serialized KKT request frame from initiator
/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous)
/// * `responder_signing_key` - Gateway's Ed25519 signing key
/// * `responder_dh_private_key` - Gateway's x25519 private key
/// * `responder_kem_key` - Gateway's KEM public key to send
///
/// # Returns
/// * `KKTResponseData` - Signed response frame containing the KEM public key
///
/// # Errors
/// Returns `LpError::KKTError` if:
/// - Request deserialization fails
/// - Signature verification fails (if authenticated)
/// - Response generation fails
pub fn handle_request<'a>(
request_data: &KKTRequestData,
initiator_vk: Option<&ed25519::PublicKey>,
responder_signing_key: &ed25519::PrivateKey,
responder_dh_private_key: &x25519::PrivateKey,
responder_kem_key: &EncapsulationKey<'a>,
) -> Result<KKTResponseData, LpError> {
let mut rng = rand09::rng();
// Handle the request and generate response
let response_bytes = handle_kem_request(
&mut rng,
&request_data.0,
initiator_vk,
responder_signing_key,
responder_dh_private_key,
responder_kem_key,
)
.map_err(|e| LpError::KKTError(e.to_string()))?;
Ok(KKTResponseData(response_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use nym_kkt::ciphersuite::{HashFunction, KEM, SignatureScheme};
use nym_kkt::key_utils::{
generate_keypair_ed25519, generate_keypair_libcrux, generate_keypair_x25519,
hash_encapsulation_key,
};
use rand09::RngCore;
#[test]
fn test_kkt_roundtrip_authenticated() {
let mut rng = rand09::rng();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
// Generate responder's KEM keypair (X25519 for testing)
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
// Create ciphersuite
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
// Hash the KEM key (simulating directory storage)
let key_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&responder_kem_key.encode(),
);
// Client: Create request
let (session_secret, context, request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
// Gateway: Handle request
let response_data = handle_request(
&request_data,
Some(initiator_ed25519_keypair.public_key()),
responder_ed25519_keypair.private_key(),
responder_x25519.private_key(),
&responder_kem_key,
)
.unwrap();
// Client: Process response
let obtained_key = process_response(
context,
&session_secret,
responder_ed25519_keypair.public_key(),
&key_hash,
&response_data,
)
.unwrap();
// Verify we got the correct KEM key
assert_eq!(obtained_key.encode(), responder_kem_key.encode());
}
// #[test]
// fn test_kkt_roundtrip_anonymous() {
// let mut rng = rand09::rng();
// // Only responder has keys (anonymous initiator)
// // Generate Ed25519 keypairs for both parties
// let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
// let responder_x25519 = generate_keypair_x25519(&mut rng);
// let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
// let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
// let ciphersuite = Ciphersuite::resolve_ciphersuite(
// KEM::X25519,
// HashFunction::Blake3,
// SignatureScheme::Ed25519,
// None,
// )
// .unwrap();
// let key_hash = hash_encapsulation_key(
// &ciphersuite.hash_function(),
// ciphersuite.hash_len(),
// &responder_kem_key.encode(),
// );
// // Anonymous initiator - use anonymous_initiator_process directly
// use nym_kkt::kkt::anonymous_initiator_process;
// let (mut context, request_frame) =
// anonymous_initiator_process(&mut rng, ciphersuite).unwrap();
// let request_data = KKTRequestData(request_frame.to_bytes());
// // Gateway: Handle anonymous request
// let response_data = handle_request(
// &request_data,
// None,
// responder_ed25519_keypair.private_key(),
// &responder_x25519_sk,
// &responder_kem_key,
// )
// .unwrap();
// // Initiator: Validate response
// let obtained_key = initiator_ingest_response(
// &mut context,
// responder_ed25519_keypair.public_key(),
// &key_hash,
// &response_data.0,
// )
// .unwrap();
// assert_eq!(obtained_key.encode(), responder_kem_key.encode());
// }
#[test]
fn test_invalid_signature_rejected() {
let mut rng = rand09::rng();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
// Different keypair for wrong signature
let mut wrong_secret = [0u8; 32];
rng.fill_bytes(&mut wrong_secret);
let wrong_keypair = ed25519::KeyPair::from_secret(wrong_secret, 2);
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (_session_secret, _context, request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
// Gateway handles request but we provide WRONG verification key
let result = handle_request(
&request_data,
Some(wrong_keypair.public_key()), // Wrong key!
responder_ed25519_keypair.private_key(),
responder_x25519.private_key(),
&responder_kem_key,
);
// Should fail signature verification
assert!(result.is_err());
if let Err(LpError::KKTError(_)) = result {
// Expected
} else {
panic!("Expected KKTError");
}
}
#[test]
fn test_hash_mismatch_rejected() {
let mut rng = rand09::rng();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
// Use WRONG hash
let wrong_hash = [0u8; 32];
let (session_secret, context, request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
let response_data = handle_request(
&request_data,
Some(initiator_ed25519_keypair.public_key()),
responder_ed25519_keypair.private_key(),
responder_x25519.private_key(),
&responder_kem_key,
)
.unwrap();
// Client validates with WRONG hash
let result = process_response(
context,
&session_secret,
responder_ed25519_keypair.public_key(),
&wrong_hash, // Wrong!
&response_data,
);
// Should fail hash validation
assert!(result.is_err());
if let Err(LpError::KKTError(_)) = result {
// Expected
} else {
panic!("Expected KKTError");
}
}
#[test]
fn test_malformed_request_rejected() {
let mut rng = rand09::rng();
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
let (_, responder_kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let responder_kem_key = EncapsulationKey::X25519(responder_kem_pk);
// Create malformed request data (invalid bytes)
let malformed_request = KKTRequestData(vec![0xFF; 100]);
let result = handle_request(
&malformed_request,
None,
responder_ed25519_keypair.private_key(),
responder_x25519.private_key(),
&responder_kem_key,
);
// Should fail to parse
assert!(result.is_err());
if let Err(LpError::KKTError(_)) = result {
// Expected
} else {
panic!("Expected KKTError");
}
}
#[test]
fn test_malformed_response_rejected() {
let mut rng = rand09::rng();
// Generate Ed25519 keypairs for both parties
let initiator_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(0));
let responder_ed25519_keypair = generate_keypair_ed25519(&mut rng, Some(1));
let responder_x25519 = generate_keypair_x25519(&mut rng);
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (session_secret, context, _request_data) = create_request(
ciphersuite,
initiator_ed25519_keypair.private_key(),
responder_x25519.public_key(),
)
.unwrap();
// Create malformed response data
let malformed_response = KKTResponseData(vec![0xFF; 100]);
let key_hash = [0u8; 32];
let result = process_response(
context,
&session_secret,
responder_ed25519_keypair.public_key(),
&key_hash,
&malformed_response,
);
// Should fail to parse
assert!(result.is_err());
if let Err(LpError::KKTError(_)) = result {
// Expected
} else {
panic!("Expected KKTError");
}
}
}
+327
View File
@@ -0,0 +1,327 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod codec;
pub mod config;
pub mod error;
pub mod kkt_orchestrator;
pub mod message;
pub mod noise_protocol;
pub mod packet;
pub mod psk;
pub mod replay;
pub mod session;
mod session_integration;
pub mod session_manager;
pub mod state_machine;
pub use config::LpConfig;
pub use error::LpError;
pub use message::{ClientHelloData, LpMessage};
pub use packet::{BOOTSTRAP_RECEIVER_IDX, LpPacket, OuterHeader};
pub use replay::{ReceivingKeyCounterValidator, ReplayError};
pub use session::{LpSession, generate_fresh_salt};
pub use session_manager::SessionManager;
pub use state_machine::LpStateMachine;
pub const NOISE_PATTERN: &str = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
pub const NOISE_PSK_INDEX: u8 = 3;
#[cfg(test)]
pub fn sessions_for_tests() -> (LpSession, LpSession) {
use nym_crypto::asymmetric::{ed25519, x25519};
use std::sync::Arc;
let mut rng = rand::thread_rng();
// X25519 keypairs for Noise protocol
let keypair_1 = Arc::new(x25519::KeyPair::new(&mut rng));
let keypair_2 = Arc::new(x25519::KeyPair::new(&mut rng));
// Use a fixed receiver_index for deterministic tests
let receiver_index: u32 = 12345;
// Ed25519 keypairs for PSQ authentication (placeholders for testing)
let ed25519_keypair_1 = ed25519::KeyPair::from_secret([1u8; 32], 0);
let ed25519_keypair_2 = ed25519::KeyPair::from_secret([2u8; 32], 1);
let ed25519_keypair1_pubkey = *ed25519_keypair_1.public_key();
// Use consistent salt for deterministic tests
let salt = [1u8; 32];
// PSQ will always derive the PSK during handshake using X25519 as DHKEM
let initiator_session = LpSession::new(
receiver_index,
true,
Arc::new(ed25519_keypair_1),
keypair_1.clone(),
ed25519_keypair_2.public_key(),
keypair_2.public_key(),
&salt,
)
.expect("Test session creation failed");
let responder_session = LpSession::new(
receiver_index,
false,
Arc::new(ed25519_keypair_2),
keypair_2.clone(),
&ed25519_keypair1_pubkey,
keypair_1.public_key(),
&salt,
)
.expect("Test session creation failed");
(initiator_session, responder_session)
}
#[cfg(test)]
mod tests {
use crate::message::LpMessage;
use crate::packet::{LpHeader, LpPacket, TRAILER_LEN};
use crate::session_manager::SessionManager;
use crate::{LpError, sessions_for_tests};
use bytes::BytesMut;
use std::sync::Arc;
// Import the new standalone functions
use crate::codec::{parse_lp_packet, serialize_lp_packet};
#[test]
fn test_replay_protection_integration() {
// Create session
let session = sessions_for_tests().0;
// === Packet 1 (Counter 0 - Should succeed) ===
let packet1 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: 42, // Matches session's sending_index assumption for this test
counter: 0,
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize packet
let mut buf1 = BytesMut::new();
serialize_lp_packet(&packet1, &mut buf1, None).unwrap();
// Parse packet
let parsed_packet1 = parse_lp_packet(&buf1, None).unwrap();
// Perform replay check (should pass)
session
.receiving_counter_quick_check(parsed_packet1.header.counter)
.expect("Initial packet failed replay check");
// Mark received (simulating successful processing)
session
.receiving_counter_mark(parsed_packet1.header.counter)
.expect("Failed to mark initial packet received");
// === Packet 2 (Counter 0 - Replay, should fail check) ===
let packet2 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: 42,
counter: 0, // Same counter as before (replay)
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize packet
let mut buf2 = BytesMut::new();
serialize_lp_packet(&packet2, &mut buf2, None).unwrap();
// Parse packet
let parsed_packet2 = parse_lp_packet(&buf2, None).unwrap();
// Perform replay check (should fail)
let replay_result = session.receiving_counter_quick_check(parsed_packet2.header.counter);
assert!(replay_result.is_err());
match replay_result.unwrap_err() {
LpError::Replay(e) => {
assert!(matches!(e, crate::replay::ReplayError::DuplicateCounter));
}
e => panic!("Expected replay error, got {:?}", e),
}
// Do not mark received as it failed validation
// === Packet 3 (Counter 1 - Should succeed) ===
let packet3 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: 42,
counter: 1, // Incremented counter
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize packet
let mut buf3 = BytesMut::new();
serialize_lp_packet(&packet3, &mut buf3, None).unwrap();
// Parse packet
let parsed_packet3 = parse_lp_packet(&buf3, None).unwrap();
// Perform replay check (should pass)
session
.receiving_counter_quick_check(parsed_packet3.header.counter)
.expect("Packet 3 failed replay check");
// Mark received
session
.receiving_counter_mark(parsed_packet3.header.counter)
.expect("Failed to mark packet 3 received");
// Verify validator state directly on the session
let state = session.current_packet_cnt();
assert_eq!(state.0, 2); // Next expected counter (correct - was 1, now expects 2)
assert_eq!(state.1, 2); // Total marked received (correct - packets 1 and 3)
}
#[test]
fn test_session_manager_integration() {
use nym_crypto::asymmetric::ed25519;
// Create session manager
let local_manager = SessionManager::new();
let remote_manager = SessionManager::new();
// Generate Ed25519 keypairs for PSQ authentication
let ed25519_keypair_local = ed25519::KeyPair::from_secret([8u8; 32], 0);
let ed25519_keypair_remote = ed25519::KeyPair::from_secret([9u8; 32], 1);
let ed25519_keypair_local_pubkey = *ed25519_keypair_local.public_key();
let x25519_keypair_local_pubkey = ed25519_keypair_local_pubkey.to_x25519().unwrap();
let x25519_keypair_remote_pubkey = ed25519_keypair_remote.public_key().to_x25519().unwrap();
// Use fixed receiver_index for deterministic test
let receiver_index: u32 = 54321;
// Test salt
let salt = [46u8; 32];
// Create a session via manager
let _ = local_manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair_local),
ed25519_keypair_remote.public_key(),
&x25519_keypair_remote_pubkey,
true,
&salt,
)
.unwrap();
let _ = remote_manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair_remote),
&ed25519_keypair_local_pubkey,
&x25519_keypair_local_pubkey,
false,
&salt,
)
.unwrap();
// === Packet 1 (Counter 0 - Should succeed) ===
let packet1 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: receiver_index,
counter: 0,
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize
let mut buf1 = BytesMut::new();
serialize_lp_packet(&packet1, &mut buf1, None).unwrap();
// Parse
let parsed_packet1 = parse_lp_packet(&buf1, None).unwrap();
// Process via SessionManager method (which should handle checks + marking)
// NOTE: We might need a method on SessionManager/LpSession like `process_incoming_packet`
// that encapsulates parse -> check -> process_noise -> mark.
// For now, we simulate the steps using the retrieved session.
// Perform replay check
local_manager
.receiving_counter_quick_check(receiver_index, parsed_packet1.header.counter)
.expect("Packet 1 check failed");
// Mark received
local_manager
.receiving_counter_mark(receiver_index, parsed_packet1.header.counter)
.expect("Packet 1 mark failed");
// === Packet 2 (Counter 1 - Should succeed on same session) ===
let packet2 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: receiver_index,
counter: 1,
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize
let mut buf2 = BytesMut::new();
serialize_lp_packet(&packet2, &mut buf2, None).unwrap();
// Parse
let parsed_packet2 = parse_lp_packet(&buf2, None).unwrap();
// Perform replay check
local_manager
.receiving_counter_quick_check(receiver_index, parsed_packet2.header.counter)
.expect("Packet 2 check failed");
// Mark received
local_manager
.receiving_counter_mark(receiver_index, parsed_packet2.header.counter)
.expect("Packet 2 mark failed");
// === Packet 3 (Counter 0 - Replay, should fail check) ===
let packet3 = LpPacket {
header: LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: receiver_index,
counter: 0, // Replay of first packet
},
message: LpMessage::Busy,
trailer: [0u8; TRAILER_LEN],
};
// Serialize
let mut buf3 = BytesMut::new();
serialize_lp_packet(&packet3, &mut buf3, None).unwrap();
// Parse
let parsed_packet3 = parse_lp_packet(&buf3, None).unwrap();
// Perform replay check (should fail)
let replay_result = local_manager
.receiving_counter_quick_check(receiver_index, parsed_packet3.header.counter);
assert!(replay_result.is_err());
match replay_result.unwrap_err() {
LpError::Replay(e) => {
assert!(matches!(e, crate::replay::ReplayError::DuplicateCounter));
}
e => panic!("Expected replay error for packet 3, got {:?}", e),
}
// Do not mark received
}
}
+710
View File
@@ -0,0 +1,710 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{BOOTSTRAP_RECEIVER_IDX, LpError};
use bytes::{BufMut, BytesMut};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use nym_crypto::asymmetric::{ed25519, x25519};
use serde::{Deserialize, Serialize};
use std::fmt::{self, Display};
/// Data structure for the ClientHello message
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientHelloData {
/// Client-proposed receiver index for session identification (4 bytes)
/// Auto-generated randomly by the client
pub receiver_index: u32,
/// Client's LP x25519 public key (32 bytes) - derived from Ed25519 key
pub client_lp_public_key: x25519::PublicKey,
/// Client's Ed25519 public key (32 bytes) - for PSQ authentication
pub client_ed25519_public_key: ed25519::PublicKey,
/// Salt for PSK derivation (32 bytes: 8-byte timestamp + 24-byte nonce)
pub salt: [u8; 32],
}
impl ClientHelloData {
// 4 bytes for receiver index + 32 bytes for client lp key, 32 bytes for client ed25519 key + 32 bytes for salt
pub const LEN: usize = 100;
fn len(&self) -> usize {
Self::LEN
}
fn generate_receiver_index() -> u32 {
loop {
let candidate = rand::random();
if candidate != BOOTSTRAP_RECEIVER_IDX {
return candidate;
}
}
}
/// Generates a new ClientHelloData with fresh salt.
///
/// Salt format: 8 bytes timestamp (u64 LE) + 24 bytes random nonce
///
/// # Arguments
/// * `client_lp_public_key` - Client's x25519 public key (derived from Ed25519)
/// * `client_ed25519_public_key` - Client's Ed25519 public key (for PSQ authentication)
pub fn new_with_fresh_salt(
client_lp_public_key: x25519::PublicKey,
client_ed25519_public_key: ed25519::PublicKey,
timestamp: u64,
) -> Self {
// Generate salt: timestamp + nonce
let mut salt = [0u8; 32];
// First 8 bytes: current timestamp as u64 little-endian
salt[..8].copy_from_slice(&timestamp.to_le_bytes());
// Last 24 bytes: random nonce
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut salt[8..]);
Self {
receiver_index: Self::generate_receiver_index(), // Auto-generate random receiver index
client_lp_public_key,
client_ed25519_public_key,
salt,
}
}
/// Extracts the timestamp from the salt.
///
/// # Returns
/// Unix timestamp in seconds
pub fn extract_timestamp(&self) -> u64 {
let mut timestamp_bytes = [0u8; 8];
timestamp_bytes.copy_from_slice(&self.salt[..8]);
u64::from_le_bytes(timestamp_bytes)
}
pub fn encode(&self, dst: &mut BytesMut) {
dst.put_u32_le(self.receiver_index);
dst.put_slice(self.client_lp_public_key.as_bytes());
dst.put_slice(self.client_ed25519_public_key.as_bytes());
dst.put_slice(&self.salt);
}
pub fn decode(b: &[u8]) -> Result<Self, LpError> {
if b.len() != Self::LEN {
return Err(LpError::DeserializationError(format!(
"Expected {} bytes to deserialise ClientHelloData. got {}",
Self::LEN,
b.len()
)));
}
// SAFETY: we checked for valid byte lengths
#[allow(clippy::unwrap_used)]
let client_lp_public_key_bytes = b[4..36].try_into().unwrap();
let client_ed25519_public_key_bytes = b[36..68].try_into().unwrap();
Ok(ClientHelloData {
receiver_index: u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
client_lp_public_key: x25519::PublicKey::from_byte_array(client_lp_public_key_bytes),
client_ed25519_public_key: ed25519::PublicKey::from_byte_array(
client_ed25519_public_key_bytes,
)?,
salt: b[68..].try_into().unwrap(),
})
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, IntoPrimitive, TryFromPrimitive)]
#[repr(u32)]
pub enum MessageType {
Busy = 0x0000,
Handshake = 0x0001,
EncryptedData = 0x0002,
ClientHello = 0x0003,
KKTRequest = 0x0004,
KKTResponse = 0x0005,
ForwardPacket = 0x0006,
/// Receiver index collision - client should retry with new index
Collision = 0x0007,
/// Acknowledgment - gateway confirms receipt of message
Ack = 0x0008,
/// Subsession request - client initiates subsession creation
SubsessionRequest = 0x0009,
/// Subsession KK1 - first message of Noise KK handshake
SubsessionKK1 = 0x000A,
/// Subsession KK2 - second message of Noise KK handshake
SubsessionKK2 = 0x000B,
/// Subsession ready - subsession established confirmation
SubsessionReady = 0x000C,
/// Subsession abort - race winner tells loser to become responder
SubsessionAbort = 0x000D,
}
impl MessageType {
pub(crate) fn from_u32(value: u32) -> Option<Self> {
MessageType::try_from(value).ok()
}
pub fn to_u32(&self) -> u32 {
u32::from(*self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandshakeData(pub Vec<u8>);
impl HandshakeData {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(HandshakeData(bytes.to_vec()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedDataPayload(pub Vec<u8>);
impl EncryptedDataPayload {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(EncryptedDataPayload(bytes.to_vec()))
}
}
/// KKT request frame data (serialized KKTFrame bytes)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KKTRequestData(pub Vec<u8>);
impl KKTRequestData {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(KKTRequestData(bytes.to_vec()))
}
}
/// KKT response frame data (serialized KKTFrame bytes)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KKTResponseData(pub Vec<u8>);
impl KKTResponseData {
fn len(&self) -> usize {
self.0.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(KKTResponseData(bytes.to_vec()))
}
}
/// Packet forwarding request with embedded inner LP packet
#[derive(Debug, Clone)]
pub struct ForwardPacketData {
/// Target gateway's Ed25519 identity (32 bytes)
pub target_gateway_identity: [u8; 32],
// TODO: replace it with `SocketAddr`
/// Target gateway's LP address (IP:port string)
pub target_lp_address: String,
/// Complete inner LP packet bytes (serialized LpPacket)
/// This is the CLIENT→EXIT gateway packet, encrypted for exit
pub inner_packet_bytes: Vec<u8>,
}
impl ForwardPacketData {
fn len(&self) -> usize {
// 32 bytes target gateway identity
// +
// 4 bytes length of target lp address
// +
// target_lp_address.len()
// +
// 4 bytes of length of inner packet bytes
// +
// inner_packet_bytes.len()
32 + 4 + self.target_lp_address.len() + 4 + self.inner_packet_bytes.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.target_gateway_identity);
dst.put_u16_le(self.target_lp_address.len() as u16);
dst.put_slice(self.target_lp_address.as_bytes());
dst.put_u32_le(self.inner_packet_bytes.len() as u32);
dst.put_slice(&self.inner_packet_bytes);
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = BytesMut::new();
self.encode(&mut buf);
buf.into()
}
pub fn decode(bytes: &[u8]) -> Result<Self, LpError> {
// smallest possible packet with empty address and empty data
if bytes.len() < 38 {
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ForwardPacketData[1]. got {}",
bytes.len()
)));
}
// SAFETY: we ensured we have sufficient data
#[allow(clippy::unwrap_used)]
let target_gateway_identity = bytes[0..32].try_into().unwrap();
let target_lp_address_len = u16::from_le_bytes([bytes[32], bytes[33]]);
// smallest possible packet with empty data
if bytes[34..].len() < 4 + target_lp_address_len as usize {
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ForwardPacketData[2]. got {}",
bytes.len()
)));
}
let target_lp_address =
String::from_utf8_lossy(&bytes[34..34 + target_lp_address_len as usize]).to_string();
let inner_packet_bytes_len = u32::from_le_bytes([
bytes[34 + target_lp_address_len as usize],
bytes[34 + target_lp_address_len as usize + 1],
bytes[34 + target_lp_address_len as usize + 2],
bytes[34 + target_lp_address_len as usize + 3],
]);
if bytes[34 + target_lp_address_len as usize + 4..].len() != inner_packet_bytes_len as usize
{
return Err(LpError::DeserializationError(format!(
"Expected {inner_packet_bytes_len} bytes to deserialise inner packet bytes of ForwardPacketData. got {}",
bytes[34 + target_lp_address_len as usize + 4..].len()
)));
}
let inner_packet_bytes = bytes[34 + target_lp_address_len as usize + 4..].to_vec();
Ok(ForwardPacketData {
target_gateway_identity,
target_lp_address,
inner_packet_bytes,
})
}
}
/// Subsession KK1 message - first message of Noise KK handshake
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsessionKK1Data {
/// Noise KK first message payload (ephemeral key + encrypted static)
pub payload: Vec<u8>,
}
impl SubsessionKK1Data {
fn len(&self) -> usize {
self.payload.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.payload);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(SubsessionKK1Data {
payload: bytes.to_vec(),
})
}
}
/// Subsession KK2 message - second message of Noise KK handshake
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsessionKK2Data {
/// Noise KK second message payload (ephemeral key + encrypted response)
pub payload: Vec<u8>,
}
impl SubsessionKK2Data {
fn len(&self) -> usize {
self.payload.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.payload);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
Ok(SubsessionKK2Data {
payload: bytes.to_vec(),
})
}
}
/// Subsession ready confirmation with new session index
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsessionReadyData {
/// New subsession's receiver index for routing
pub receiver_index: u32,
}
impl SubsessionReadyData {
pub const LEN: usize = 4;
fn len(&self) -> usize {
Self::LEN
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_u32_le(self.receiver_index);
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
if bytes.len() != 4 {
return Err(LpError::DeserializationError(format!(
"Expected 4 bytes to deserialise SubsessionReadyData. got {}",
bytes.len()
)));
}
Ok(SubsessionReadyData {
receiver_index: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
})
}
}
#[derive(Debug, Clone)]
pub enum LpMessage {
Busy,
Handshake(HandshakeData),
EncryptedData(EncryptedDataPayload),
ClientHello(ClientHelloData),
KKTRequest(KKTRequestData),
KKTResponse(KKTResponseData),
ForwardPacket(ForwardPacketData),
/// Receiver index collision - client should retry with new receiver_index
Collision,
/// Acknowledgment - gateway confirms receipt of message
Ack,
/// Subsession request - client initiates subsession creation (empty, signal only)
SubsessionRequest,
/// Subsession KK1 - first message of Noise KK handshake
SubsessionKK1(SubsessionKK1Data),
/// Subsession KK2 - second message of Noise KK handshake
SubsessionKK2(SubsessionKK2Data),
/// Subsession ready - subsession established confirmation
SubsessionReady(SubsessionReadyData),
/// Subsession abort - race winner tells loser to become responder (empty, signal only)
SubsessionAbort,
}
impl Display for LpMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LpMessage::Busy => write!(f, "Busy"),
LpMessage::Handshake(_) => write!(f, "Handshake"),
LpMessage::EncryptedData(_) => write!(f, "EncryptedData"),
LpMessage::ClientHello(_) => write!(f, "ClientHello"),
LpMessage::KKTRequest(_) => write!(f, "KKTRequest"),
LpMessage::KKTResponse(_) => write!(f, "KKTResponse"),
LpMessage::ForwardPacket(_) => write!(f, "ForwardPacket"),
LpMessage::Collision => write!(f, "Collision"),
LpMessage::Ack => write!(f, "Ack"),
LpMessage::SubsessionRequest => write!(f, "SubsessionRequest"),
LpMessage::SubsessionKK1(_) => write!(f, "SubsessionKK1"),
LpMessage::SubsessionKK2(_) => write!(f, "SubsessionKK2"),
LpMessage::SubsessionReady(_) => write!(f, "SubsessionReady"),
LpMessage::SubsessionAbort => write!(f, "SubsessionAbort"),
}
}
}
impl LpMessage {
pub fn payload(&self) -> &[u8] {
match self {
LpMessage::Busy => &[],
LpMessage::Handshake(payload) => payload.0.as_slice(),
LpMessage::EncryptedData(payload) => payload.0.as_slice(),
LpMessage::ClientHello(_) => &[], // Structured data, serialized in encode_content
LpMessage::KKTRequest(payload) => payload.0.as_slice(),
LpMessage::KKTResponse(payload) => payload.0.as_slice(),
LpMessage::ForwardPacket(_) => &[], // Structured data, serialized in encode_content
LpMessage::Collision => &[],
LpMessage::Ack => &[],
LpMessage::SubsessionRequest => &[],
LpMessage::SubsessionKK1(_) => &[], // Structured data, serialized in encode_content
LpMessage::SubsessionKK2(_) => &[], // Structured data, serialized in encode_content
LpMessage::SubsessionReady(_) => &[], // Structured data, serialized in encode_content
LpMessage::SubsessionAbort => &[],
}
}
pub fn is_empty(&self) -> bool {
match self {
LpMessage::Busy => true,
LpMessage::Handshake(payload) => payload.0.is_empty(),
LpMessage::EncryptedData(payload) => payload.0.is_empty(),
LpMessage::ClientHello(_) => false, // Always has data
LpMessage::KKTRequest(payload) => payload.0.is_empty(),
LpMessage::KKTResponse(payload) => payload.0.is_empty(),
LpMessage::ForwardPacket(_) => false, // Always has data
LpMessage::Collision => true,
LpMessage::Ack => true,
LpMessage::SubsessionRequest => true, // Empty signal
LpMessage::SubsessionKK1(_) => false, // Always has payload
LpMessage::SubsessionKK2(_) => false, // Always has payload
LpMessage::SubsessionReady(_) => false, // Always has receiver_index
LpMessage::SubsessionAbort => true, // Empty signal
}
}
pub fn len(&self) -> usize {
match self {
LpMessage::Busy => 0,
LpMessage::Handshake(payload) => payload.len(),
LpMessage::EncryptedData(payload) => payload.len(),
LpMessage::ClientHello(payload) => payload.len(),
LpMessage::KKTRequest(payload) => payload.len(),
LpMessage::KKTResponse(payload) => payload.len(),
LpMessage::ForwardPacket(payload) => payload.len(),
LpMessage::Collision => 0,
LpMessage::Ack => 0,
LpMessage::SubsessionRequest => 0,
LpMessage::SubsessionKK1(payload) => payload.len(),
LpMessage::SubsessionKK2(payload) => payload.len(),
LpMessage::SubsessionReady(payload) => payload.len(),
LpMessage::SubsessionAbort => 0,
}
}
pub fn typ(&self) -> MessageType {
match self {
LpMessage::Busy => MessageType::Busy,
LpMessage::Handshake(_) => MessageType::Handshake,
LpMessage::EncryptedData(_) => MessageType::EncryptedData,
LpMessage::ClientHello(_) => MessageType::ClientHello,
LpMessage::KKTRequest(_) => MessageType::KKTRequest,
LpMessage::KKTResponse(_) => MessageType::KKTResponse,
LpMessage::ForwardPacket(_) => MessageType::ForwardPacket,
LpMessage::Collision => MessageType::Collision,
LpMessage::Ack => MessageType::Ack,
LpMessage::SubsessionRequest => MessageType::SubsessionRequest,
LpMessage::SubsessionKK1(_) => MessageType::SubsessionKK1,
LpMessage::SubsessionKK2(_) => MessageType::SubsessionKK2,
LpMessage::SubsessionReady(_) => MessageType::SubsessionReady,
LpMessage::SubsessionAbort => MessageType::SubsessionAbort,
}
}
pub fn encode_content(&self, dst: &mut BytesMut) {
match self {
LpMessage::Busy => { /* No content */ }
LpMessage::Handshake(payload) => payload.encode(dst),
LpMessage::EncryptedData(payload) => payload.encode(dst),
LpMessage::ClientHello(data) => data.encode(dst),
LpMessage::KKTRequest(payload) => payload.encode(dst),
LpMessage::KKTResponse(payload) => payload.encode(dst),
LpMessage::ForwardPacket(data) => data.encode(dst),
LpMessage::Collision => { /* No content */ }
LpMessage::Ack => { /* No content */ }
LpMessage::SubsessionRequest => { /* No content - signal only */ }
LpMessage::SubsessionKK1(data) => data.encode(dst),
LpMessage::SubsessionKK2(data) => data.encode(dst),
LpMessage::SubsessionReady(data) => data.encode(dst),
LpMessage::SubsessionAbort => { /* No content - signal only */ }
}
}
/// Parse message from its type and content bytes.
///
/// Used when decrypting outer-encrypted packets where the message type
/// was encrypted along with the content.
pub fn decode_content(content: &[u8], message_type: MessageType) -> Result<Self, LpError> {
match message_type {
MessageType::Busy => {
content.ensure_empty()?;
Ok(LpMessage::Busy)
}
MessageType::Handshake => Ok(LpMessage::Handshake(HandshakeData::decode(content)?)),
MessageType::EncryptedData => Ok(LpMessage::EncryptedData(
EncryptedDataPayload::decode(content)?,
)),
MessageType::ClientHello => {
Ok(LpMessage::ClientHello(ClientHelloData::decode(content)?))
}
MessageType::KKTRequest => Ok(LpMessage::KKTRequest(KKTRequestData::decode(content)?)),
MessageType::KKTResponse => {
Ok(LpMessage::KKTResponse(KKTResponseData::decode(content)?))
}
MessageType::ForwardPacket => Ok(LpMessage::ForwardPacket(ForwardPacketData::decode(
content,
)?)),
MessageType::Collision => {
content.ensure_empty()?;
Ok(LpMessage::Collision)
}
MessageType::Ack => {
content.ensure_empty()?;
Ok(LpMessage::Ack)
}
MessageType::SubsessionRequest => {
content.ensure_empty()?;
Ok(LpMessage::SubsessionRequest)
}
MessageType::SubsessionKK1 => Ok(LpMessage::SubsessionKK1(SubsessionKK1Data::decode(
content,
)?)),
MessageType::SubsessionKK2 => Ok(LpMessage::SubsessionKK2(SubsessionKK2Data::decode(
content,
)?)),
MessageType::SubsessionReady => Ok(LpMessage::SubsessionReady(
SubsessionReadyData::decode(content)?,
)),
MessageType::SubsessionAbort => {
content.ensure_empty()?;
Ok(LpMessage::SubsessionAbort)
}
}
}
}
/// Helper trait for improving readability to return error if bytes content is not empty
trait EnsureEmptyContent {
fn ensure_empty(&self) -> Result<(), LpError>;
}
impl EnsureEmptyContent for &[u8] {
fn ensure_empty(&self) -> Result<(), LpError> {
if !self.is_empty() {
return Err(LpError::InvalidPayloadSize {
expected: 0,
actual: self.len(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
use crate::LpPacket;
use crate::packet::{LpHeader, TRAILER_LEN};
#[test]
fn encoding() {
let message = LpMessage::EncryptedData(EncryptedDataPayload(vec![11u8; 124]));
let resp_header = LpHeader {
protocol_version: 1,
reserved: [0u8; 3],
receiver_idx: 0,
counter: 0,
};
let packet = LpPacket {
header: resp_header,
message,
trailer: [80; TRAILER_LEN],
};
// Just print packet for debug, will be captured in test output
println!("{packet:?}");
// Verify message type
assert!(matches!(packet.message.typ(), MessageType::EncryptedData));
// Verify correct data in message
match &packet.message {
LpMessage::EncryptedData(data) => {
assert_eq!(*data, EncryptedDataPayload(vec![11u8; 124]));
}
_ => panic!("Wrong message type"),
}
}
#[test]
fn test_client_hello_salt_generation() {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_secs();
let mut rng = rand::thread_rng();
let ed25519 = ed25519::KeyPair::new(&mut rng);
let x25519 = ed25519.to_x25519();
let client_key = *x25519.public_key();
let client_ed25519_key = *ed25519.public_key();
let hello1 =
ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
let hello2 =
ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
// Different salts should be generated
assert_ne!(hello1.salt, hello2.salt);
// But timestamps should be very close (within 1 second)
let ts1 = hello1.extract_timestamp();
let ts2 = hello2.extract_timestamp();
assert!((ts1 as i64 - ts2 as i64).abs() <= 1);
}
#[test]
fn test_client_hello_timestamp_extraction() {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_secs();
let mut rng = rand::thread_rng();
let ed25519 = ed25519::KeyPair::new(&mut rng);
let x25519 = ed25519.to_x25519();
let client_key = *x25519.public_key();
let client_ed25519_key = *ed25519.public_key();
let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
let timestamp = hello.extract_timestamp();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Timestamp should be within 1 second of now
assert!((timestamp as i64 - now as i64).abs() <= 1);
}
#[test]
fn test_client_hello_salt_format() {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_secs();
let mut rng = rand::thread_rng();
let ed25519 = ed25519::KeyPair::new(&mut rng);
let x25519 = ed25519.to_x25519();
let client_key = *x25519.public_key();
let client_ed25519_key = *ed25519.public_key();
let hello = ClientHelloData::new_with_fresh_salt(client_key, client_ed25519_key, timestamp);
// First 8 bytes should be non-zero timestamp
let timestamp_bytes = &hello.salt[..8];
assert_ne!(timestamp_bytes, &[0u8; 8]);
// Salt should be 32 bytes total
assert_eq!(hello.salt.len(), 32);
}
}
+330
View File
@@ -0,0 +1,330 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Sans-IO Noise protocol state machine, adapted from noise-psq.
use snow::{TransportState, params::NoiseParams};
use thiserror::Error;
// --- Error Definition ---
/// Errors related to the Noise protocol state machine.
#[derive(Error, Debug)]
pub enum NoiseError {
#[error("encountered a Noise decryption error")]
DecryptionError,
#[error("encountered a Noise Protocol error - {0}")]
ProtocolError(snow::Error),
#[error("operation is invalid in the current protocol state")]
IncorrectStateError,
#[error("attempted transport mode operation without real PSK injection")]
PskNotInjected,
#[error("Other Noise-related error: {0}")]
Other(String),
#[error("session is read-only after demotion")]
SessionReadOnly,
}
impl From<snow::Error> for NoiseError {
fn from(err: snow::Error) -> Self {
match err {
snow::Error::Decrypt => NoiseError::DecryptionError,
err => NoiseError::ProtocolError(err),
}
}
}
// --- Protocol State and Structs ---
/// Represents the possible states of the Noise protocol machine.
#[derive(Debug)]
pub enum NoiseProtocolState {
/// The protocol is currently performing the handshake.
/// Contains the Snow handshake state.
Handshaking(Box<snow::HandshakeState>),
/// The handshake is complete, and the protocol is in transport mode.
/// Contains the Snow transport state.
Transport(TransportState),
/// The protocol has encountered an unrecoverable error.
/// Stores the error description.
Failed(String),
}
/// The core sans-io Noise protocol state machine.
#[derive(Debug)]
pub struct NoiseProtocol {
state: NoiseProtocolState,
// We might need buffers for incoming/outgoing data later if we add internal buffering
// read_buffer: Vec<u8>,
// write_buffer: Vec<u8>,
}
/// Represents the outcome of processing received bytes via `read_message`.
#[derive(Debug, PartialEq)]
pub enum ReadResult {
/// A handshake or transport message was successfully processed, but yielded no application data
/// and did not complete the handshake.
NoOp,
/// A complete application data message was decrypted.
DecryptedData(Vec<u8>),
/// The handshake successfully completed during this read operation.
HandshakeComplete,
// NOTE: NeedMoreBytes variant removed as read_message expects full frames.
}
// --- Implementation ---
impl NoiseProtocol {
/// Creates a new `NoiseProtocol` instance in the Handshaking state.
///
/// Takes an initialized `snow::HandshakeState` (e.g., from `snow::Builder`).
pub fn new(initial_state: snow::HandshakeState) -> Self {
NoiseProtocol {
state: NoiseProtocolState::Handshaking(Box::new(initial_state)),
}
}
/// Processes a single, complete incoming Noise message frame.
///
/// Assumes the caller handles buffering and framing to provide one full message.
/// Returns the result of processing the message.
pub fn read_message(&mut self, input: &[u8]) -> Result<ReadResult, NoiseError> {
// Allocate a buffer large enough for the maximum possible Noise message size.
// TODO: Consider reusing a buffer for efficiency.
let mut buffer = vec![0u8; 65535]; // Max Noise message size
match &mut self.state {
NoiseProtocolState::Handshaking(handshake_state) => {
match handshake_state.read_message(input, &mut buffer) {
Ok(_) => {
if handshake_state.is_handshake_finished() {
// Transition to Transport state.
let current_state = std::mem::replace(
&mut self.state,
// Temporary placeholder needed for mem::replace
NoiseProtocolState::Failed(
NoiseError::IncorrectStateError.to_string(),
),
);
if let NoiseProtocolState::Handshaking(state_to_convert) = current_state
{
match state_to_convert.into_transport_mode() {
Ok(transport_state) => {
self.state = NoiseProtocolState::Transport(transport_state);
Ok(ReadResult::HandshakeComplete)
}
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
} else {
// Should be unreachable
let err = NoiseError::IncorrectStateError;
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
} else {
// Handshake continues
Ok(ReadResult::NoOp)
}
}
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
}
NoiseProtocolState::Transport(transport_state) => {
match transport_state.read_message(input, &mut buffer) {
Ok(len) => Ok(ReadResult::DecryptedData(buffer[..len].to_vec())),
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
}
NoiseProtocolState::Failed(_) => Err(NoiseError::IncorrectStateError),
}
}
/// Checks if there are pending handshake messages to send.
///
/// If in Handshaking state and it's our turn, generates the message.
/// Transitions state to Transport if the handshake completes after this message.
/// Returns `None` if not in Handshaking state or not our turn.
pub fn get_bytes_to_send(&mut self) -> Option<Result<Vec<u8>, NoiseError>> {
match &mut self.state {
NoiseProtocolState::Handshaking(handshake_state) => {
if handshake_state.is_my_turn() {
let mut buffer = vec![0u8; 65535];
match handshake_state.write_message(&[], &mut buffer) {
// Empty payload for handshake msg
Ok(len) => {
if handshake_state.is_handshake_finished() {
// Transition to Transport state.
let current_state = std::mem::replace(
&mut self.state,
NoiseProtocolState::Failed(
NoiseError::IncorrectStateError.to_string(),
),
);
if let NoiseProtocolState::Handshaking(state_to_convert) =
current_state
{
match state_to_convert.into_transport_mode() {
Ok(transport_state) => {
self.state =
NoiseProtocolState::Transport(transport_state);
Some(Ok(buffer[..len].to_vec())) // Return final handshake msg
}
Err(e) => {
let err = NoiseError::from(e);
self.state =
NoiseProtocolState::Failed(err.to_string());
Some(Err(err))
}
}
} else {
// Should be unreachable
let err = NoiseError::IncorrectStateError;
self.state = NoiseProtocolState::Failed(err.to_string());
Some(Err(err))
}
} else {
// Handshake continues
Some(Ok(buffer[..len].to_vec()))
}
}
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Some(Err(err))
}
}
} else {
// Not our turn
None
}
}
NoiseProtocolState::Transport(_) | NoiseProtocolState::Failed(_) => {
// No handshake messages to send in these states
None
}
}
}
/// Encrypts an application data payload for sending during the Transport phase.
///
/// Returns the ciphertext (payload + 16-byte tag).
/// Errors if not in Transport state or encryption fails.
pub fn write_message(&mut self, payload: &[u8]) -> Result<Vec<u8>, NoiseError> {
match &mut self.state {
NoiseProtocolState::Transport(transport_state) => {
let mut buffer = vec![0u8; payload.len() + 16]; // Payload + tag
match transport_state.write_message(payload, &mut buffer) {
Ok(len) => Ok(buffer[..len].to_vec()),
Err(e) => {
let err = NoiseError::from(e);
self.state = NoiseProtocolState::Failed(err.to_string());
Err(err)
}
}
}
NoiseProtocolState::Handshaking(_) | NoiseProtocolState::Failed(_) => {
Err(NoiseError::IncorrectStateError)
}
}
}
/// Returns true if the protocol is in the transport phase (handshake complete).
pub fn is_transport(&self) -> bool {
matches!(self.state, NoiseProtocolState::Transport(_))
}
/// Returns true if the protocol has failed.
pub fn is_failed(&self) -> bool {
matches!(self.state, NoiseProtocolState::Failed(_))
}
/// Check if the handshake has finished and the protocol is in transport mode.
pub fn is_handshake_finished(&self) -> bool {
matches!(self.state, NoiseProtocolState::Transport(_))
}
/// Inject a PSK into the Noise HandshakeState.
///
/// This allows dynamic PSK injection after HandshakeState construction,
/// which is required for PSQ (Post-Quantum Secure PSK) integration where
/// the PSK is derived during the handshake process.
///
/// # Arguments
/// * `index` - PSK index (typically 3 for XKpsk3 pattern)
/// * `psk` - The pre-shared key bytes to inject
///
/// # Errors
/// Returns an error if:
/// - Not in handshake state
/// - The underlying snow library rejects the PSK
pub fn set_psk(&mut self, index: u8, psk: &[u8]) -> Result<(), NoiseError> {
match &mut self.state {
NoiseProtocolState::Handshaking(handshake_state) => {
handshake_state
.set_psk(index as usize, psk)
.map_err(NoiseError::ProtocolError)?;
Ok(())
}
_ => Err(NoiseError::IncorrectStateError),
}
}
}
pub fn create_noise_state(
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<NoiseProtocol, NoiseError> {
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
let psk_index = 3;
let noise_params: NoiseParams = pattern_name.parse().unwrap();
let builder = snow::Builder::new(noise_params.clone());
// Using dummy remote key as it's not needed for state creation itself
// In a real scenario, the key would depend on initiator/responder role
let handshake_state = builder
.local_private_key(local_private_key)
.remote_public_key(remote_public_key) // Use own public as dummy remote
.psk(psk_index, psk)
.build_initiator()?;
Ok(NoiseProtocol::new(handshake_state))
}
pub fn create_noise_state_responder(
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<NoiseProtocol, NoiseError> {
let pattern_name = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
let psk_index = 3;
let noise_params: NoiseParams = pattern_name.parse().unwrap();
let builder = snow::Builder::new(noise_params.clone());
// Using dummy remote key as it's not needed for state creation itself
// In a real scenario, the key would depend on initiator/responder role
let handshake_state = builder
.local_private_key(local_private_key)
.remote_public_key(remote_public_key) // Use own public as dummy remote
.psk(psk_index, psk)
.build_responder()?;
Ok(NoiseProtocol::new(handshake_state))
}
+285
View File
@@ -0,0 +1,285 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::LpError;
use crate::message::LpMessage;
use crate::replay::ReceivingKeyCounterValidator;
use bytes::{BufMut, BytesMut};
use nym_lp_common::format_debug_bytes;
use parking_lot::Mutex;
use std::fmt::Write;
use std::fmt::{Debug, Formatter};
use std::sync::Arc;
use tracing::warn;
#[allow(dead_code)]
pub(crate) const UDP_HEADER_LEN: usize = 8;
#[allow(dead_code)]
pub(crate) const IP_HEADER_LEN: usize = 40; // v4 - 20, v6 - 40
#[allow(dead_code)]
pub(crate) const MTU: usize = 1500;
#[allow(dead_code)]
pub(crate) const UDP_OVERHEAD: usize = UDP_HEADER_LEN + IP_HEADER_LEN;
#[allow(dead_code)]
pub const TRAILER_LEN: usize = 16;
#[allow(dead_code)]
pub(crate) const UDP_PAYLOAD_SIZE: usize = MTU - UDP_OVERHEAD - TRAILER_LEN;
pub mod version {
/// The current version of the Lewes Protocol that is put into each new constructed header.
pub const CURRENT: u8 = 1;
}
#[derive(Clone)]
pub struct LpPacket {
pub(crate) header: LpHeader,
pub(crate) message: LpMessage,
pub(crate) trailer: [u8; TRAILER_LEN],
}
impl Debug for LpPacket {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", format_debug_bytes(&self.debug_bytes())?)
}
}
impl LpPacket {
pub fn new(header: LpHeader, message: LpMessage) -> Self {
Self {
header,
message,
trailer: [0; TRAILER_LEN],
}
}
/// Compute a hash of the message payload
///
/// This can be used for message integrity verification or deduplication
pub fn hash_payload(&self) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let mut buffer = BytesMut::new();
// Include message type and content in the hash
buffer.put_slice(&(self.message.typ() as u16).to_le_bytes());
self.message.encode_content(&mut buffer);
hasher.update(&buffer);
hasher.finalize().into()
}
pub fn hash_payload_hex(&self) -> String {
let hash = self.hash_payload();
hash.iter()
.fold(String::with_capacity(hash.len() * 2), |mut acc, byte| {
let _ = write!(acc, "{:02x}", byte);
acc
})
}
pub fn message(&self) -> &LpMessage {
&self.message
}
pub fn header(&self) -> &LpHeader {
&self.header
}
pub(crate) fn debug_bytes(&self) -> Vec<u8> {
let mut bytes = BytesMut::new();
self.encode(&mut bytes);
bytes.freeze().to_vec()
}
pub(crate) fn encode(&self, dst: &mut BytesMut) {
self.header.encode(dst);
dst.put_slice(&(self.message.typ() as u16).to_le_bytes());
self.message.encode_content(dst);
dst.put_slice(&self.trailer)
}
/// Validate packet counter against a replay protection validator
///
/// This performs a quick check to see if the packet counter is valid before
/// any expensive processing is done.
pub fn validate_counter(
&self,
validator: &Arc<Mutex<ReceivingKeyCounterValidator>>,
) -> Result<(), LpError> {
let guard = validator.lock();
guard.will_accept_branchless(self.header.counter)?;
Ok(())
}
/// Mark packet as received in the replay protection validator
///
/// This should be called after a packet has been successfully processed.
pub fn mark_received(
&self,
validator: &Arc<Mutex<ReceivingKeyCounterValidator>>,
) -> Result<(), LpError> {
let mut guard = validator.lock();
guard.mark_did_receive_branchless(self.header.counter)?;
Ok(())
}
}
/// Session ID used for ClientHello bootstrap packets before session is established.
///
/// When a client first connects, it sends a ClientHello packet with receiver_idx=0
/// because neither side can compute the deterministic session ID yet (requires
/// both parties' X25519 keys). After ClientHello is processed, both sides derive
/// the same session ID from their keys, and all subsequent packets use that ID.
pub const BOOTSTRAP_RECEIVER_IDX: u32 = 0;
/// Outer header (12 bytes) - always cleartext, used for routing.
///
/// This is the first 12 bytes of every LP packet, containing only the fields
/// needed for session lookup (receiver_idx) and replay protection (counter).
/// For encrypted packets, this is the AAD (additional authenticated data).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OuterHeader {
pub receiver_idx: u32,
pub counter: u64,
}
impl OuterHeader {
pub const SIZE: usize = 12; // receiver_idx(4) + counter(8)
pub fn new(receiver_idx: u32, counter: u64) -> Self {
Self {
receiver_idx,
counter,
}
}
pub fn parse(src: &[u8]) -> Result<Self, LpError> {
if src.len() < Self::SIZE {
return Err(LpError::InsufficientBufferSize);
}
Ok(Self {
receiver_idx: u32::from_le_bytes(src[0..4].try_into().unwrap()),
counter: u64::from_le_bytes(src[4..12].try_into().unwrap()),
})
}
pub fn encode(&self) -> [u8; Self::SIZE] {
let mut buf = [0u8; Self::SIZE];
buf[0..4].copy_from_slice(&self.receiver_idx.to_le_bytes());
buf[4..12].copy_from_slice(&self.counter.to_le_bytes());
buf
}
/// Encode directly into a BytesMut buffer
pub fn encode_into(&self, dst: &mut BytesMut) {
dst.put_slice(&self.receiver_idx.to_le_bytes());
dst.put_slice(&self.counter.to_le_bytes());
}
}
/// Internal LP header representation containing all logical header fields.
///
/// **Note**: This struct represents the LOGICAL header, not the wire format.
/// On the wire, packets use the unified format where:
/// - `OuterHeader` (receiver_idx + counter) always comes first (12 bytes, cleartext)
/// - Inner content (version + reserved + payload) follows (cleartext or encrypted)
///
/// The `LpHeader::encode()` method outputs the old logical format for debug purposes only.
/// Use `serialize_lp_packet()` in codec.rs for actual wire serialization.
#[derive(Debug, Clone)]
pub struct LpHeader {
pub protocol_version: u8,
pub reserved: [u8; 3],
pub receiver_idx: u32,
pub counter: u64,
}
impl LpHeader {
pub const SIZE: usize = 16;
}
impl LpHeader {
pub fn new(receiver_idx: u32, counter: u64) -> Self {
Self {
protocol_version: version::CURRENT,
reserved: [0u8; 3],
receiver_idx,
counter,
}
}
pub fn encode(&self, dst: &mut BytesMut) {
// protocol version
dst.put_u8(self.protocol_version);
// reserved
dst.put_slice(&self.reserved);
// sender index
dst.put_slice(&self.receiver_idx.to_le_bytes());
// counter
dst.put_slice(&self.counter.to_le_bytes());
}
pub fn parse(src: &[u8]) -> Result<Self, LpError> {
if src.len() < Self::SIZE {
return Err(LpError::InsufficientBufferSize);
}
let protocol_version = src[0];
// Ensure we are using compatible protocol
// right now only support a single version
if protocol_version > version::CURRENT {
return Err(LpError::IncompatibleFuturePacketVersion {
got: protocol_version,
highest_supported: version::CURRENT,
});
}
if protocol_version < version::CURRENT {
return Err(LpError::IncompatibleLegacyPacketVersion {
got: protocol_version,
lowest_supported: version::CURRENT,
});
}
// skip reserved bytes, but log if they're different from the expected zeroes
let reserved = [src[1], src[2], src[3]];
if reserved != [0u8; 3] {
warn!("received non-zero reserved bytes. got: {reserved:?}");
}
let mut receiver_idx_bytes = [0u8; 4];
receiver_idx_bytes.copy_from_slice(&src[4..8]);
let receiver_idx = u32::from_le_bytes(receiver_idx_bytes);
let mut counter_bytes = [0u8; 8];
counter_bytes.copy_from_slice(&src[8..16]);
let counter = u64::from_le_bytes(counter_bytes);
Ok(LpHeader {
protocol_version,
reserved: [0u8; 3],
receiver_idx,
counter,
})
}
/// Get the counter value from the header
pub fn counter(&self) -> u64 {
self.counter
}
/// Get the sender index from the header
pub fn receiver_idx(&self) -> u32 {
self.receiver_idx
}
}
// subsequent data: MessageType || Data
+792
View File
@@ -0,0 +1,792 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! PSK (Pre-Shared Key) derivation for LP sessions using Blake3 KDF.
//!
//! This module implements identity-bound PSK derivation where both client and gateway
//! derive the same PSK from their LP keypairs.
//!
//! PSQ is embedded in Noise (not separate protocol) because:
//! 1. Single round-trip: PSQ ciphertext piggybacks on Noise handshake messages
//! 2. PSK binding: Noise XKpsk3 pattern authenticates both ECDH and PSQ-derived PSK
//! 3. Simpler state machine: No separate PSQ negotiation phase needed
//! 4. Atomic security: Session establishment either succeeds fully or fails completely
//!
//! Two approaches are supported:
//! - **Legacy ECDH-only** (`derive_psk`) - Simple but no post-quantum security
//! - **PSQ-enhanced** (`derive_psk_with_psq_*`) - Combines ECDH with post-quantum KEM
//!
//! ## Error Handling Strategy
//!
//! **PSQ failures always abort the handshake cleanly with no retry or fallback.**
//!
//! ### Rationale
//!
//! PSQ errors indicate:
//! - **Authentication failures** (CredError) - Potential attack or misconfiguration
//! - **Timing failures** (TimestampElapsed) - Replay attacks or clock skew
//! - **Crypto failures** (CryptoError) - Library bugs or hardware faults
//! - **Serialization failures** (Serialization) - Protocol violations or corruption
//!
//! None of these are transient errors that benefit from retry. Falling back to
//! ECDH-only PSK would silently degrade post-quantum security.
//!
//! ### Error Recovery Behavior
//!
//! On any PSQ error:
//! 1. Function returns `Err(LpError)` immediately
//! 2. Session state remains unchanged (dummy PSK, clean Noise state)
//! 3. Handshake aborts - caller must start fresh connection
//! 4. Error is logged with diagnostic context
//!
//! ### State Guarantees on Error
//!
//! - **`psq_state`**: Remains in `NotStarted` (initiator) or `ResponderWaiting` (responder)
//! - **Noise `HandshakeState`**: PSK slot 3 = dummy `[0u8; 32]` (not modified on error)
//! - **No partial data**: All allocations are stack-local to failed function
//! - **No cleanup needed**: No state was mutated
use crate::LpError;
use libcrux_psq::v1::cred::{Authenticator, Ed25519};
use libcrux_psq::v1::impls::X25519 as PsqX25519;
use libcrux_psq::v1::psk_registration::{Initiator, InitiatorMsg, Responder};
use libcrux_psq::v1::traits::{Ciphertext as PsqCiphertext, PSQ};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey};
use std::time::Duration;
use tls_codec::{Deserialize as TlsDeserializeTrait, Serialize as TlsSerializeTrait};
/// Context string for Blake3 KDF domain separation (PSQ-enhanced).
const PSK_PSQ_CONTEXT: &str = "nym-lp-psk-psq-v1";
/// Session context for PSQ protocol.
const PSQ_SESSION_CONTEXT: &[u8] = b"nym-lp-psq-session";
/// Context string for subsession PSK derivation.
const SUBSESSION_PSK_CONTEXT: &str = "lp-subsession-psk-v1";
/// Result from PSQ initiator message creation.
///
/// Contains all outputs needed for session establishment:
/// - `psk`: Final derived PSK for Noise handshake (ECDH || K_pq || salt → Blake3)
/// - `payload`: Serialized PSQ message to send to responder
/// - `pq_shared_secret`: Raw K_pq from KEM encapsulation (for subsession derivation)
#[derive(Debug)]
pub struct PsqInitiatorResult {
/// Final PSK for Noise XKpsk3 handshake
pub psk: [u8; 32],
/// Serialized PSQ payload to embed in handshake message
pub payload: Vec<u8>,
/// Raw PQ shared secret (K_pq) before KDF combination.
/// Used for deriving subsession PSKs to preserve PQ protection.
pub pq_shared_secret: [u8; 32],
}
/// Result from PSQ responder message processing.
///
/// Contains all outputs needed for session establishment:
/// - `psk`: Final derived PSK for Noise handshake (matches initiator's)
/// - `psk_handle`: Encrypted PSK handle (ctxt_B) to send back to initiator
/// - `pq_shared_secret`: Raw K_pq from KEM decapsulation (for subsession derivation)
#[derive(Debug)]
pub struct PsqResponderResult {
/// Final PSK for Noise XKpsk3 handshake
pub psk: [u8; 32],
/// Encrypted PSK handle (ctxt_B) from PSQ responder message
pub psk_handle: Vec<u8>,
/// Raw PQ shared secret (K_pq) before KDF combination.
/// Used for deriving subsession PSKs to preserve PQ protection.
pub pq_shared_secret: [u8; 32],
}
/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Initiator side.
///
/// This function combines classical ECDH with post-quantum KEM to provide forward secrecy
/// and HNDL (Harvest-Now, Decrypt-Later) resistance.
///
/// # Formula
/// ```text
/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public)
/// (psq_psk, ct) = PSQ_Encapsulate(remote_kem_public, session_context)
/// psk = Blake3_derive_key(
/// context="nym-lp-psk-psq-v1",
/// input=ecdh_secret || psq_psk || salt
/// )
/// ```
///
/// # Arguments
/// * `local_x25519_private` - Initiator's X25519 private key (for Noise)
/// * `remote_x25519_public` - Responder's X25519 public key (for Noise)
/// * `remote_kem_public` - Responder's KEM public key (obtained via KKT)
/// * `salt` - 32-byte salt for session binding
///
/// # Returns
/// * `Ok((psk, ciphertext))` - PSK and ciphertext to send to responder
/// * `Err(LpError)` - If PSQ encapsulation fails
///
/// # Example
/// ```ignore
/// // Client side (after KKT exchange)
/// let (psk, ciphertext) = derive_psk_with_psq_initiator(
/// client_x25519_private,
/// gateway_x25519_public,
/// &gateway_kem_key, // from KKT
/// &salt
/// )?;
/// // Send ciphertext to gateway
/// ```
pub fn derive_psk_with_psq_initiator(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
remote_kem_public: &EncapsulationKey,
salt: &[u8; 32],
) -> Result<([u8; 32], Vec<u8>), LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: PSQ encapsulation for post-quantum security
// KEM algorithm migration path:
// - X25519: Current default for testing/compatibility (no HNDL resistance)
// - MlKem768: Future production default (NIST PQ Level 3, HNDL resistant)
// - XWing: Maximum security option (hybrid X25519 + ML-KEM)
// Migration: Update LpConfig.kem_algorithm, no protocol changes needed.
// KKT protocol adapts automatically to different KEM key sizes.
let kem_pk = match remote_kem_public {
EncapsulationKey::X25519(pk) => pk,
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
let mut rng = rand09::rng();
let (psq_psk, ciphertext) =
PsqX25519::encapsulate_psq(kem_pk, PSQ_SESSION_CONTEXT, &mut rng)
.map_err(|e| LpError::Internal(format!("PSQ encapsulation failed: {:?}", e)))?;
// Step 3: Combine ECDH + PSQ via Blake3 KDF
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need &
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
// Serialize ciphertext using TLS encoding for transport
let ct_bytes = ciphertext
.tls_serialize_detached()
.map_err(|e| LpError::Internal(format!("Ciphertext serialization failed: {:?}", e)))?;
Ok((final_psk, ct_bytes))
}
/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Responder side.
///
/// This function decapsulates the ciphertext from the initiator and combines it with
/// ECDH to derive the same PSK.
///
/// # Formula
/// ```text
/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public)
/// psq_psk = PSQ_Decapsulate(local_kem_keypair, ciphertext, session_context)
/// psk = Blake3_derive_key(
/// context="nym-lp-psk-psq-v1",
/// input=ecdh_secret || psq_psk || salt
/// )
/// ```
///
/// # Arguments
/// * `local_x25519_private` - Responder's X25519 private key (for Noise)
/// * `remote_x25519_public` - Initiator's X25519 public key (for Noise)
/// * `local_kem_keypair` - Responder's KEM keypair (decapsulation key, public key)
/// * `ciphertext` - PSQ ciphertext from initiator
/// * `salt` - 32-byte salt for session binding
///
/// # Returns
/// * `Ok(psk)` - Derived PSK
/// * `Err(LpError)` - If PSQ decapsulation fails
///
/// # Example
/// ```ignore
/// // Gateway side (after receiving ciphertext)
/// let psk = derive_psk_with_psq_responder(
/// gateway_x25519_private,
/// client_x25519_public,
/// (&gateway_kem_sk, &gateway_kem_pk),
/// &ciphertext, // from client
/// &salt
/// )?;
/// ```
pub fn derive_psk_with_psq_responder(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
local_kem_keypair: (&DecapsulationKey, &EncapsulationKey),
ciphertext: &[u8],
salt: &[u8; 32],
) -> Result<[u8; 32], LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey
let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) {
(DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk),
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
// Step 3: Deserialize ciphertext using TLS decoding
let ct = PsqCiphertext::<PsqX25519>::tls_deserialize(&mut &ciphertext[..])
.map_err(|e| LpError::Internal(format!("Ciphertext deserialization failed: {:?}", e)))?;
// Step 4: PSQ decapsulation for post-quantum security
let psq_psk = PsqX25519::decapsulate_psq(kem_sk, kem_pk, &ct, PSQ_SESSION_CONTEXT)
.map_err(|e| LpError::Internal(format!("PSQ decapsulation failed: {:?}", e)))?;
// Step 5: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator)
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need &
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
Ok(final_psk)
}
/// PSQ protocol wrapper for initiator (client) side.
///
/// Creates a PSQ initiator message with Ed25519 authentication, following the protocol:
/// 1. Encapsulate PSK using responder's KEM key
/// 2. Derive PSK and AEAD keys from K_pq
/// 3. Sign the encapsulation with Ed25519
/// 4. AEAD encrypt (timestamp || signature || public_key)
///
/// Returns (PSK, serialized_payload) where payload includes enc_pq and encrypted auth data.
///
/// # Arguments
/// * `local_x25519_private` - Client's X25519 private key (for hybrid ECDH)
/// * `remote_x25519_public` - Gateway's X25519 public key (for hybrid ECDH)
/// * `remote_kem_public` - Gateway's PQ KEM public key (from KKT)
/// * `client_ed25519_sk` - Client's Ed25519 signing key
/// * `client_ed25519_pk` - Client's Ed25519 public key (credential)
/// * `salt` - Session salt
/// * `session_context` - Context bytes for PSQ (e.g., b"nym-lp-psq-session")
///
/// # Returns
/// `PsqInitiatorResult` containing PSK, payload, and raw PQ shared secret
pub fn psq_initiator_create_message(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
remote_kem_public: &EncapsulationKey,
client_ed25519_sk: &ed25519::PrivateKey,
client_ed25519_pk: &ed25519::PublicKey,
salt: &[u8; 32],
session_context: &[u8],
) -> Result<PsqInitiatorResult, LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: PSQ v1 with Ed25519 authentication
// Extract X25519 KEM key from EncapsulationKey
let kem_pk = match remote_kem_public {
EncapsulationKey::X25519(pk) => pk,
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
// Convert nym Ed25519 keys to libcrux format
type Ed25519VerificationKey = <Ed25519 as Authenticator>::VerificationKey;
let ed25519_sk_bytes = client_ed25519_sk.to_bytes();
let ed25519_pk_bytes = client_ed25519_pk.to_bytes();
let ed25519_verification_key = Ed25519VerificationKey::from_bytes(ed25519_pk_bytes);
// Use PSQ v1 API with Ed25519 authentication
let mut rng = rand09::rng();
let (state, initiator_msg) = Initiator::send_initial_message::<Ed25519, PsqX25519>(
session_context,
Duration::from_secs(3600), // 1 hour expiry
kem_pk,
&ed25519_sk_bytes,
&ed25519_verification_key,
&mut rng,
)
.map_err(|e| {
tracing::error!(
"PSQ initiator failed - KEM encapsulation or signing error: {:?}",
e
);
LpError::Internal(format!("PSQ v1 send_initial_message failed: {:?}", e))
})?;
// Extract PSQ shared secret (unregistered PSK) - this is K_pq
let psq_psk = state.unregistered_psk();
// pq_shared_secret is the raw K_pq from KEM encapsulation.
// Store it for subsession derivation before it's combined with ECDH.
let pq_shared_secret: [u8; 32] = *psq_psk;
// Step 3: Combine ECDH + PSQ via Blake3 KDF
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(psq_psk); // psq_psk is already a &[u8; 32]
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
// Serialize InitiatorMsg with TLS encoding for transport
let msg_bytes = initiator_msg
.tls_serialize_detached()
.map_err(|e| LpError::Internal(format!("InitiatorMsg serialization failed: {:?}", e)))?;
Ok(PsqInitiatorResult {
psk: final_psk,
payload: msg_bytes,
pq_shared_secret,
})
}
/// PSQ protocol wrapper for responder (gateway) side.
///
/// Processes a PSQ initiator message, verifies authentication, and derives PSK.
/// Follows the protocol:
/// 1. Decapsulate to get K_pq
/// 2. Derive AEAD keys and verify encrypted auth data
/// 3. Verify Ed25519 signature
/// 4. Check timestamp validity
/// 5. Derive PSK
///
/// # Arguments
/// * `local_x25519_private` - Gateway's X25519 private key (for hybrid ECDH)
/// * `remote_x25519_public` - Client's X25519 public key (for hybrid ECDH)
/// * `local_kem_keypair` - Gateway's PQ KEM keypair
/// * `initiator_ed25519_pk` - Client's Ed25519 public key (for signature verification)
/// * `psq_payload` - Serialized PSQ payload from initiator
/// * `salt` - Session salt (must match initiator's)
/// * `session_context` - Context bytes for PSQ
///
/// # Returns
/// `PsqResponderResult` containing PSK, PSK handle, and raw PQ shared secret
pub fn psq_responder_process_message(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
local_kem_keypair: (&DecapsulationKey, &EncapsulationKey),
initiator_ed25519_pk: &ed25519::PublicKey,
psq_payload: &[u8],
salt: &[u8; 32],
session_context: &[u8],
) -> Result<PsqResponderResult, LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey
let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) {
(DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk),
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
// Step 3: Deserialize InitiatorMsg using TLS decoding
let initiator_msg = InitiatorMsg::<PsqX25519>::tls_deserialize(&mut &psq_payload[..])
.map_err(|e| LpError::Internal(format!("InitiatorMsg deserialization failed: {:?}", e)))?;
// Step 4: Convert nym Ed25519 public key to libcrux VerificationKey format
type Ed25519VerificationKey = <Ed25519 as Authenticator>::VerificationKey;
let initiator_ed25519_pk_bytes = initiator_ed25519_pk.to_bytes();
let initiator_verification_key = Ed25519VerificationKey::from_bytes(initiator_ed25519_pk_bytes);
// Step 5: PSQ v1 responder processing with Ed25519 verification
let (registered_psk, responder_msg) = Responder::send::<Ed25519, PsqX25519>(
b"nym-lp-handle", // PSK storage handle
Duration::from_secs(3600), // 1 hour expiry (must match initiator)
session_context, // Must match initiator's session_context
kem_pk, // Responder's public key
kem_sk, // Responder's secret key
&initiator_verification_key, // Initiator's Ed25519 public key for verification
&initiator_msg, // InitiatorMsg to verify and process
)
.map_err(|e| {
use libcrux_psq::v1::Error as PsqError;
match e {
PsqError::CredError => {
tracing::warn!(
"PSQ responder auth failure - invalid Ed25519 signature (potential attack)"
);
}
PsqError::TimestampElapsed | PsqError::RegistrationError => {
tracing::warn!(
"PSQ responder timing failure - TTL expired (potential replay attack)"
);
}
_ => {
tracing::error!("PSQ responder failed - {:?}", e);
}
}
LpError::Internal(format!("PSQ v1 responder send failed: {:?}", e))
})?;
// Extract the PSQ PSK from the registered PSK - this is K_pq
let psq_psk = registered_psk.psk;
// pq_shared_secret is the raw K_pq from KEM decapsulation.
// Store it for subsession derivation before it's combined with ECDH.
let pq_shared_secret: [u8; 32] = psq_psk;
// Step 6: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator)
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need &
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
// Step 7: Serialize ResponderMsg (contains ctxt_B - encrypted PSK handle)
use tls_codec::Serialize;
let responder_msg_bytes = responder_msg
.tls_serialize_detached()
.map_err(|e| LpError::Internal(format!("ResponderMsg serialization failed: {:?}", e)))?;
Ok(PsqResponderResult {
psk: final_psk,
psk_handle: responder_msg_bytes,
pq_shared_secret,
})
}
/// Derive subsession PSK from parent's PQ shared secret.
///
/// Uses Blake3 KDF with domain separation to derive unique PSK for each subsession.
/// This preserves PQ protection: subsession keys inherit quantum resistance from
/// parent's KEM shared secret (K_pq).
///
/// # Security Model
///
/// Subsessions use Noise KKpsk0 pattern where:
/// - Both parties already know each other's static X25519 keys (from parent session)
/// - PSK provides PQ protection by deriving from parent's K_pq
/// - Each subsession gets unique PSK via index parameter (prevents key reuse)
///
/// # Arguments
/// * `pq_shared_secret` - Parent session's K_pq (32 bytes from KEM)
/// * `subsession_index` - Monotonic index for this subsession (prevents reuse)
///
/// # Returns
/// 32-byte PSK for Noise KKpsk0 handshake
pub fn derive_subsession_psk(pq_shared_secret: &[u8; 32], subsession_index: u64) -> [u8; 32] {
nym_crypto::kdf::derive_key_blake3(
SUBSESSION_PSK_CONTEXT,
pq_shared_secret,
&subsession_index.to_le_bytes(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::thread_rng;
fn generate_x25519_keypair() -> x25519::KeyPair {
x25519::KeyPair::new(&mut thread_rng())
}
#[test]
fn test_psk_derivation_is_symmetric() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let salt = [2u8; 32];
let mut rng = &mut rand09::rng();
let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let dec_key = DecapsulationKey::X25519(_kem_sk);
// Client derives PSK
let (client_psk, ciphertext) = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt,
)
.unwrap();
// Gateway derives PSK from their perspective
let gateway_psk = derive_psk_with_psq_responder(
keypair_2.private_key(),
keypair_1.public_key(),
(&dec_key, &enc_key),
&ciphertext,
&salt,
)
.unwrap();
assert_eq!(
client_psk, gateway_psk,
"Both sides should derive identical PSK"
);
}
#[test]
fn test_different_salts_produce_different_psks() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let salt1 = [1u8; 32];
let salt2 = [2u8; 32];
let mut rng = &mut rand09::rng();
let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let psk1 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt1,
)
.unwrap();
let psk2 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt2,
)
.unwrap();
assert_ne!(psk1, psk2, "Different salts should produce different PSKs");
}
#[test]
fn test_different_keys_produce_different_psks() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let keypair_3 = generate_x25519_keypair();
let salt = [3u8; 32];
let mut rng = &mut rand09::rng();
let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let psk1 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt,
)
.unwrap();
let psk2 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_3.public_key(),
&enc_key,
&salt,
)
.unwrap();
assert_ne!(
psk1, psk2,
"Different remote keys should produce different PSKs"
);
}
// PSQ-enhanced PSK tests
use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey, KEM};
use nym_kkt::key_utils::generate_keypair_libcrux;
#[test]
fn test_psq_derivation_deterministic() {
let mut rng = rand09::rng();
// Generate X25519 keypairs for Noise
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// Generate KEM keypair for PSQ
let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let dec_key = DecapsulationKey::X25519(kem_sk);
let salt = [1u8; 32];
// Derive PSK twice with same inputs (initiator side)
let (_psk1, ct1) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
let (_psk2, _ct2) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
// PSKs will be different due to randomness in PSQ, but ciphertexts too
// This test verifies the function is deterministic given the SAME ciphertext
let psk_responder1 = derive_psk_with_psq_responder(
gateway_keypair.private_key(),
client_keypair.public_key(),
(&dec_key, &enc_key),
&ct1,
&salt,
)
.unwrap();
let psk_responder2 = derive_psk_with_psq_responder(
gateway_keypair.private_key(),
client_keypair.public_key(),
(&dec_key, &enc_key),
&ct1, // Same ciphertext
&salt,
)
.unwrap();
assert_eq!(
psk_responder1, psk_responder2,
"Same ciphertext should produce same PSK"
);
}
#[test]
fn test_psq_derivation_symmetric() {
let mut rng = rand09::rng();
// Generate X25519 keypairs for Noise
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// Generate KEM keypair for PSQ
let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let dec_key = DecapsulationKey::X25519(kem_sk);
let salt = [2u8; 32];
// Client derives PSK (initiator)
let (client_psk, ciphertext) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
// Gateway derives PSK from ciphertext (responder)
let gateway_psk = derive_psk_with_psq_responder(
gateway_keypair.private_key(),
client_keypair.public_key(),
(&dec_key, &enc_key),
&ciphertext,
&salt,
)
.unwrap();
assert_eq!(
client_psk, gateway_psk,
"Both sides should derive identical PSK via PSQ"
);
}
#[test]
fn test_different_kem_keys_different_psk() {
let mut rng = rand09::rng();
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// Two different KEM keypairs
let (_, kem_pk1) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let (_, kem_pk2) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key1 = EncapsulationKey::X25519(kem_pk1);
let enc_key2 = EncapsulationKey::X25519(kem_pk2);
let salt = [3u8; 32];
let (psk1, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key1,
&salt,
)
.unwrap();
let (psk2, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key2,
&salt,
)
.unwrap();
assert_ne!(
psk1, psk2,
"Different KEM keys should produce different PSKs"
);
}
#[test]
fn test_psq_psk_output_length() {
let mut rng = rand09::rng();
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let salt = [4u8; 32];
let (psk, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
assert_eq!(psk.len(), 32, "PSQ PSK should be exactly 32 bytes");
}
#[test]
fn test_psq_different_salts_different_psks() {
let mut rng = rand09::rng();
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let salt1 = [1u8; 32];
let salt2 = [2u8; 32];
let (psk1, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt1,
)
.unwrap();
let (psk2, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt2,
)
.unwrap();
assert_ne!(psk1, psk2, "Different salts should produce different PSKs");
}
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Error types for replay protection.
use thiserror::Error;
/// Errors that can occur during replay protection validation.
#[derive(Debug, Error)]
pub enum ReplayError {
/// The counter value is invalid (e.g., too far in the future)
#[error("Invalid counter value")]
InvalidCounter,
/// The packet has already been received (replay attack)
#[error("Duplicate counter value")]
DuplicateCounter,
/// The packet is outside the replay window
#[error("Packet outside replay window")]
OutOfWindow,
}
/// Result type for replay protection operations
pub type ReplayResult<T> = Result<T, ReplayError>;
#[cfg(test)]
mod tests {
use super::*;
use crate::error::LpError;
#[test]
fn test_replay_error_variants() {
let invalid = ReplayError::InvalidCounter;
let duplicate = ReplayError::DuplicateCounter;
let out_of_window = ReplayError::OutOfWindow;
assert_eq!(invalid.to_string(), "Invalid counter value");
assert_eq!(duplicate.to_string(), "Duplicate counter value");
assert_eq!(out_of_window.to_string(), "Packet outside replay window");
}
#[test]
fn test_replay_error_conversion() {
let replay_error = ReplayError::InvalidCounter;
let lp_error: LpError = replay_error.into();
match lp_error {
LpError::Replay(e) => {
assert!(matches!(e, ReplayError::InvalidCounter));
}
_ => panic!("Expected Replay variant"),
}
}
#[test]
fn test_replay_result() {
let ok_result: ReplayResult<()> = Ok(());
let err = ReplayError::InvalidCounter;
assert!(ok_result.is_ok());
assert!(matches!(err, ReplayError::InvalidCounter));
}
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Replay protection module for the Lewes Protocol.
//!
//! This module implements BoringTun-style replay protection to prevent
//! replay attacks and ensure packet ordering. It uses a bitmap-based
//! approach to track received packets and validate their sequence.
pub mod error;
pub mod simd;
pub mod validator;
pub use error::ReplayError;
pub use validator::ReceivingKeyCounterValidator;
+281
View File
@@ -0,0 +1,281 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! ARM NEON implementation of bitmap operations.
use super::BitmapOps;
#[cfg(target_feature = "neon")]
use std::arch::aarch64::{vceqq_u64, vdupq_n_u64, vgetq_lane_u64, vld1q_u64, vst1q_u64};
/// ARM NEON bitmap operations implementation
pub struct ArmBitmapOps;
impl BitmapOps for ArmBitmapOps {
#[inline(always)]
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
debug_assert!(start_idx + num_words <= bitmap.len());
#[cfg(target_feature = "neon")]
unsafe {
// Process 2 words at a time with NEON
// Safety:
// - vdupq_n_u64 is safe to call with any u64 value
let zero_vec = vdupq_n_u64(0);
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
vst1q_u64(bitmap[idx..].as_mut_ptr(), zero_vec);
idx += 2;
}
// Handle remaining words (0 or 1)
while idx < end_idx {
bitmap[idx] = 0;
idx += 1;
}
}
#[cfg(not(target_feature = "neon"))]
{
// Fallback to scalar implementation
for i in start_idx..(start_idx + num_words) {
bitmap[i] = 0;
}
}
}
#[inline(always)]
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
debug_assert!(start_idx + num_words <= bitmap.len());
#[cfg(target_feature = "neon")]
unsafe {
// Process 2 words at a time with NEON
// Safety:
// - vdupq_n_u64 is safe to call with any u64 value
let zero_vec = vdupq_n_u64(0);
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
let data_vec = vld1q_u64(bitmap[idx..].as_ptr());
// Safety:
// - vceqq_u64 is safe when given valid vector values from vld1q_u64 and vdupq_n_u64
// - vgetq_lane_u64 is safe with valid indices (0 and 1) for a 2-lane vector
let cmp_result = vceqq_u64(data_vec, zero_vec);
let mask1 = vgetq_lane_u64(cmp_result, 0);
let mask2 = vgetq_lane_u64(cmp_result, 1);
if (mask1 & mask2) != u64::MAX {
return false;
}
idx += 2;
}
// Handle remaining words (0 or 1)
while idx < end_idx {
if bitmap[idx] != 0 {
return false;
}
idx += 1;
}
true
}
#[cfg(not(target_feature = "neon"))]
{
// Fallback to scalar implementation
bitmap[start_idx..(start_idx + num_words)]
.iter()
.all(|&w| w == 0)
}
}
#[inline(always)]
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
bitmap[word_idx] |= 1u64 << bit_pos;
}
#[inline(always)]
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
bitmap[word_idx] &= !(1u64 << bit_pos);
}
#[inline(always)]
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
}
}
/// We also implement optimized versions for specific operations that could
/// benefit from NEON but don't fit the general trait pattern
///
/// Atomic operations for the bitmap
pub mod atomic {
#[cfg(target_feature = "neon")]
use std::arch::aarch64::{vdupq_n_u64, vld1q_u64, vorrq_u64, vst1q_u64};
/// Check and set bit, returning the previous state
/// This function is not actually atomic! It's just a non-atomic optimization
/// For actual atomic operations, the caller must provide proper synchronization
#[inline(always)]
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
let mask = 1u64 << bit_pos;
// Get old value
let old_word = bitmap[word_idx];
// Set bit regardless of current state
bitmap[word_idx] |= mask;
// Return true if bit was already set (duplicate)
(old_word & mask) != 0
}
/// Set a range of bits efficiently using NEON
///
/// # Safety
///
/// This function is unsafe because it:
/// - Uses SIMD intrinsics that require the NEON CPU feature to be available
/// - Accesses bitmap memory through raw pointers
/// - Does not perform bounds checking beyond what's required for SIMD operations
///
/// Caller must ensure:
/// - The NEON feature is available on the current CPU
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
/// - No other thread is concurrently modifying the same memory
#[inline(always)]
#[cfg(target_feature = "neon")]
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
if start_word == end_word {
// Special case: all bits in the same word
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if !start_bit.is_multiple_of(64) {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if !(end_bit + 1).is_multiple_of(64) {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle using NEON
let first_full_word = if start_bit.is_multiple_of(64) {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1).is_multiple_of(64) {
end_word
} else {
end_word - 1
};
if first_full_word <= last_full_word {
// Use NEON to set words faster
// Safety: vdupq_n_u64 is safe to call with any u64 value
let ones_vec = unsafe { vdupq_n_u64(u64::MAX) };
let mut idx = first_full_word;
while idx + 2 <= last_full_word + 1 {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We check that idx + 2 <= last_full_word + 1 to ensure we have 2 complete words
unsafe {
let current_vec = vld1q_u64(bitmap[idx..].as_ptr());
// Safety: vorrq_u64 is safe when given valid vector values
let result_vec = vorrq_u64(current_vec, ones_vec);
vst1q_u64(bitmap[idx..].as_mut_ptr(), result_vec);
}
idx += 2;
}
// Handle remaining words
while idx <= last_full_word {
bitmap[idx] = u64::MAX;
idx += 1;
}
}
}
/// Set a range of bits efficiently (scalar fallback)
#[inline(always)]
#[cfg(not(target_feature = "neon"))]
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
if start_word == end_word {
// Special case: all bits in the same word
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
for word_idx in first_full_word..=last_full_word {
bitmap[word_idx] = u64::MAX;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! SIMD optimizations for the replay protection bitmap operations.
//!
//! This module provides architecture-specific SIMD implementations with a common interface.
// Re-export the appropriate implementation
#[cfg(target_arch = "x86_64")]
mod x86;
#[cfg(target_arch = "x86_64")]
pub use self::x86::*;
#[cfg(target_arch = "aarch64")]
mod arm;
#[cfg(target_arch = "aarch64")]
pub use self::arm::*;
// Fallback scalar implementation for all other architectures
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
mod scalar;
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub use self::scalar::*;
/// Trait defining SIMD operations for bitmap manipulation
pub trait BitmapOps {
/// Clear a range of words in the bitmap
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize);
/// Check if a range of words in the bitmap is all zeros
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool;
/// Set a specific bit in the bitmap
fn set_bit(bitmap: &mut [u64], bit_idx: u64);
/// Clear a specific bit in the bitmap
fn clear_bit(bitmap: &mut [u64], bit_idx: u64);
/// Check if a specific bit is set in the bitmap
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool;
}
/// Get the optimal number of words to process in a SIMD operation
/// for the current architecture
#[inline(always)]
pub fn optimal_simd_width() -> usize {
// This value is specialized for each architecture in their respective modules
OPTIMAL_SIMD_WIDTH
}
/// Constant indicating the optimal SIMD processing width in number of u64 words
/// for the current architecture
#[cfg(target_arch = "x86_64")]
#[cfg(target_feature = "avx2")]
pub const OPTIMAL_SIMD_WIDTH: usize = 4; // 256 bits = 4 u64 words
#[cfg(target_arch = "x86_64")]
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
#[cfg(target_arch = "aarch64")]
#[cfg(target_feature = "neon")]
pub const OPTIMAL_SIMD_WIDTH: usize = 2; // 128 bits = 2 u64 words
// Fallback for non-SIMD platforms or when features aren't available
#[cfg(not(any(
all(target_arch = "x86_64", target_feature = "avx2"),
all(target_arch = "x86_64", target_feature = "sse2"),
all(target_arch = "aarch64", target_feature = "neon")
)))]
pub const OPTIMAL_SIMD_WIDTH: usize = 1; // Scalar fallback
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Scalar (non-SIMD) implementation of bitmap operations.
//! Used as a fallback when SIMD instructions are unavailable.
use super::BitmapOps;
/// Scalar (non-SIMD) bitmap operations implementation
pub struct ScalarBitmapOps;
impl BitmapOps for ScalarBitmapOps {
#[inline(always)]
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
for i in start_idx..(start_idx + num_words) {
bitmap[i] = 0;
}
}
#[inline(always)]
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
for i in start_idx..(start_idx + num_words) {
if bitmap[i] != 0 {
return false;
}
}
true
}
#[inline(always)]
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
bitmap[word_idx] |= 1u64 << bit_pos;
}
#[inline(always)]
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
bitmap[word_idx] &= !(1u64 << bit_pos);
}
#[inline(always)]
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
}
}
/// Scalar implementations of other bitmap utilities
pub mod atomic {
/// Check and set bit, returning the previous state
/// This function is not actually atomic! It's just a normal operation
#[inline(always)]
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = (bit_idx % 64) as u64;
let mask = 1u64 << bit_pos;
// Get old value
let old_word = bitmap[word_idx];
// Set bit regardless of current state
bitmap[word_idx] |= mask;
// Return true if bit was already set (duplicate)
(old_word & mask) != 0
}
/// Set a range of bits efficiently
#[inline(always)]
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
if start_word == end_word {
// Special case: all bits in the same word
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle
let first_full_word = if start_bit % 64 == 0 {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
for word_idx in first_full_word..=last_full_word {
bitmap[word_idx] = u64::MAX;
}
}
}
+496
View File
@@ -0,0 +1,496 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! x86/x86_64 SIMD implementation of bitmap operations.
//! Provides optimized implementations using SSE2 and AVX2 intrinsics.
use super::BitmapOps;
// Track execution counts for debugging
#[cfg(target_feature = "avx2")]
static mut AVX2_CLEAR_COUNT: usize = 0;
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
static mut SSE2_CLEAR_COUNT: usize = 0;
static mut SCALAR_CLEAR_COUNT: usize = 0;
// Import the appropriate SIMD intrinsics
#[cfg(target_feature = "avx2")]
use std::arch::x86_64::{
__m256i, _mm256_cmpeq_epi64, _mm256_load_si256, _mm256_loadu_si256, _mm256_movemask_epi8,
_mm256_or_si256, _mm256_set1_epi64x, _mm256_setzero_si256, _mm256_store_si256,
_mm256_storeu_si256, _mm256_testz_si256,
};
#[cfg(target_feature = "sse2")]
use std::arch::x86_64::{
__m128i, _mm_loadu_si128, _mm_or_si128, _mm_set1_epi64x, _mm_setzero_si128, _mm_storeu_si128,
};
#[cfg(all(target_feature = "sse2", not(target_feature = "sse4.1")))]
use std::arch::x86_64::{_mm_cmpeq_epi64, _mm_movemask_epi8};
/// x86/x86_64 SIMD bitmap operations implementation
pub struct X86BitmapOps;
impl BitmapOps for X86BitmapOps {
#[allow(unreachable_code)]
#[inline(always)]
fn clear_words(bitmap: &mut [u64], start_idx: usize, num_words: usize) {
debug_assert!(start_idx + num_words <= bitmap.len());
// First try AVX2 (256-bit, 4 words at a time)
#[cfg(target_feature = "avx2")]
unsafe {
// Track execution count
AVX2_CLEAR_COUNT += 1;
// Process 4 words at a time with AVX2
let zero_vec = _mm256_setzero_si256();
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 4 words
while idx + 4 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 4 u64 words (32 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 4 <= end_idx to ensure we have 4 complete words
// - The unaligned _storeu_ variant is used to handle any alignment
_mm256_storeu_si256(bitmap[idx..].as_mut_ptr() as *mut __m256i, zero_vec);
idx += 4;
}
// Handle remaining words with SSE2 or scalar ops
if idx < end_idx {
if idx + 2 <= end_idx {
// Use SSE2 for 2 words
// Safety: Same as above, but for 2 words (16 bytes) instead of 4
let sse_zero = _mm_setzero_si128();
_mm_storeu_si128(bitmap[idx..].as_mut_ptr() as *mut __m128i, sse_zero);
idx += 2;
}
// Handle any remaining words
while idx < end_idx {
bitmap[idx] = 0;
idx += 1;
}
}
return;
}
// If AVX2 is unavailable, try SSE2 (128-bit, 2 words at a time)
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
unsafe {
// Track execution count
SSE2_CLEAR_COUNT += 1;
// Process 2 words at a time with SSE2
let zero_vec = _mm_setzero_si128();
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
// - The unaligned _storeu_ variant is used to handle any alignment
_mm_storeu_si128(bitmap[idx..].as_mut_ptr() as *mut __m128i, zero_vec);
idx += 2;
}
// Handle remaining word (if any)
if idx < end_idx {
bitmap[idx] = 0;
}
return;
}
// Fallback to scalar implementation if no SIMD features available
unsafe {
// Safety: Just increments a static counter, with no possibility of data races
// as long as this function isn't called concurrently
SCALAR_CLEAR_COUNT += 1;
}
// Scalar fallback
#[allow(clippy::needless_range_loop)]
for i in start_idx..(start_idx + num_words) {
bitmap[i] = 0;
}
}
#[allow(unreachable_code)]
#[inline(always)]
fn is_range_zero(bitmap: &[u64], start_idx: usize, num_words: usize) -> bool {
debug_assert!(start_idx + num_words <= bitmap.len());
// First try AVX2 (256-bit, 4 words at a time)
#[cfg(target_feature = "avx2")]
unsafe {
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 4 words
while idx + 4 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads of at least 4 u64 words (32 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 4 <= end_idx to ensure we have 4 complete words
// - The unaligned _loadu_ variant is used to handle any alignment
let data_vec = _mm256_loadu_si256(bitmap[idx..].as_ptr() as *const __m256i);
// Check if any bits are non-zero
// Safety: _mm256_testz_si256 is safe when given valid __m256i values,
// which data_vec is guaranteed to be
if !_mm256_testz_si256(data_vec, data_vec) {
return false;
}
idx += 4;
}
// Handle remaining words with SSE2 or scalar ops
if idx < end_idx {
if idx + 2 <= end_idx {
// Use SSE2 for 2 words
// Safety:
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
let data_vec = _mm_loadu_si128(bitmap[idx..].as_ptr() as *const __m128i);
// Safety: _mm_testz_si128 is safe when given valid __m128i values
if std::arch::x86_64::_mm_testz_si128(data_vec, data_vec) == 0 {
return false;
}
idx += 2;
}
// Handle any remaining words
while idx < end_idx {
if bitmap[idx] != 0 {
return false;
}
idx += 1;
}
}
return true;
}
// If AVX2 is unavailable, try SSE2 (128-bit, 2 words at a time)
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
unsafe {
let mut idx = start_idx;
let end_idx = start_idx + num_words;
// Process aligned blocks of 2 words
while idx + 2 <= end_idx {
// Safety:
// - bitmap[idx..] is valid for reads of at least 2 u64 words (16 bytes)
// - We've validated with the debug_assert that start_idx + num_words <= bitmap.len()
// - We check that idx + 2 <= end_idx to ensure we have 2 complete words
// - The unaligned _loadu_ variant is used to handle any alignment
let data_vec = _mm_loadu_si128(bitmap[idx..].as_ptr() as *const __m128i);
// Check if any bits are non-zero (SSE4.1 would have _mm_testz_si128,
// but for SSE2 compatibility we need to use a different approach)
#[cfg(target_feature = "sse4.1")]
{
// Safety: _mm_testz_si128 is safe when given valid __m128i values
if std::arch::x86_64::_mm_testz_si128(data_vec, data_vec) == 0 {
return false;
}
}
#[cfg(not(target_feature = "sse4.1"))]
{
// Compare with zero vector using SSE2 only
// Safety: All operations are valid with the data_vec value
let zero_vec = _mm_setzero_si128();
let cmp = _mm_cmpeq_epi64(data_vec, zero_vec);
// The movemask gives us a bit for each byte, set if the high bit of the byte is set
// For all-zero comparison, all 16 bits should be set (0xFFFF)
let mask = _mm_movemask_epi8(cmp);
if mask != 0xFFFF {
return false;
}
}
idx += 2;
}
// Handle remaining word (if any)
if idx < end_idx && bitmap[idx] != 0 {
return false;
}
return true;
}
// Scalar fallback
bitmap[start_idx..(start_idx + num_words)]
.iter()
.all(|&word| word == 0)
}
#[inline(always)]
fn set_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
bitmap[word_idx] |= 1u64 << bit_pos;
}
#[inline(always)]
fn clear_bit(bitmap: &mut [u64], bit_idx: u64) {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
bitmap[word_idx] &= !(1u64 << bit_pos);
}
#[inline(always)]
fn check_bit(bitmap: &[u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
(bitmap[word_idx] & (1u64 << bit_pos)) != 0
}
}
/// Additional x86 optimized operations not covered by the trait
pub mod atomic {
use super::*;
/// Check and set bit, returning the previous state
/// This function is not actually atomic! It's just a non-atomic optimization
#[inline(always)]
pub fn check_and_set_bit(bitmap: &mut [u64], bit_idx: u64) -> bool {
let word_idx = (bit_idx / 64) as usize;
let bit_pos = bit_idx % 64;
let mask = 1u64 << bit_pos;
// Get old value
let old_word = bitmap[word_idx];
// Set bit regardless of current state
bitmap[word_idx] |= mask;
// Return true if bit was already set (duplicate)
(old_word & mask) != 0
}
/// Set multiple bits at once using SIMD when possible
///
/// # Safety
///
/// This function is unsafe because it:
/// - Uses SIMD intrinsics that require the AVX2 CPU feature to be available
/// - Accesses bitmap memory through raw pointers
/// - Does not perform bounds checking beyond what's required for SIMD operations
///
/// Caller must ensure:
/// - The AVX2 feature is available on the current CPU
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
/// - No other thread is concurrently modifying the same memory
#[inline(always)]
#[cfg(target_feature = "avx2")]
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
// Special case: all bits in the same word
if start_word == end_word {
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle using AVX2
let first_full_word = if start_bit.is_multiple_of(64) {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
if first_full_word <= last_full_word {
// Use AVX2 to set multiple words at once
// Safety: _mm256_set1_epi64x is safe to call with any i64 value
let ones = _mm256_set1_epi64x(-1); // All bits set to 1
let mut i = first_full_word;
while i + 4 <= last_full_word + 1 {
// Safety:
// - bitmap[i..] is valid for reads/writes of at least 4 u64 words (32 bytes)
// - We check that i + 4 <= last_full_word + 1 to ensure we have 4 complete words
// - The unaligned _loadu/_storeu variants are used to handle any alignment
let current = _mm256_loadu_si256(bitmap[i..].as_ptr() as *const __m256i);
let result = _mm256_or_si256(current, ones);
_mm256_storeu_si256(bitmap[i..].as_mut_ptr() as *mut __m256i, result);
i += 4;
}
// Use SSE2 for remaining pairs of words
if i + 2 <= last_full_word + 1 {
// Safety:
// - bitmap[i..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We check that i + 2 <= last_full_word + 1 to ensure we have 2 complete words
// - The unaligned _loadu/_storeu variants are used to handle any alignment
let sse_ones = _mm_set1_epi64x(-1);
let current = unsafe { _mm_loadu_si128(bitmap[i..].as_ptr() as *const __m128i) };
let result = _mm_or_si128(current, sse_ones);
_mm_storeu_si128(bitmap[i..].as_mut_ptr() as *mut __m128i, result);
i += 2;
}
// Handle any remaining words
while i <= last_full_word {
bitmap[i] = u64::MAX;
i += 1;
}
}
}
/// Set multiple bits at once using SSE2 (when AVX2 not available)
///
/// # Safety
///
/// This function is unsafe because it:
/// - Uses SIMD intrinsics that require the SSE2 CPU feature to be available
/// - Accesses bitmap memory through raw pointers
/// - Does not perform bounds checking beyond what's required for SIMD operations
///
/// Caller must ensure:
/// - The SSE2 feature is available on the current CPU
/// - `bitmap` has sufficient size to hold indices up to `end_bit/64`
/// - `start_bit` and `end_bit` are valid bit indices within the bitmap
/// - No other thread is concurrently modifying the same memory
#[inline(always)]
#[cfg(all(target_feature = "sse2", not(target_feature = "avx2")))]
pub unsafe fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
// Special case: all bits in the same word
if start_word == end_word {
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if !start_bit.is_multiple_of(64) {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if !(end_bit + 1).is_multiple_of(64) {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle using SSE2
let first_full_word = if start_bit.is_multiple_of(64) {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1).is_multiple_of(64) {
end_word
} else {
end_word - 1
};
if first_full_word <= last_full_word {
// Use SSE2 to set multiple words at once
// Safety: _mm_set1_epi64x is safe to call with any i64 value
let ones = unsafe { _mm_set1_epi64x(-1) }; // All bits set to 1
let mut i = first_full_word;
while i + 2 <= last_full_word + 1 {
// Safety:
// - bitmap[i..] is valid for reads/writes of at least 2 u64 words (16 bytes)
// - We check that i + 2 <= last_full_word + 1 to ensure we have 2 complete words
// - The unaligned _loadu/_storeu variants are used to handle any alignment
let current = unsafe { _mm_loadu_si128(bitmap[i..].as_ptr() as *const __m128i) };
let result = unsafe { _mm_or_si128(current, ones) };
unsafe { _mm_storeu_si128(bitmap[i..].as_mut_ptr() as *mut __m128i, result) };
i += 2;
}
// Handle any remaining words
while i <= last_full_word {
bitmap[i] = u64::MAX;
i += 1;
}
}
}
/// Set multiple bits at once using scalar operations (fallback)
#[inline(always)]
#[cfg(not(any(target_feature = "avx2", target_feature = "sse2")))]
pub fn set_bits_range(bitmap: &mut [u64], start_bit: u64, end_bit: u64) {
// Process whole words where possible
let start_word = (start_bit / 64) as usize;
let end_word = (end_bit / 64) as usize;
// Special case: all bits in the same word
if start_word == end_word {
let start_mask = u64::MAX << (start_bit % 64);
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[start_word] |= start_mask & end_mask;
return;
}
// Handle partial words at the beginning and end
if start_bit % 64 != 0 {
let start_mask = u64::MAX << (start_bit % 64);
bitmap[start_word] |= start_mask;
}
if (end_bit + 1) % 64 != 0 {
let end_mask = u64::MAX >> (63 - (end_bit % 64));
bitmap[end_word] |= end_mask;
}
// Handle complete words in the middle
let first_full_word = if start_bit.is_multiple_of(64) {
start_word
} else {
start_word + 1
};
let last_full_word = if (end_bit + 1) % 64 == 0 {
end_word
} else {
end_word - 1
};
for i in first_full_word..=last_full_word {
bitmap[i] = u64::MAX;
}
}
}
+876
View File
@@ -0,0 +1,876 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Replay protection validator implementation.
//!
//! This module implements the core replay protection logic using a bitmap-based
//! approach to track received packets and validate their sequence.
use crate::replay::error::{ReplayError, ReplayResult};
use crate::replay::simd::{self, BitmapOps};
// Determine the appropriate SIMD implementation at compile time
#[cfg(target_arch = "aarch64")]
#[cfg(target_feature = "neon")]
use crate::replay::simd::ArmBitmapOps as SimdImpl;
#[cfg(target_arch = "x86_64")]
#[cfg(target_feature = "avx2")]
use crate::replay::simd::X86BitmapOps as SimdImpl;
#[cfg(target_arch = "x86_64")]
#[cfg(all(not(target_feature = "avx2"), target_feature = "sse2"))]
use crate::replay::simd::X86BitmapOps as SimdImpl;
#[cfg(not(any(
all(target_arch = "x86_64", target_feature = "avx2"),
all(target_arch = "x86_64", target_feature = "sse2"),
all(target_arch = "aarch64", target_feature = "neon")
)))]
use crate::replay::simd::ScalarBitmapOps as SimdImpl;
/// Size of a word in the bitmap (64 bits)
const WORD_SIZE: usize = 64;
/// Number of words in the bitmap (allows reordering of 64*16 = 1024 packets)
const N_WORDS: usize = 16;
/// Total number of bits in the bitmap
const N_BITS: usize = WORD_SIZE * N_WORDS;
/// Validator for receiving key counters to prevent replay attacks.
///
/// This structure maintains a bitmap of received packets and validates
/// incoming packet counters to ensure they are not replayed.
#[derive(Debug, Clone, Default)]
pub struct ReceivingKeyCounterValidator {
/// Next expected counter value
next: u64,
/// Total number of received packets
receive_cnt: u64,
/// Bitmap for tracking received packets
bitmap: [u64; N_WORDS],
}
impl ReceivingKeyCounterValidator {
/// Creates a new validator with the given initial counter value.
pub fn new(initial_counter: u64) -> Self {
Self {
next: initial_counter,
receive_cnt: 0,
bitmap: [0; N_WORDS],
}
}
/// Sets a bit in the bitmap to mark a counter as received.
#[inline(always)]
fn set_bit(&mut self, idx: u64) {
SimdImpl::set_bit(&mut self.bitmap, idx % (N_BITS as u64));
}
/// Clears a bit in the bitmap.
#[inline(always)]
fn clear_bit(&mut self, idx: u64) {
SimdImpl::clear_bit(&mut self.bitmap, idx % (N_BITS as u64));
}
/// Clears the word that contains the given index.
#[inline(always)]
#[allow(dead_code)]
fn clear_word(&mut self, idx: u64) {
let bit_idx = idx % (N_BITS as u64);
let word = (bit_idx / (WORD_SIZE as u64)) as usize;
SimdImpl::clear_words(&mut self.bitmap, word, 1);
}
/// Returns true if the bit is set, false otherwise.
#[inline(always)]
fn check_bit_branchless(&self, idx: u64) -> bool {
SimdImpl::check_bit(&self.bitmap, idx % (N_BITS as u64))
}
/// Performs a quick check to determine if a counter will be accepted.
///
/// This is a fast check that can be done before more expensive operations.
///
/// Returns:
/// - `Ok(())` if the counter is acceptable
/// - `Err(ReplayError::InvalidCounter)` if the counter is invalid (too far back)
/// - `Err(ReplayError::DuplicateCounter)` if the counter has already been received
#[inline(always)]
pub fn will_accept_branchless(&self, counter: u64) -> ReplayResult<()> {
// Calculate conditions
let is_growing = counter >= self.next;
// Handle potential overflow when adding N_BITS to counter
let too_far_back = if counter > u64::MAX - (N_BITS as u64) {
// If adding N_BITS would overflow, it can't be too far back
false
} else {
counter + (N_BITS as u64) < self.next
};
let duplicate = self.check_bit_branchless(counter);
if is_growing {
Ok(())
} else if too_far_back {
Err(ReplayError::OutOfWindow)
} else if duplicate {
Err(ReplayError::DuplicateCounter)
} else {
Ok(())
}
}
/// Special case function for clearing the entire bitmap
/// Used for the fast path when we know the bitmap must be entirely cleared
#[inline(always)]
fn clear_window_fast(&mut self) {
SimdImpl::clear_words(&mut self.bitmap, 0, N_WORDS);
}
/// Checks if the bitmap is completely empty (all zeros)
/// This is used for fast path optimization
#[inline(always)]
fn is_bitmap_empty(&self) -> bool {
SimdImpl::is_range_zero(&self.bitmap, 0, N_WORDS)
}
/// Marks a counter as received and updates internal state.
///
/// This method should be called after a packet has been validated
/// and processed successfully.
///
/// Returns:
/// - `Ok(())` if the counter was successfully marked
/// - `Err(ReplayError::InvalidCounter)` if the counter is invalid (too far back)
/// - `Err(ReplayError::DuplicateCounter)` if the counter has already been received
#[inline(always)]
pub fn mark_did_receive_branchless(&mut self, counter: u64) -> ReplayResult<()> {
// Calculate conditions once - using saturating operations to prevent overflow
// For the too_far_back check, we need to avoid overflowing when adding N_BITS to counter
let too_far_back = if counter > u64::MAX - (N_BITS as u64) {
// If adding N_BITS would overflow, it can't be too far back
false
} else {
counter + (N_BITS as u64) < self.next
};
let is_sequential = counter == self.next;
let is_out_of_order = counter < self.next;
// Early return for out-of-window condition
if too_far_back {
return Err(ReplayError::OutOfWindow);
}
// Check for duplicate (only matters for out-of-order packets)
let duplicate = is_out_of_order && self.check_bit_branchless(counter);
if duplicate {
return Err(ReplayError::DuplicateCounter);
}
// Fast path for far ahead counters with empty bitmap
let far_ahead = counter.saturating_sub(self.next) >= (N_BITS as u64);
if far_ahead && self.is_bitmap_empty() {
// No need to clear anything, just set the new bit
self.set_bit(counter);
self.next = counter.saturating_add(1);
self.receive_cnt += 1;
return Ok(());
}
// Handle bitmap clearing for ahead counters that aren't sequential
if !is_sequential && !is_out_of_order {
self.clear_window(counter);
}
// Set the bit and update counters
self.set_bit(counter);
// Update next counter safely - avoid overflow
self.next = if is_sequential {
counter.saturating_add(1)
} else {
self.next.max(counter.saturating_add(1))
};
self.receive_cnt += 1;
Ok(())
}
/// Returns the current packet count statistics.
///
/// Returns a tuple of `(next, receive_cnt)` where:
/// - `next` is the next expected counter value
/// - `receive_cnt` is the total number of received packets
pub fn current_packet_cnt(&self) -> (u64, u64) {
(self.next, self.receive_cnt)
}
#[inline(always)]
#[allow(dead_code)]
fn check_and_set_bit_branchless(&mut self, idx: u64) -> bool {
let bit_idx = idx % (N_BITS as u64);
simd::atomic::check_and_set_bit(&mut self.bitmap, bit_idx)
}
#[inline(always)]
#[allow(dead_code)]
fn increment_counter_branchless(&mut self, condition: bool) {
// Add either 1 or 0 based on condition
self.receive_cnt += condition as u64;
}
#[inline(always)]
pub fn mark_sequential_branchless(&mut self, counter: u64) -> ReplayResult<()> {
// Check if sequential
let is_sequential = counter == self.next;
// Set the bit
self.set_bit(counter);
// Conditionally update next counter using saturating add to prevent overflow
self.next = self.next.saturating_add(is_sequential as u64);
// Always increment receive count if we got here
self.receive_cnt += 1;
Ok(())
}
// Helper function for window clearing with SIMD optimization
#[inline(always)]
fn clear_window(&mut self, counter: u64) {
// Handle potential overflow safely
// If counter is very large (close to u64::MAX), we need special handling
let counter_distance = counter.saturating_sub(self.next);
let far_ahead = counter_distance >= (N_BITS as u64);
// Fast path: Complete window clearing for far ahead counters
if far_ahead {
// Check if window is already clear for fast path optimization
if !self.is_bitmap_empty() {
// Use SIMD to clear the entire bitmap at once
self.clear_window_fast();
}
return;
}
// Prepare for partial window clearing
let mut i = self.next;
// Get SIMD processing width (platform optimized)
let simd_width = simd::optimal_simd_width();
// Pre-alignment clearing
if !i.is_multiple_of(WORD_SIZE as u64) {
let current_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
// Check if we need to clear this word
if self.bitmap[current_word] != 0 {
// Safely handle potential overflow by checking before each increment
while !i.is_multiple_of(WORD_SIZE as u64) && i < counter {
self.clear_bit(i);
// Prevent overflow on increment
if i == u64::MAX {
break;
}
i += 1;
}
} else {
// Fast forward to the next word boundary
let words_to_skip = (WORD_SIZE as u64) - (i % (WORD_SIZE as u64));
if words_to_skip > u64::MAX - i {
// Would overflow, just set to MAX
i = u64::MAX;
} else {
i += words_to_skip;
}
}
}
// Word-aligned clearing with SIMD where possible
while i <= counter.saturating_sub(WORD_SIZE as u64) {
let current_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
// Check if we have enough consecutive words to use SIMD
if current_word + simd_width <= N_WORDS
&& i.is_multiple_of(simd_width as u64 * WORD_SIZE as u64)
{
// Use SIMD to clear multiple words at once if any need clearing
let needs_clearing =
!SimdImpl::is_range_zero(&self.bitmap, current_word, simd_width);
if needs_clearing {
SimdImpl::clear_words(&mut self.bitmap, current_word, simd_width);
}
// Skip the words we just processed
let words_to_skip = simd_width as u64 * WORD_SIZE as u64;
if words_to_skip > u64::MAX - i {
i = u64::MAX;
break;
}
i += words_to_skip;
} else {
// Process single word
if self.bitmap[current_word] != 0 {
self.bitmap[current_word] = 0;
}
// Check for potential overflow before incrementing
if i > u64::MAX - (WORD_SIZE as u64) {
i = u64::MAX;
break;
}
i += WORD_SIZE as u64;
}
}
// Post-alignment clearing (bit by bit for remaining bits)
if i < counter {
let final_word = (i % (N_BITS as u64) / (WORD_SIZE as u64)) as usize;
let is_final_word_empty = self.bitmap[final_word] == 0;
// Skip clearing if word is already empty
if !is_final_word_empty {
while i < counter {
self.clear_bit(i);
// Prevent overflow on increment
if i == u64::MAX {
break;
}
i += 1;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_replay_counter_basic() {
let mut validator = ReceivingKeyCounterValidator::default();
// Check initial state
assert_eq!(validator.next, 0);
assert_eq!(validator.receive_cnt, 0);
// Test sequential counters
assert!(validator.mark_did_receive_branchless(0).is_ok());
assert!(validator.mark_did_receive_branchless(0).is_err());
assert!(validator.mark_did_receive_branchless(1).is_ok());
assert!(validator.mark_did_receive_branchless(1).is_err());
}
#[test]
fn test_replay_counter_out_of_order() {
let mut validator = ReceivingKeyCounterValidator::default();
// Process some sequential packets
assert!(validator.mark_did_receive_branchless(0).is_ok());
assert!(validator.mark_did_receive_branchless(1).is_ok());
assert!(validator.mark_did_receive_branchless(2).is_ok());
// Out-of-order packet that hasn't been seen yet
assert!(validator.mark_did_receive_branchless(1).is_err()); // Already seen
assert!(validator.mark_did_receive_branchless(10).is_ok()); // New packet, ahead of next
// Next should now be 11
assert_eq!(validator.next, 11);
// Can still accept packets in the valid window
assert!(validator.will_accept_branchless(9).is_ok());
assert!(validator.will_accept_branchless(8).is_ok());
// But duplicates are rejected
assert!(validator.will_accept_branchless(10).is_err());
}
#[test]
fn test_replay_counter_full() {
let mut validator = ReceivingKeyCounterValidator::default();
// Process a bunch of sequential packets
for i in 0..64 {
assert!(validator.mark_did_receive_branchless(i).is_ok());
assert!(validator.mark_did_receive_branchless(i).is_err());
}
// Test out of order within window
assert!(validator.mark_did_receive_branchless(15).is_err()); // Already seen
assert!(validator.mark_did_receive_branchless(63).is_err()); // Already seen
// Test for packets within bitmap range
for i in 64..(N_BITS as u64) + 128 {
assert!(validator.mark_did_receive_branchless(i).is_ok());
assert!(validator.mark_did_receive_branchless(i).is_err());
}
}
#[test]
fn test_replay_counter_window_sliding() {
let mut validator = ReceivingKeyCounterValidator::default();
// Jump far ahead to force window sliding
let far_ahead = (N_BITS as u64) * 3;
assert!(validator.mark_did_receive_branchless(far_ahead).is_ok());
// Everything too far back should be rejected
for i in 0..=(N_BITS as u64) * 2 {
assert!(matches!(
validator.will_accept_branchless(i),
Err(ReplayError::OutOfWindow)
));
assert!(validator.mark_did_receive_branchless(i).is_err());
}
// Values in window but less than far_ahead should be accepted
for i in (N_BITS as u64) * 2 + 1..far_ahead {
assert!(validator.will_accept_branchless(i).is_ok());
}
// The far_ahead value itself should be rejected now (duplicate)
assert!(matches!(
validator.will_accept_branchless(far_ahead),
Err(ReplayError::DuplicateCounter)
));
// Test receiving packets in reverse order within window
for i in ((N_BITS as u64) * 2 + 1..far_ahead).rev() {
assert!(validator.mark_did_receive_branchless(i).is_ok());
assert!(validator.mark_did_receive_branchless(i).is_err());
}
}
#[test]
fn test_out_of_order_tracking() {
let mut validator = ReceivingKeyCounterValidator::default();
// Jump ahead
assert!(validator.mark_did_receive_branchless(1000).is_ok());
// Test some more additions
assert!(validator.mark_did_receive_branchless(1000 + 70).is_ok());
assert!(validator.mark_did_receive_branchless(1000 + 71).is_ok());
assert!(validator.mark_did_receive_branchless(1000 + 72).is_ok());
assert!(
validator
.mark_did_receive_branchless(1000 + 72 + 125)
.is_ok()
);
assert!(validator.mark_did_receive_branchless(1000 + 63).is_ok());
// Check duplicates
assert!(validator.mark_did_receive_branchless(1000 + 70).is_err());
assert!(validator.mark_did_receive_branchless(1000 + 71).is_err());
assert!(validator.mark_did_receive_branchless(1000 + 72).is_err());
}
#[test]
fn test_counter_stats() {
let mut validator = ReceivingKeyCounterValidator::default();
// Initial state
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 0);
assert_eq!(count, 0);
// After receiving some packets
assert!(validator.mark_did_receive_branchless(0).is_ok());
assert!(validator.mark_did_receive_branchless(1).is_ok());
assert!(validator.mark_did_receive_branchless(2).is_ok());
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 3);
assert_eq!(count, 3);
// After an out of order packet
assert!(validator.mark_did_receive_branchless(10).is_ok());
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 11);
assert_eq!(count, 4);
// After a packet from the past (within window)
assert!(validator.mark_did_receive_branchless(5).is_ok());
let (next, count) = validator.current_packet_cnt();
assert_eq!(next, 11); // Next doesn't change
assert_eq!(count, 5); // Count increases
}
#[test]
fn test_window_boundary_edge_cases() {
let mut validator = ReceivingKeyCounterValidator::default();
// First process a sequence of packets
for i in 0..100 {
assert!(validator.mark_did_receive_branchless(i).is_ok());
}
// The window should now span from 100 to 100+N_BITS
// Test packet near the upper edge of the window
let upper_edge = 100 + (N_BITS as u64) - 1;
assert!(validator.will_accept_branchless(upper_edge).is_ok());
assert!(validator.mark_did_receive_branchless(upper_edge).is_ok());
// Test packet just outside the upper edge (should be accepted)
let just_outside_upper = 100 + (N_BITS as u64);
assert!(validator.will_accept_branchless(just_outside_upper).is_ok());
// Test packet near the lower edge of the window
let lower_edge = 100 + 1; // +1 because we've already processed 100
assert!(validator.will_accept_branchless(lower_edge).is_ok());
// Test packet just outside the lower edge (should be rejected)
if upper_edge >= (N_BITS as u64) * 2 {
// Only test this if we're far enough along to have a lower bound
let just_outside_lower = 100 - (N_BITS as u64);
assert!(matches!(
validator.will_accept_branchless(just_outside_lower),
Err(ReplayError::OutOfWindow)
));
}
}
#[test]
fn test_multiple_window_shifts() {
let mut validator = ReceivingKeyCounterValidator::default();
// First jump - process packet far ahead
let first_jump = 2000;
assert!(validator.mark_did_receive_branchless(first_jump).is_ok());
// Verify next counter is updated
let (next, _) = validator.current_packet_cnt();
assert_eq!(next, first_jump + 1);
// Second large jump, even further ahead
let second_jump = first_jump + 5000;
assert!(validator.mark_did_receive_branchless(second_jump).is_ok());
// Verify next counter is updated again
let (next, _) = validator.current_packet_cnt();
assert_eq!(next, second_jump + 1);
// Test packets within the new window
let mid_window = second_jump - 500;
assert!(validator.will_accept_branchless(mid_window).is_ok());
// Test packets outside the new window
let outside_window = first_jump + 100;
assert!(matches!(
validator.will_accept_branchless(outside_window),
Err(ReplayError::OutOfWindow)
));
}
#[test]
fn test_interleaved_packets_at_boundaries() {
let mut validator = ReceivingKeyCounterValidator::default();
// Jump ahead to establish a large window
let jump = 2000;
assert!(validator.mark_did_receive_branchless(jump).is_ok());
// Process a sequence at the upper boundary
for i in 0..10 {
let upper_packet = jump + 100 + i;
assert!(validator.mark_did_receive_branchless(upper_packet).is_ok());
}
// Process a sequence at the lower boundary
for i in 0..10 {
let lower_packet = jump - (N_BITS as u64) + 100 + i;
// These might fail if they're outside the window, that's ok
let _ = validator.mark_did_receive_branchless(lower_packet);
}
// Process alternating packets at both ends
for i in 0..5 {
let upper = jump + 200 + i;
let lower = jump - (N_BITS as u64) + 200 + i;
assert!(validator.will_accept_branchless(upper).is_ok());
let lower_result = validator.will_accept_branchless(lower);
// Lower might be accepted or rejected, depending on exactly where the window is
if lower_result.is_ok() {
assert!(validator.mark_did_receive_branchless(lower).is_ok());
}
assert!(validator.mark_did_receive_branchless(upper).is_ok());
}
}
#[test]
fn test_exact_window_size_with_full_bitmap() {
let mut validator = ReceivingKeyCounterValidator::default();
// Fill the entire bitmap with non-sequential packets
// This tests both window size and bitmap capacity
// Generate a random but reproducible pattern
let mut positions = Vec::new();
for i in 0..N_BITS {
positions.push((i * 7) % N_BITS);
}
// Mark packets in this pattern
for pos in &positions {
assert!(validator.mark_did_receive_branchless(*pos as u64).is_ok());
}
// Try to mark them again (should all fail as duplicates)
for pos in &positions {
assert!(matches!(
validator.mark_did_receive_branchless(*pos as u64),
Err(ReplayError::DuplicateCounter)
));
}
// Force window to slide
let far_ahead = (N_BITS as u64) * 2;
assert!(validator.mark_did_receive_branchless(far_ahead).is_ok());
// Old packets should now be outside the window
for pos in &positions {
if *pos as u64 + (N_BITS as u64) < far_ahead {
assert!(matches!(
validator.will_accept_branchless(*pos as u64),
Err(ReplayError::OutOfWindow)
));
}
}
}
use std::sync::{Arc, Barrier};
use std::thread;
#[test]
fn test_concurrent_access() {
let validator = Arc::new(std::sync::Mutex::new(
ReceivingKeyCounterValidator::default(),
));
let num_threads = 8;
let operations_per_thread = 1000;
let barrier = Arc::new(Barrier::new(num_threads));
// Create thread handles
let mut handles = vec![];
for thread_id in 0..num_threads {
let validator_clone = Arc::clone(&validator);
let barrier_clone = Arc::clone(&barrier);
let handle = thread::spawn(move || {
// Wait for all threads to be ready
barrier_clone.wait();
let mut successes = 0;
let mut duplicates = 0;
let mut out_of_window = 0;
for i in 0..operations_per_thread {
// Generate a somewhat random but reproducible counter value
// Different threads will sometimes try to insert the same value
let counter = (i * 7 + thread_id * 13) as u64;
let mut guard = validator_clone.lock().unwrap();
match guard.mark_did_receive_branchless(counter) {
Ok(()) => successes += 1,
Err(ReplayError::DuplicateCounter) => duplicates += 1,
Err(ReplayError::OutOfWindow) => out_of_window += 1,
_ => {}
}
}
(successes, duplicates, out_of_window)
});
handles.push(handle);
}
// Collect results
let mut total_successes = 0;
let mut total_duplicates = 0;
let mut total_out_of_window = 0;
for handle in handles {
let (successes, duplicates, out_of_window) = handle.join().unwrap();
total_successes += successes;
total_duplicates += duplicates;
total_out_of_window += out_of_window;
}
// Verify that all operations were accounted for
assert_eq!(
total_successes + total_duplicates + total_out_of_window,
num_threads * operations_per_thread
);
// Verify that some operations were successful and some were duplicates
assert!(total_successes > 0);
assert!(total_duplicates > 0);
// Check final state of the validator
let final_state = validator.lock().unwrap();
let (_next, receive_cnt) = final_state.current_packet_cnt();
// Verify that the received count matches our successful operations
assert_eq!(receive_cnt, total_successes as u64);
}
#[test]
fn test_memory_usage() {
use std::mem::{size_of, size_of_val};
// Test small validator
let validator_default = ReceivingKeyCounterValidator::default();
let size_default = size_of_val(&validator_default);
// Expected size calculation
let expected_size = size_of::<u64>() * 2 + // next + receive_cnt
size_of::<u64>() * N_WORDS; // bitmap
assert_eq!(size_default, expected_size);
println!("Default validator size: {} bytes", size_default);
// Memory efficiency calculation (bits tracked per byte of memory)
let bits_per_byte = N_BITS as f64 / size_default as f64;
println!(
"Memory efficiency: {:.2} bits tracked per byte of memory",
bits_per_byte
);
// Verify minimum memory needed for different window sizes
for window_size in [64usize, 128, 256, 512, 1024, 2048] {
let words_needed = window_size.div_ceil(WORD_SIZE);
let memory_needed = size_of::<u64>() * 2 + size_of::<u64>() * words_needed;
println!(
"Window size {}: {} bytes minimum",
window_size, memory_needed
);
}
}
#[test]
#[cfg(any(
target_feature = "sse2",
target_feature = "avx2",
target_feature = "neon"
))]
fn test_simd_operations() {
// This test verifies that SIMD-optimized operations would produce
// the same results as the scalar implementation
// Create a validator with a known state
let mut validator = ReceivingKeyCounterValidator::default();
// Fill bitmap with a pattern
for i in 0..64 {
validator.set_bit(i);
}
// Create a copy for comparison
let _original_bitmap = validator.bitmap;
// Simulate SIMD clear (4 words at a time)
#[cfg(target_feature = "avx2")]
{
use std::arch::x86_64::{_mm256_setzero_si256, _mm256_storeu_si256};
// Clear words 0-3 using AVX2
unsafe {
let zero_vec = _mm256_setzero_si256();
_mm256_storeu_si256(validator.bitmap.as_mut_ptr() as *mut _, zero_vec);
}
// Verify first 4 words are cleared
assert_eq!(validator.bitmap[0], 0);
assert_eq!(validator.bitmap[1], 0);
assert_eq!(validator.bitmap[2], 0);
assert_eq!(validator.bitmap[3], 0);
// Verify other words are unchanged
for i in 4..N_WORDS {
assert_eq!(validator.bitmap[i], _original_bitmap[i]);
}
}
#[cfg(target_feature = "sse2")]
{
use std::arch::x86_64::{_mm_setzero_si128, _mm_storeu_si128};
// Reset validator
validator.bitmap = _original_bitmap;
// Clear words 0-1 using SSE2
unsafe {
let zero_vec = _mm_setzero_si128();
_mm_storeu_si128(validator.bitmap.as_mut_ptr() as *mut _, zero_vec);
}
// Verify first 2 words are cleared
assert_eq!(validator.bitmap[0], 0);
assert_eq!(validator.bitmap[1], 0);
// Verify other words are unchanged
#[allow(clippy::needless_range_loop)]
for i in 2..N_WORDS {
assert_eq!(validator.bitmap[i], _original_bitmap[i]);
}
}
// No SIMD available, make this test a no-op
#[cfg(not(any(
target_feature = "sse2",
target_feature = "avx2",
target_feature = "neon"
)))]
{
println!("No SIMD features available, skipping SIMD test");
}
}
#[test]
fn test_clear_window_overflow() {
// Set a very large next value, close to u64::MAX
let mut validator = ReceivingKeyCounterValidator {
next: u64::MAX - 1000,
..Default::default()
};
// Try to clear window with an even higher counter
// This should exercise the potentially problematic code
let counter = u64::MAX - 500;
// Call clear_window directly (this is what we suspect has issues)
validator.clear_window(counter);
// If we got here without a panic, at least it's not crashing
// Let's verify the bitmap state is reasonable
let any_non_zero = validator.bitmap.iter().any(|&word| word != 0);
assert!(!any_non_zero, "Bitmap should be cleared");
// Try the full function which uses clear_window internally
assert!(validator.mark_did_receive_branchless(counter).is_ok());
// Verify it was marked
assert!(matches!(
validator.will_accept_branchless(counter),
Err(ReplayError::DuplicateCounter)
));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Session management for the Lewes Protocol.
//!
//! This module implements session lifecycle management functionality, handling
//! creation, retrieval, and storage of sessions.
use dashmap::DashMap;
use nym_crypto::asymmetric::{ed25519, x25519};
use std::sync::Arc;
use crate::noise_protocol::ReadResult;
use crate::state_machine::{LpAction, LpInput, LpState, LpStateBare};
use crate::{LpError, LpMessage, LpSession, LpStateMachine};
/// Manages the lifecycle of Lewes Protocol sessions.
///
/// The SessionManager is responsible for creating, storing, and retrieving sessions,
/// ensuring proper thread-safety for concurrent access.
pub struct SessionManager {
/// Manages state machines directly, keyed by lp_id
state_machines: DashMap<u32, LpStateMachine>,
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}
impl SessionManager {
/// Creates a new session manager with empty session storage.
pub fn new() -> Self {
Self {
state_machines: DashMap::new(),
}
}
pub fn process_input(&self, lp_id: u32, input: LpInput) -> Result<Option<LpAction>, LpError> {
self.with_state_machine_mut(lp_id, |sm| sm.process_input(input).transpose())?
}
pub fn add(&self, session: LpSession) -> Result<(), LpError> {
let sm = LpStateMachine {
state: LpState::ReadyToHandshake {
session: Box::new(session),
},
};
self.state_machines.insert(sm.id()?, sm);
Ok(())
}
pub fn handshaking(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Handshaking)
}
pub fn should_initiate_handshake(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.ready_to_handshake(lp_id)? || self.closed(lp_id)?)
}
pub fn ready_to_handshake(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::ReadyToHandshake)
}
pub fn closed(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Closed)
}
pub fn transport(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Transport)
}
#[cfg(test)]
fn get_state_machine_id(&self, lp_id: u32) -> Result<u32, LpError> {
self.with_state_machine(lp_id, |sm| sm.id())?
}
pub fn get_state(&self, lp_id: u32) -> Result<LpStateBare, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.bare_state()))?
}
pub fn receiving_counter_quick_check(&self, lp_id: u32, counter: u64) -> Result<(), LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?.receiving_counter_quick_check(counter)
})?
}
pub fn receiving_counter_mark(&self, lp_id: u32, counter: u64) -> Result<(), LpError> {
self.with_state_machine(lp_id, |sm| sm.session()?.receiving_counter_mark(counter))?
}
pub fn start_handshake(&self, lp_id: u32) -> Option<Result<LpMessage, LpError>> {
self.prepare_handshake_message(lp_id)
}
pub fn prepare_handshake_message(&self, lp_id: u32) -> Option<Result<LpMessage, LpError>> {
self.with_state_machine(lp_id, |sm| sm.session().ok()?.prepare_handshake_message())
.ok()?
}
pub fn is_handshake_complete(&self, lp_id: u32) -> Result<bool, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.is_handshake_complete()))?
}
pub fn next_counter(&self, lp_id: u32) -> Result<u64, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.next_counter()))?
}
pub fn decrypt_data(&self, lp_id: u32, message: &LpMessage) -> Result<Vec<u8>, LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?
.decrypt_data(message)
.map_err(LpError::NoiseError)
})?
}
pub fn encrypt_data(&self, lp_id: u32, message: &[u8]) -> Result<LpMessage, LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?
.encrypt_data(message)
.map_err(LpError::NoiseError)
})?
}
pub fn current_packet_cnt(&self, lp_id: u32) -> Result<(u64, u64), LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.current_packet_cnt()))?
}
pub fn process_handshake_message(
&self,
lp_id: u32,
message: &LpMessage,
) -> Result<ReadResult, LpError> {
self.with_state_machine(lp_id, |sm| sm.session()?.process_handshake_message(message))?
}
pub fn session_count(&self) -> usize {
self.state_machines.len()
}
pub fn state_machine_exists(&self, lp_id: u32) -> bool {
self.state_machines.contains_key(&lp_id)
}
pub fn with_state_machine<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
where
F: FnOnce(&LpStateMachine) -> R,
{
if let Some(sm) = self.state_machines.get(&lp_id) {
Ok(f(&sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
}
// self.state_machines.get(&lp_id).map(|sm_ref| f(&*sm_ref)) // Lock held only during closure execution
}
// For mutable access (like running process_input)
pub fn with_state_machine_mut<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
where
F: FnOnce(&mut LpStateMachine) -> R, // Closure takes mutable ref
{
if let Some(mut sm) = self.state_machines.get_mut(&lp_id) {
Ok(f(&mut sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
}
}
pub fn create_session_state_machine(
&self,
receiver_index: u32,
local_ed25519_keypair: Arc<ed25519::KeyPair>,
remote_ed25519_key: &ed25519::PublicKey,
remote_x25519_key: &x25519::PublicKey,
is_initiator: bool,
salt: &[u8; 32],
) -> Result<u32, LpError> {
let sm = LpStateMachine::new(
receiver_index,
is_initiator,
local_ed25519_keypair,
remote_ed25519_key,
remote_x25519_key,
salt,
)?;
self.state_machines.insert(receiver_index, sm);
Ok(receiver_index)
}
/// Method to remove a state machine
pub fn remove_state_machine(&self, lp_id: u32) -> bool {
let removed = self.state_machines.remove(&lp_id);
removed.is_some()
}
/// Test-only method to initialize KKT state to Completed for a session.
/// This allows integration tests to bypass KKT exchange and directly test PSQ/handshake.
#[cfg(test)]
pub fn init_kkt_for_test(
&self,
lp_id: u32,
remote_x25519_pub: &x25519::PublicKey,
) -> Result<(), LpError> {
self.with_state_machine(lp_id, |sm| {
sm.session()?.set_kkt_completed_for_test(remote_x25519_pub);
Ok(())
})?
}
}
#[cfg(test)]
mod tests {
use super::*;
use nym_crypto::asymmetric::ed25519;
#[test]
fn test_session_manager_get() {
let manager = SessionManager::new();
let ed25519_keypair = ed25519::KeyPair::from_secret([10u8; 32], 0);
let ed25519_keypair2 = ed25519::KeyPair::from_secret([16u8; 32], 0);
let x25519_keypair2 = ed25519_keypair2.to_x25519();
let salt = [47u8; 32];
let receiver_index: u32 = 1001;
let sm_1_id = manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair),
ed25519_keypair2.public_key(),
x25519_keypair2.public_key(),
true,
&salt,
)
.unwrap();
let retrieved = manager.state_machine_exists(sm_1_id);
assert!(retrieved);
let not_found = manager.state_machine_exists(99);
assert!(!not_found);
}
#[test]
fn test_session_manager_remove() {
let manager = SessionManager::new();
let ed25519_keypair = ed25519::KeyPair::from_secret([11u8; 32], 0);
let ed25519_keypair2 = ed25519::KeyPair::from_secret([16u8; 32], 0);
let x25519_keypair2 = ed25519_keypair2.to_x25519();
let salt = [48u8; 32];
let receiver_index: u32 = 2002;
let sm_1_id = manager
.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair),
ed25519_keypair2.public_key(),
x25519_keypair2.public_key(),
true,
&salt,
)
.unwrap();
let removed = manager.remove_state_machine(sm_1_id);
assert!(removed);
assert_eq!(manager.session_count(), 0);
let removed_again = manager.remove_state_machine(sm_1_id);
assert!(!removed_again);
}
#[test]
fn test_multiple_sessions() {
let manager = SessionManager::new();
let ed25519_keypair_1 = ed25519::KeyPair::from_secret([12u8; 32], 0);
let ed25519_keypair_2 = ed25519::KeyPair::from_secret([13u8; 32], 1);
let ed25519_keypair_3 = ed25519::KeyPair::from_secret([14u8; 32], 2);
let salt = [49u8; 32];
let pubkey1 = *ed25519_keypair_1.public_key();
let pubkey2 = *ed25519_keypair_2.public_key();
let pubkey3 = *ed25519_keypair_3.public_key();
let xpubkey1 = *ed25519_keypair_1.to_x25519().public_key();
let xpubkey2 = *ed25519_keypair_2.to_x25519().public_key();
let xpubkey3 = *ed25519_keypair_3.to_x25519().public_key();
let sm_1 = manager
.create_session_state_machine(
3001,
Arc::new(ed25519_keypair_1),
&pubkey2,
&xpubkey2,
true,
&salt,
)
.unwrap();
let sm_2 = manager
.create_session_state_machine(
3002,
Arc::new(ed25519_keypair_2),
&pubkey3,
&xpubkey3,
true,
&salt,
)
.unwrap();
let sm_3 = manager
.create_session_state_machine(
3003,
Arc::new(ed25519_keypair_3),
&pubkey1,
&xpubkey1,
true,
&salt,
)
.unwrap();
assert_eq!(manager.session_count(), 3);
let retrieved1 = manager.get_state_machine_id(sm_1).unwrap();
let retrieved2 = manager.get_state_machine_id(sm_2).unwrap();
let retrieved3 = manager.get_state_machine_id(sm_3).unwrap();
assert_eq!(retrieved1, sm_1);
assert_eq!(retrieved2, sm_2);
assert_eq!(retrieved3, sm_3);
}
#[test]
fn test_session_manager_create_session() {
let manager = SessionManager::new();
let ed25519_keypair = ed25519::KeyPair::from_secret([15u8; 32], 0);
let ed25519_keypair2 = ed25519::KeyPair::from_secret([16u8; 32], 0);
let salt = [50u8; 32];
let receiver_index: u32 = 4004;
let x25519_keypair2 = ed25519_keypair2.to_x25519();
let sm = manager.create_session_state_machine(
receiver_index,
Arc::new(ed25519_keypair),
ed25519_keypair2.public_key(),
x25519_keypair2.public_key(),
true,
&salt,
);
assert!(sm.is_ok());
let sm = sm.unwrap();
assert_eq!(manager.session_count(), 1);
let retrieved = manager.get_state_machine_id(sm);
assert!(retrieved.is_ok());
assert_eq!(retrieved.unwrap(), sm);
}
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -9,6 +9,7 @@ repository = { workspace = true }
[dependencies]
bytes = { workspace = true }
cfg-if = { workspace = true }
tokio-util = { workspace = true, features = ["codec"] }
thiserror = { workspace = true }
tracing = { workspace = true }
@@ -21,3 +22,7 @@ nym-sphinx-acknowledgements = { path = "../acknowledgements" }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
[features]
# When enabled, mix nodes skip ack extraction and forwarding
no-mix-acks = []
+24 -12
View File
@@ -14,7 +14,7 @@ use nym_sphinx_types::{
};
use std::fmt::Display;
use thiserror::Error;
use tracing::{debug, info, trace};
use tracing::{debug, trace};
#[derive(Debug)]
pub enum MixProcessingResultData {
@@ -364,21 +364,33 @@ fn split_into_ack_and_message(
| PacketSize::ExtendedPacket32
| PacketSize::OutfoxRegularPacket => {
trace!("received a normal packet!");
let (ack_data, message) = split_hop_data_into_ack_and_message(data, packet_type)?;
let (ack_first_hop, ack_packet) =
match SurbAck::try_recover_first_hop_packet(&ack_data, packet_type) {
Ok((first_hop, packet)) => (first_hop, packet),
Err(err) => {
info!("Failed to recover first hop from ack data: {err}");
return Err(err.into());
}
};
let forward_ack = MixPacket::new(ack_first_hop, ack_packet, packet_type, key_rotation);
Ok((Some(forward_ack), message))
cfg_if::cfg_if! {
if #[cfg(feature = "no-mix-acks")] {
let _ = packet_type;
let _ = key_rotation;
// AIDEV-NOTE: When no-mix-acks is enabled, skip ack extraction entirely.
// The full payload (including ack portion) is returned as the message.
Ok((None, data))
} else {
let (ack_data, message) = split_hop_data_into_ack_and_message(data, packet_type)?;
let (ack_first_hop, ack_packet) =
match SurbAck::try_recover_first_hop_packet(&ack_data, packet_type) {
Ok((first_hop, packet)) => (first_hop, packet),
Err(err) => {
tracing::info!("Failed to recover first hop from ack data: {err}");
return Err(err.into());
}
};
let forward_ack = MixPacket::new(ack_first_hop, ack_packet, packet_type, key_rotation);
Ok((Some(forward_ack), message))
}
}
}
}
}
#[allow(dead_code)]
fn split_hop_data_into_ack_and_message(
mut extracted_data: Vec<u8>,
packet_type: PacketType,
+8 -1
View File
@@ -12,10 +12,17 @@ license.workspace = true
workspace = true
[dependencies]
bincode = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tokio-util.workspace = true
serde.workspace = true
nym-authenticator-requests = { path = "../authenticator-requests" }
nym-credentials-interface = { path = "../credentials-interface" }
nym-crypto = { path = "../crypto" }
nym-ip-packet-requests = { path = "../ip-packet-requests" }
nym-sphinx = { path = "../nymsphinx" }
nym-wireguard-types = { path = "../wireguard-types" }
[dev-dependencies]
bincode.workspace = true
time.workspace = true
+10 -1
View File
@@ -1,13 +1,21 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use nym_authenticator_requests::AuthenticatorVersion;
use nym_crypto::asymmetric::x25519::{PublicKey, serde_helpers::bs58_x25519_pubkey};
use nym_ip_packet_requests::IpPair;
use nym_sphinx::addressing::{NodeIdentity, Recipient};
use serde::{Deserialize, Serialize};
mod lp_messages;
mod serialisation;
pub use lp_messages::{
LpGatewayData, LpRegistrationRequest, LpRegistrationResponse, RegistrationMode,
};
pub use serialisation::BincodeError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NymNode {
@@ -15,6 +23,7 @@ pub struct NymNode {
pub ip_address: IpAddr,
pub ipr_address: Option<Recipient>,
pub authenticator_address: Option<Recipient>,
pub lp_address: Option<SocketAddr>,
pub version: AuthenticatorVersion,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
+305
View File
@@ -0,0 +1,305 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! LP (Lewes Protocol) registration message types shared between client and gateway.
use nym_credentials_interface::{CredentialSpendingData, TicketType};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::GatewayData;
use crate::serialisation::{BincodeError, BincodeOptions, lp_bincode_serializer};
/// Registration request sent by client after LP handshake
/// Aligned with existing authenticator registration flow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpRegistrationRequest {
/// Client's WireGuard public key (for dVPN mode)
pub wg_public_key: nym_wireguard_types::PeerPublicKey,
/// Bandwidth credential for payment
pub credential: CredentialSpendingData,
/// Ticket type for bandwidth allocation
pub ticket_type: TicketType,
/// Registration mode
pub mode: RegistrationMode,
/// Client's IP address (for tracking/metrics)
pub client_ip: IpAddr,
/// Unix timestamp for replay protection
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RegistrationMode {
/// dVPN mode - register as WireGuard peer (most common)
Dvpn,
/// Mixnet mode - register for mixnet routing via IPR
///
/// Client provides identity and encryption keys for nym address derivation.
/// Gateway stores client in ActiveClientsStore for SURB reply delivery.
Mixnet {
/// Client's ed25519 public key (identity)
///
/// Used to derive DestinationAddressBytes for ActiveClientsStore lookup.
/// Must match the key used in LP handshake for authentication.
client_ed25519_pubkey: [u8; 32],
/// Client's x25519 public key (encryption)
///
/// Used for SURB reply encryption. Combined with ed25519 identity
/// and gateway identity to form the full nym Recipient address.
client_x25519_pubkey: [u8; 32],
},
}
/// Gateway data for mixnet mode registration
///
/// Contains the gateway's identity and sphinx key needed for the client
/// to construct its full nym Recipient address.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpGatewayData {
/// Gateway's ed25519 identity public key
///
/// Forms part of the client's nym Recipient address.
pub gateway_identity: [u8; 32],
/// Gateway's x25519 sphinx public key
///
/// Used by the client for Sphinx packet construction.
pub gateway_sphinx_key: [u8; 32],
}
/// Registration response from gateway
/// Contains GatewayData for compatibility with existing client code
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpRegistrationResponse {
/// Whether registration succeeded
pub success: bool,
/// Error message if registration failed
pub error: Option<String>,
/// Gateway configuration data for dVPN mode (WireGuard)
/// This matches what WireguardRegistrationResult expects
pub gateway_data: Option<GatewayData>,
/// Gateway data for mixnet mode
///
/// Contains gateway identity and sphinx key needed for nym address construction.
/// Only populated for Mixnet mode registrations.
pub lp_gateway_data: Option<LpGatewayData>,
/// Allocated bandwidth in bytes
pub allocated_bandwidth: i64,
}
impl LpRegistrationRequest {
/// Create a new dVPN registration request
pub fn new_dvpn(
wg_public_key: nym_wireguard_types::PeerPublicKey,
credential: CredentialSpendingData,
ticket_type: TicketType,
client_ip: IpAddr,
) -> Self {
Self {
wg_public_key,
credential,
ticket_type,
mode: RegistrationMode::Dvpn,
client_ip,
#[allow(clippy::expect_used)]
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_secs(),
}
}
/// Validate the request timestamp is within acceptable bounds
pub fn validate_timestamp(&self, max_skew_secs: u64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
(now as i64 - self.timestamp as i64).abs() <= max_skew_secs as i64
}
/// Attempt to serialise this `LpRegistrationRequest` into bytes.
pub fn serialise(&self) -> Result<Vec<u8>, BincodeError> {
lp_bincode_serializer().serialize(self)
}
/// Attempt to deserialise a `LpRegistrationRequest` from bytes.
pub fn try_deserialise(b: &[u8]) -> Result<Self, BincodeError> {
lp_bincode_serializer().deserialize(b)
}
}
impl LpRegistrationResponse {
/// Create a success response with GatewayData (for dVPN mode)
pub fn success(allocated_bandwidth: i64, gateway_data: GatewayData) -> Self {
Self {
success: true,
error: None,
gateway_data: Some(gateway_data),
lp_gateway_data: None,
allocated_bandwidth,
}
}
/// Create a success response for mixnet mode with LpGatewayData
pub fn success_mixnet(allocated_bandwidth: i64, lp_gateway_data: LpGatewayData) -> Self {
Self {
success: true,
error: None,
gateway_data: None,
lp_gateway_data: Some(lp_gateway_data),
allocated_bandwidth,
}
}
/// Create an error response
pub fn error(error: String) -> Self {
Self {
success: false,
error: Some(error),
gateway_data: None,
lp_gateway_data: None,
allocated_bandwidth: 0,
}
}
/// Attempt to serialise this `LpRegistrationResponse` into bytes.
pub fn serialise(&self) -> Result<Vec<u8>, BincodeError> {
lp_bincode_serializer().serialize(self)
}
/// Attempt to deserialise a `LpRegistrationResponse` from bytes.
pub fn try_deserialise(b: &[u8]) -> Result<Self, BincodeError> {
lp_bincode_serializer().deserialize(b)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
// ==================== Helper Functions ====================
fn create_test_gateway_data() -> GatewayData {
use std::net::Ipv6Addr;
GatewayData {
public_key: nym_crypto::asymmetric::x25519::PublicKey::from(
nym_sphinx::PublicKey::from([1u8; 32]),
),
private_ipv4: Ipv4Addr::new(10, 0, 0, 1),
private_ipv6: Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1),
endpoint: "192.168.1.1:8080".parse().expect("Valid test endpoint"),
}
}
// ==================== LpRegistrationRequest Tests ====================
// ==================== LpRegistrationResponse Tests ====================
#[test]
fn test_lp_registration_response_success() {
let gateway_data = create_test_gateway_data();
let allocated_bandwidth = 1_000_000_000;
let response = LpRegistrationResponse::success(allocated_bandwidth, gateway_data.clone());
assert!(response.success);
assert!(response.error.is_none());
assert!(response.gateway_data.is_some());
assert_eq!(response.allocated_bandwidth, allocated_bandwidth);
let returned_gw_data = response
.gateway_data
.expect("Gateway data should be present in success response");
assert_eq!(returned_gw_data.public_key, gateway_data.public_key);
assert_eq!(returned_gw_data.private_ipv4, gateway_data.private_ipv4);
assert_eq!(returned_gw_data.private_ipv6, gateway_data.private_ipv6);
assert_eq!(returned_gw_data.endpoint, gateway_data.endpoint);
}
#[test]
fn test_lp_registration_response_error() {
let error_msg = String::from("Insufficient bandwidth");
let response = LpRegistrationResponse::error(error_msg.clone());
assert!(!response.success);
assert_eq!(response.error, Some(error_msg));
assert!(response.gateway_data.is_none());
assert_eq!(response.allocated_bandwidth, 0);
}
// ==================== RegistrationMode Tests ====================
#[test]
fn test_registration_mode_serialize_dvpn() {
let mode = RegistrationMode::Dvpn;
let serialized = bincode::serialize(&mode).expect("Failed to serialize mode");
let deserialized: RegistrationMode =
bincode::deserialize(&serialized).expect("Failed to deserialize mode");
assert!(matches!(deserialized, RegistrationMode::Dvpn));
}
#[test]
fn test_registration_mode_serialize_mixnet() {
let client_ed25519_pubkey = [99u8; 32];
let client_x25519_pubkey = [88u8; 32];
let mode = RegistrationMode::Mixnet {
client_ed25519_pubkey,
client_x25519_pubkey,
};
let serialized = bincode::serialize(&mode).expect("Failed to serialize mode");
let deserialized: RegistrationMode =
bincode::deserialize(&serialized).expect("Failed to deserialize mode");
match deserialized {
RegistrationMode::Mixnet {
client_ed25519_pubkey: ed25519,
client_x25519_pubkey: x25519,
} => {
assert_eq!(ed25519, client_ed25519_pubkey);
assert_eq!(x25519, client_x25519_pubkey);
}
_ => panic!("Expected Mixnet mode"),
}
}
#[test]
fn test_lp_registration_response_success_mixnet() {
let lp_gateway_data = LpGatewayData {
gateway_identity: [1u8; 32],
gateway_sphinx_key: [2u8; 32],
};
let allocated_bandwidth = 500_000_000;
let response = LpRegistrationResponse::success_mixnet(allocated_bandwidth, lp_gateway_data);
assert!(response.success);
assert!(response.error.is_none());
assert!(response.gateway_data.is_none());
assert!(response.lp_gateway_data.is_some());
assert_eq!(response.allocated_bandwidth, allocated_bandwidth);
let gw_data = response
.lp_gateway_data
.expect("LpGatewayData should be present");
assert_eq!(gw_data.gateway_identity, [1u8; 32]);
assert_eq!(gw_data.gateway_sphinx_key, [2u8; 32]);
}
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use bincode::Options;
pub use bincode::Error as BincodeError;
pub use bincode::Options as BincodeOptions;
/// Create explicit bincode options for consistent serialization across versions.
///
/// Using explicit options future-proofs against bincode 1.x/2.x default changes.
pub fn lp_bincode_serializer() -> impl BincodeOptions {
bincode::DefaultOptions::new()
.with_big_endian()
.with_varint_encoding()
}
+3
View File
@@ -15,6 +15,9 @@ anyhow = { workspace = true }
futures = { workspace = true }
rand_chacha = { workspace = true }
tokio = { workspace = true, features = ["sync", "time", "rt"] }
tracing = { workspace = true }
nym-bin-common = { path = "../bin-common", features = ["tracing"] }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
+19
View File
@@ -8,6 +8,12 @@ use std::future::Future;
use tokio::task::JoinHandle;
use tokio::time::error::Elapsed;
use nym_bin_common::logging::tracing_subscriber::EnvFilter;
use nym_bin_common::logging::tracing_subscriber::layer::SubscriberExt;
use nym_bin_common::logging::tracing_subscriber::util::SubscriberInitExt;
use nym_bin_common::logging::{default_tracing_fmt_layer, tracing_subscriber};
pub use rand_chacha::rand_core::{CryptoRng, RngCore};
pub fn leak<T>(val: T) -> &'static mut T {
Box::leak(Box::new(val))
}
@@ -31,3 +37,16 @@ pub fn seeded_rng(seed: [u8; 32]) -> ChaCha20Rng {
pub fn u64_seeded_rng(seed: u64) -> ChaCha20Rng {
ChaCha20Rng::seed_from_u64(seed)
}
// test logger to use during debugging
#[allow(clippy::unwrap_used)]
pub fn setup_test_logger() {
tracing_subscriber::registry()
.with(default_tracing_fmt_layer(std::io::stderr))
.with(
EnvFilter::new("trace"),
// .add_directive("nym_sdk::client_pool=info".parse().unwrap())
// .add_directive("nym_sdk::tcp_proxy_client=debug".parse().unwrap()),
)
.init();
}
@@ -3,10 +3,32 @@
use crate::mocks::shared::InnerWrapper;
use futures::ready;
use std::fmt::{Display, Formatter};
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tracing::trace;
const INIT_ID: &str = "initialiser";
const RECV_ID: &str = "recipient";
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Side {
Initialiser,
Recipient,
}
impl Display for Side {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Side::Initialiser => INIT_ID.fmt(f),
Side::Recipient => RECV_ID.fmt(f),
}
}
}
// sending buffer of the first stream is the receiving buffer of the second stream
// and vice versa
@@ -17,8 +39,13 @@ pub fn mock_io_streams() -> (MockIOStream, MockIOStream) {
(ch1, ch2)
}
#[derive(Default)]
pub struct MockIOStream {
// identifier to use for logging purposes
id: Arc<AtomicU8>,
// side of the stream to use for logging purposes
side: Side,
// messages to send
tx: InnerWrapper<Vec<u8>>,
@@ -26,14 +53,41 @@ pub struct MockIOStream {
rx: InnerWrapper<Vec<u8>>,
}
impl MockIOStream {
fn make_connection(&self) -> Self {
impl Default for MockIOStream {
fn default() -> Self {
MockIOStream {
id: Arc::new(AtomicU8::new(0)),
side: Side::Initialiser,
tx: Default::default(),
rx: Default::default(),
}
}
}
impl MockIOStream {
#[allow(clippy::panic)]
fn make_connection(&self) -> Self {
if self.side != Side::Initialiser {
panic!("attempted to make invalid connection")
}
MockIOStream {
id: self.id.clone(),
side: Side::Recipient,
tx: self.rx.cloned_buffer(),
rx: self.tx.cloned_buffer(),
}
}
pub fn set_id(&self, id: u8) {
self.id.store(id, Ordering::Relaxed)
}
// the prefix `try_` is due to the fact that if the mock is cloned at an invalid state,
// `assert!` will fail causing panic (which is fine in **test** code)
pub fn try_get_remote_handle(&self) -> Self {
self.make_connection()
}
// unwrap in test code is fine
#[allow(clippy::unwrap_used)]
pub fn unchecked_tx_data(&self) -> Vec<u8> {
@@ -45,6 +99,25 @@ impl MockIOStream {
pub fn unchecked_rx_data(&self) -> Vec<u8> {
self.rx.buffer.try_lock().unwrap().content.clone()
}
fn log_read(&self, bytes: usize) {
let id = self.id.load(Ordering::Relaxed);
if id == 0 {
trace!("[{}] read {bytes} bytes from mock stream", self.side)
} else {
trace!("[{}-{id}] read {bytes} bytes from mock stream", self.side)
}
}
fn log_write(&self, bytes: usize) {
let id = self.id.load(Ordering::Relaxed);
if id == 0 {
trace!("[{}] wrote {bytes} bytes to mock stream", self.side)
} else {
trace!("[{}-{id}] wrote {bytes} bytes to mock stream", self.side)
}
}
}
impl AsyncRead for MockIOStream {
@@ -55,11 +128,13 @@ impl AsyncRead for MockIOStream {
) -> Poll<io::Result<()>> {
ready!(Pin::new(&mut self.rx).poll_guard_ready(cx));
let unfilled = buf.remaining();
// SAFETY: guard is ready
#[allow(clippy::unwrap_used)]
let guard = self.rx.guard().unwrap();
let data = guard.take_content();
let data = guard.take_at_most(unfilled);
if data.is_empty() {
// nothing to retrieve - store the waiter so that the sender could trigger it
guard.waker = Some(cx.waker().clone());
@@ -69,6 +144,7 @@ impl AsyncRead for MockIOStream {
return Poll::Pending;
}
self.log_read(data.len());
// if let Some(waker) = guard.waker.take() {
// waker.wake();
// }
@@ -105,6 +181,8 @@ impl AsyncWrite for MockIOStream {
// return Poll::Pending;
// }
self.log_write(buf.len());
Poll::Ready(Ok(len))
}
+43
View File
@@ -106,4 +106,47 @@ impl<T> ContentWrapper<T> {
}
}
impl ContentWrapper<Vec<u8>> {
pub fn take_at_most(&mut self, count: usize) -> Vec<u8> {
if self.content.is_empty() {
return Vec::new();
}
if self.content.len() <= count {
return self.take_content();
}
let remaining = self.content.split_off(count);
mem::replace(&mut self.content, remaining)
}
}
impl<T> LockState<T> {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn take_at_most() {
let mut empty: ContentWrapper<Vec<u8>> = ContentWrapper::default();
let mut non_empty: ContentWrapper<Vec<u8>> = ContentWrapper {
content: vec![1, 2, 3, 4, 5],
..Default::default()
};
assert_eq!(empty.take_at_most(0), Vec::<u8>::new());
assert_eq!(empty.take_at_most(1), Vec::<u8>::new());
assert_eq!(empty.take_at_most(42), Vec::<u8>::new());
assert_eq!(non_empty.take_at_most(0), Vec::<u8>::new());
assert_eq!(non_empty.take_at_most(1), vec![1]);
assert_eq!(non_empty.take_at_most(3), vec![2, 3, 4]);
assert_eq!(non_empty.take_at_most(42), vec![5]);
let mut non_empty: ContentWrapper<Vec<u8>> = ContentWrapper {
content: vec![1, 2, 3, 4, 5],
..Default::default()
};
assert_eq!(non_empty.take_at_most(100), vec![1, 2, 3, 4, 5]);
}
}

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