Compare commits

...

32 Commits

Author SHA1 Message Date
Jędrzej Stuczyński 0b97d2bf93 wip 2026-03-06 15:37:49 +00:00
Jędrzej Stuczyński 16928a48a9 bugfix: setting correct LpPeerConfig during handshake 2026-03-06 10:18:45 +00:00
Jędrzej Stuczyński 24891adddf additional wiring of nested connections control 2026-03-05 16:07:50 +00:00
Jędrzej Stuczyński 6a42a8dd49 chore: add unit test for mutual KKT 2026-03-05 10:56:26 +00:00
Jędrzej Stuczyński bc0c2e5d19 basic node<>node KKT 2026-03-05 10:38:13 +00:00
Jędrzej Stuczyński 225178f95a reorganise control structure 2026-03-04 16:58:49 +00:00
Jędrzej Stuczyński 32cfb3fff8 basic node LP handler 2026-03-04 15:59:40 +00:00
Jędrzej Stuczyński f62a74a6af wip 2026-03-04 13:48:11 +00:00
Jędrzej Stuczyński fe9275274c scaffolding persistent gateway connections 2026-03-04 13:47:53 +00:00
Jędrzej Stuczyński 5cee248122 Merge pull request #6513 from nymtech/bugfix/lp-psqv2-review-comments
addressing LP PR comments
2026-03-04 13:41:51 +00:00
Jędrzej Stuczyński 86aec84697 fixed handshake retry 2026-03-04 13:21:18 +00:00
Jędrzej Stuczyński 8f376d1b9b additional explanation for DH keys 2026-03-04 10:25:28 +00:00
Jędrzej Stuczyński f0ae4f4090 removed retry on credential spend 2026-03-04 09:54:57 +00:00
Jędrzej Stuczyński 4e850f6fe0 random clippy 2026-03-04 09:33:30 +00:00
Jędrzej Stuczyński bd3678dd4f bump up MSRV 2026-03-04 09:33:30 +00:00
Jędrzej Stuczyński 28c1637198 addressing LP PR comments 2026-03-04 09:33:28 +00:00
Jędrzej Stuczyński 8de574ec97 Merge pull request #6512 from nymtech/lp/remove-state-machine-states
remove redundant LP state machine in favour of in place processing
2026-03-04 09:33:02 +00:00
Jędrzej Stuczyński 4464d12103 clippy and review comments 2026-03-04 09:26:29 +00:00
Jędrzej Stuczyński 0d9d97e31e remove redundant LP state machine in favour of in place processing 2026-03-03 16:20:27 +00:00
Jędrzej Stuczyński a7705a5f2c Merge pull request #6511 from nymtech/merge/release/2026.5-raclette
Merge/release/2026.5 raclette
2026-03-03 14:53:19 +00:00
Jędrzej Stuczyński 7a300bdd74 Merge branch 'develop' into merge/release/2026.5-raclette 2026-03-03 14:45:20 +00:00
Jędrzej Stuczyński 6569479083 feat: introduce /v3/unstable/nym-nodes/semi-skimmed to aggregate LP information (#6499)
* feat: introduce /v3/unstable/nym-nodes/semi-skimmed to aggregate LP information

nym-nodes will require this information to establish shared PSQ

* reorganised imports
2026-03-03 14:05:02 +00:00
Jędrzej Stuczyński 611844b248 feat: enable mutual KKT exchange (#6505)
* feat: enable mutual KKT exchange

* use unwrap_or_default
2026-03-03 14:01:39 +00:00
Jędrzej Stuczyński 2cc9b05520 chore: split up lp listener (#6507)
* chore: split up lp listener

* rename 'build_lp'
2026-03-03 13:59:48 +00:00
Jędrzej Stuczyński 05b6f5e282 removed redundant LP states (#6509) 2026-03-03 13:58:47 +00:00
Merve a450b6f984 [DOCs/operators]: Typo corrections (#6502)
* docs typos fixed

* Fix typos

---------

Co-authored-by: Quinn <e@E-MacBook-Air.local>
2026-03-02 12:16:53 +00:00
benedettadavico 5093450004 bump versions 2026-03-02 10:44:54 +01:00
Jędrzej Stuczyński f6bd511599 feat: Lewes Protocol with PSQv2 (#6491)
* merging georgio/lp-psqv2-integration

* use authenicator on the responder's side

* nym-lp crate compiling

* moved the e2e test to nym-lp

* move key generation to peer

* moved principal generation

* update KKTResponder

* encapsulation key parsing

* Adding concrete types within KKT exchange

* initiator side of the full handshake

* responder side of the handshake and full e2e test

* fixed unit-tests within nym-kkt

* LpSession cleanup

* helpers for Transport

* revamp of the transport traits and initial work on client-side transport

* compiling nym-crypto

* 'working' client-entry dvpn reg

* Fix key conversion

* Slightly reduce use of rand08

* reverted back to libcrux repo refs

* intial telescoping reg

* removing dead code

* wip

* moved data encryption into the state machine

* restoring nym-lp tests

* update lp api model

* Add receiver index derivation

* Add receiver index derivation

* use derived receiver index

* feat: add kem key generation to nodes

* generate fresh x25519, mlkem768 and mceliece keys on config migration

* add lp peer config

* nym-node startup cleanup

* removed dependency on pre-rand09 from nym-lp

* re-expose LP information on the http API

* fixed tests compilation

* add peer config happy path tests

* formatting

* add more tests and fix bug

* better docs

* clippy and formatting issues

* return error on mceliece within NestedSession

* wasm fixes

* removed legacy nym-vpn-lib-wasm

* fixing wasm for real this time

* additional fixes

* add payload to kkt

* make clippy happy

* moved LP to nym-node crate

* cargo fmt

* integrate lpconfig payload

* fix response size trait impl

* Migrate receiver index

* Change receiver index to u32 and regorganize crates

* clippy

* hopefully final wasm fixes

* simple conversion method from semver to ciphersuite

* updated nym-node config template

* chore: remove duplicated code

---------

Co-authored-by: Georgio Nicolas <me@georgio.xyz>
2026-02-27 13:49:08 +00:00
benedetta davico e5c3f39a57 Merge pull request #6498 from nymtech/master
Merge pull request #6481 from nymtech/release/2026.4-quark
2026-02-27 11:13:58 +01:00
Merve 76f999fc88 {DOCs/operators]: Platform release docs and changelog + docs cleanup (#6482)
* changelog-updates

* Update changelog.mdx

* Update changelog.mdx

* Edits per reviewer request

* fixes

* fixes

* typo fixed

* removed outdated info

* Update docs based on reviewer feedback

* Update changelog.mdx

---------

Co-authored-by: merve <e@E-MacBook-Air.local>
2026-02-27 10:10:16 +00:00
dependabot[bot] 2fce8c7ca3 build(deps): bump qs and express in /wasm/client/internal-dev (#6461)
Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `qs` from 6.13.0 to 6.14.2
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.14.2)

Updates `express` from 4.21.2 to 4.22.1
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/v4.22.1/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.2...v4.22.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.2
  dependency-type: indirect
- dependency-name: express
  dependency-version: 4.22.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 14:20:59 +00:00
benedetta davico 4ed9d8fb7a Merge pull request #6481 from nymtech/release/2026.4-quark
Quark to master
2026-02-25 08:53:45 +01:00
225 changed files with 13576 additions and 18235 deletions
@@ -1,42 +0,0 @@
name: ci-build-vpn-api-wasm
on:
pull_request:
paths:
- 'common/**'
- 'nym-credential-proxy/**'
- '.github/workflows/ci-build-vpn-api-wasm.yml'
jobs:
wasm:
runs-on: arc-linux-latest
env:
CARGO_TERM_COLOR: always
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Check out repository code
uses: actions/checkout@v6
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ vars.REQUIRED_RUSTC_VERSION }}
target: wasm32-unknown-unknown
override: true
components: rustfmt, clippy
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Install wasm-opt
uses: ./.github/actions/install-wasm-opt
with:
version: '116'
- name: Install wasm-bindgen-cli
run: cargo install wasm-bindgen-cli
- name: "Build"
run: make
working-directory: nym-credential-proxy/vpn-api-lib-wasm
Generated
+130 -135
View File
@@ -1351,10 +1351,11 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "classic-mceliece-rust"
version = "3.2.0"
source = "git+https://github.com/georgio/classic-mceliece-rust#f2f27048b621df103bbe64369a18174ffec04ae1"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62a9b6d27e553269a76625911aa8cf6afaa8659f1b0c85b410cb5f51a87183d9"
dependencies = [
"rand 0.9.2",
"rand 0.8.5",
"sha3",
"zeroize",
]
@@ -1563,11 +1564,11 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-models"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.5"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"hax-lib",
"pastey",
"pastey 0.2.1",
"rand 0.9.2",
]
@@ -3307,9 +3308,9 @@ dependencies = [
[[package]]
name = "hax-lib"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86"
checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc"
dependencies = [
"hax-lib-macros",
"num-bigint",
@@ -3318,9 +3319,9 @@ dependencies = [
[[package]]
name = "hax-lib-macros"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1"
checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f"
dependencies = [
"hax-lib-macros-types",
"proc-macro-error2",
@@ -3331,9 +3332,9 @@ dependencies = [
[[package]]
name = "hax-lib-macros-types"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515"
checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2"
dependencies = [
"proc-macro2",
"quote",
@@ -4120,8 +4121,10 @@ dependencies = [
"nym-credential-verification",
"nym-credentials-interface",
"nym-crypto",
"nym-gateway",
"nym-lp-transport",
"nym-kkt",
"nym-kkt-ciphersuite",
"nym-lp",
"nym-node",
"nym-registration-client",
"nym-test-utils",
"nym-wireguard",
@@ -4450,10 +4453,21 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libcrux-aesgcm"
version = "0.0.7"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-intrinsics",
"libcrux-platform",
"libcrux-secrets",
"libcrux-traits",
]
[[package]]
name = "libcrux-chacha20poly1305"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4464,8 +4478,8 @@ dependencies = [
[[package]]
name = "libcrux-curve25519"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4475,8 +4489,8 @@ dependencies = [
[[package]]
name = "libcrux-ecdh"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-curve25519",
"libcrux-p256",
@@ -4486,8 +4500,8 @@ dependencies = [
[[package]]
name = "libcrux-ed25519"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4499,15 +4513,15 @@ dependencies = [
[[package]]
name = "libcrux-hacl-rs"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-macros",
]
[[package]]
name = "libcrux-hkdf"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-hmac",
@@ -4516,8 +4530,8 @@ dependencies = [
[[package]]
name = "libcrux-hmac"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4526,8 +4540,8 @@ dependencies = [
[[package]]
name = "libcrux-intrinsics"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"core-models",
"hax-lib",
@@ -4535,8 +4549,8 @@ dependencies = [
[[package]]
name = "libcrux-kem"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-curve25519",
"libcrux-ecdh",
@@ -4551,16 +4565,30 @@ dependencies = [
[[package]]
name = "libcrux-macros"
version = "0.0.3"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"quote",
"syn 2.0.106",
]
[[package]]
name = "libcrux-ml-dsa"
version = "0.0.7"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"core-models",
"hax-lib",
"libcrux-intrinsics",
"libcrux-macros",
"libcrux-platform",
"libcrux-sha3",
"tls_codec",
]
[[package]]
name = "libcrux-ml-kem"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.7"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"hax-lib",
"libcrux-intrinsics",
@@ -4574,8 +4602,8 @@ dependencies = [
[[package]]
name = "libcrux-p256"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4586,8 +4614,8 @@ dependencies = [
[[package]]
name = "libcrux-platform"
version = "0.0.2"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.3"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libc",
]
@@ -4595,7 +4623,7 @@ dependencies = [
[[package]]
name = "libcrux-poly1305"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4603,34 +4631,38 @@ dependencies = [
[[package]]
name = "libcrux-psq"
version = "0.0.5"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.7"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"classic-mceliece-rust",
"libcrux-aesgcm",
"libcrux-chacha20poly1305",
"libcrux-ecdh",
"libcrux-ed25519",
"libcrux-hkdf",
"libcrux-hmac",
"libcrux-kem",
"libcrux-ml-dsa",
"libcrux-ml-kem",
"libcrux-sha2",
"libcrux-traits",
"rand 0.8.5",
"rand 0.9.2",
"tls_codec",
]
[[package]]
name = "libcrux-secrets"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.5"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"hax-lib",
]
[[package]]
name = "libcrux-sha2"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-hacl-rs",
"libcrux-macros",
@@ -4639,8 +4671,8 @@ dependencies = [
[[package]]
name = "libcrux-sha3"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.7"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"hax-lib",
"libcrux-intrinsics",
@@ -4650,8 +4682,8 @@ dependencies = [
[[package]]
name = "libcrux-traits"
version = "0.0.4"
source = "git+https://github.com/cryspen/libcrux#f63bb67ead59297560edf523a3b29b21489c17ea"
version = "0.0.6"
source = "git+https://github.com/cryspen/libcrux?rev=b17f8687b67cdcfc10b55aeecc998bbbca28f775#b17f8687b67cdcfc10b55aeecc998bbbca28f775"
dependencies = [
"libcrux-secrets",
"rand 0.9.2",
@@ -5075,7 +5107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3176f18d11a1ae46053e59ec89d46ba318ae1343615bd3f8c908bfc84edae35c"
dependencies = [
"byteorder",
"pastey",
"pastey 0.1.1",
"thiserror 2.0.12",
]
@@ -5314,7 +5346,7 @@ dependencies = [
[[package]]
name = "nym-api"
version = "1.1.74"
version = "1.1.75"
dependencies = [
"anyhow",
"async-trait",
@@ -5422,7 +5454,6 @@ dependencies = [
"nym-serde-helpers",
"nym-test-utils",
"nym-ticketbooks-merkle",
"rand_chacha 0.3.1",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -5560,7 +5591,7 @@ dependencies = [
[[package]]
name = "nym-cli"
version = "1.1.71"
version = "1.1.72"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -5643,7 +5674,7 @@ dependencies = [
[[package]]
name = "nym-client"
version = "1.1.71"
version = "1.1.72"
dependencies = [
"bs58",
"clap",
@@ -5689,6 +5720,7 @@ dependencies = [
"clap",
"comfy-table",
"futures",
"getrandom 0.3.3",
"gloo-timers",
"http-body-util",
"humantime",
@@ -6192,10 +6224,13 @@ dependencies = [
"hkdf",
"hmac",
"jwt-simple",
"libcrux-curve25519",
"libcrux-psq",
"nym-pemstore",
"nym-sphinx-types",
"nym-test-utils",
"rand 0.8.5",
"rand 0.9.2",
"rand_chacha 0.3.1",
"serde",
"serde_bytes",
@@ -6357,7 +6392,6 @@ dependencies = [
"bincode",
"bip39",
"bs58",
"bytes",
"dashmap",
"defguard_wireguard_rs",
"fastrand",
@@ -6377,9 +6411,7 @@ dependencies = [
"nym-gateway-storage",
"nym-id",
"nym-ip-packet-router",
"nym-kcp",
"nym-lp",
"nym-lp-transport",
"nym-metrics",
"nym-mixnet-client",
"nym-network-defaults",
@@ -6486,6 +6518,7 @@ dependencies = [
"nym-validator-client",
"pnet_packet",
"rand 0.8.5",
"rand 0.9.2",
"reqwest 0.13.1",
"serde",
"serde_json",
@@ -6790,15 +6823,17 @@ name = "nym-kkt"
version = "0.1.0"
dependencies = [
"anyhow",
"blake3",
"classic-mceliece-rust",
"criterion",
"libcrux-chacha20poly1305",
"libcrux-ecdh",
"libcrux-kem",
"libcrux-ml-kem",
"libcrux-psq",
"num_enum",
"nym-crypto",
"nym-kkt-ciphersuite",
"nym-kkt-context",
"nym-pemstore",
"nym-test-utils",
"rand 0.9.2",
"rand_chacha 0.9.0",
"strum",
@@ -6813,11 +6848,21 @@ dependencies = [
"blake3",
"libcrux-sha3",
"num_enum",
"semver 1.0.26",
"strum",
"strum_macros",
"thiserror 2.0.12",
]
[[package]]
name = "nym-kkt-context"
version = "1.20.4"
dependencies = [
"num_enum",
"nym-kkt-ciphersuite",
"thiserror 2.0.12",
]
[[package]]
name = "nym-ledger"
version = "1.20.4"
@@ -6831,30 +6876,19 @@ dependencies = [
[[package]]
name = "nym-lp"
version = "0.1.0"
version = "1.20.4"
dependencies = [
"anyhow",
"bs58",
"bytes",
"chacha20poly1305",
"criterion",
"dashmap",
"libcrux-kem",
"libcrux-psq",
"libcrux-traits",
"mock_instant",
"num_enum",
"nym-crypto",
"nym-kkt",
"nym-lp-common",
"nym-lp-transport",
"nym-kkt-ciphersuite",
"nym-test-utils",
"parking_lot",
"rand 0.8.5",
"rand 0.9.2",
"serde",
"sha2 0.10.9",
"snow",
"thiserror 2.0.12",
"tls_codec",
"tokio",
@@ -6886,6 +6920,7 @@ dependencies = [
"nym-topology",
"nym-validator-client",
"rand 0.8.5",
"rand 0.9.2",
"rand_chacha 0.3.1",
"serde",
"serde_json",
@@ -6897,19 +6932,6 @@ dependencies = [
"url",
]
[[package]]
name = "nym-lp-common"
version = "0.1.0"
[[package]]
name = "nym-lp-transport"
version = "0.1.0"
dependencies = [
"nym-test-utils",
"tokio",
"tracing",
]
[[package]]
name = "nym-metrics"
version = "1.20.4"
@@ -7055,7 +7077,7 @@ dependencies = [
[[package]]
name = "nym-network-requester"
version = "1.1.72"
version = "1.1.73"
dependencies = [
"addr",
"anyhow",
@@ -7105,13 +7127,14 @@ dependencies = [
[[package]]
name = "nym-node"
version = "1.26.0"
version = "1.27.0"
dependencies = [
"anyhow",
"arc-swap",
"arrayref",
"async-trait",
"axum 0.7.9",
"bincode",
"bip39",
"blake2 0.8.1",
"bloomfilter",
@@ -7126,6 +7149,7 @@ dependencies = [
"criterion",
"csv",
"cupid",
"dashmap",
"futures",
"hex",
"hkdf",
@@ -7145,6 +7169,7 @@ dependencies = [
"nym-http-api-common",
"nym-ip-packet-router",
"nym-kkt",
"nym-lp",
"nym-metrics",
"nym-mixnet-client",
"nym-network-requester",
@@ -7154,6 +7179,7 @@ dependencies = [
"nym-noise-keys",
"nym-nonexhaustive-delayqueue",
"nym-pemstore",
"nym-registration-common",
"nym-sphinx-acknowledgements",
"nym-sphinx-addressing",
"nym-sphinx-forwarding",
@@ -7163,6 +7189,7 @@ dependencies = [
"nym-sphinx-types",
"nym-statistics-common",
"nym-task",
"nym-test-utils",
"nym-topology",
"nym-types",
"nym-validator-client",
@@ -7172,7 +7199,7 @@ dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rand 0.9.2",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -7220,9 +7247,9 @@ dependencies = [
"nym-http-api-client",
"nym-kkt-ciphersuite",
"nym-noise-keys",
"nym-test-utils",
"nym-upgrade-mode-check",
"nym-wireguard-types",
"rand_chacha 0.3.1",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -7445,12 +7472,7 @@ dependencies = [
"blake3",
"chacha20",
"chacha20poly1305",
"criterion",
"fastrand",
"getrandom 0.2.16",
"log",
"rand 0.8.5",
"rayon",
"sphinx-packet",
"thiserror 2.0.12",
"x25519-dalek",
@@ -7496,6 +7518,7 @@ dependencies = [
name = "nym-registration-client"
version = "1.20.4"
dependencies = [
"bincode",
"bytes",
"futures",
"nym-authenticator-client",
@@ -7504,19 +7527,19 @@ dependencies = [
"nym-credentials-interface",
"nym-crypto",
"nym-ip-packet-client",
"nym-kkt",
"nym-lp",
"nym-lp-transport",
"nym-registration-common",
"nym-sdk",
"nym-test-utils",
"nym-validator-client",
"nym-wireguard-types",
"rand 0.8.5",
"rand 0.9.2",
"thiserror 2.0.12",
"tokio",
"tokio-util",
"tracing",
"typed-builder",
"url",
]
[[package]]
@@ -7650,7 +7673,7 @@ dependencies = [
[[package]]
name = "nym-socks5-client"
version = "1.1.71"
version = "1.1.72"
dependencies = [
"bs58",
"clap",
@@ -8018,6 +8041,7 @@ dependencies = [
"futures",
"nym-bin-common",
"rand_chacha 0.3.1",
"rand_chacha 0.9.0",
"tokio",
"tracing",
]
@@ -8251,31 +8275,6 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "nym-vpn-api-lib-wasm"
version = "1.20.4"
dependencies = [
"bs58",
"getrandom 0.2.16",
"js-sys",
"nym-bin-common",
"nym-compact-ecash",
"nym-credential-proxy-requests",
"nym-credentials",
"nym-credentials-interface",
"nym-crypto",
"nym-ecash-time",
"nym-wasm-utils",
"serde",
"serde-wasm-bindgen 0.6.5",
"serde_json",
"thiserror 2.0.12",
"time",
"tsify",
"wasm-bindgen",
"zeroize",
]
[[package]]
name = "nym-wallet-types"
version = "1.0.0"
@@ -8335,9 +8334,7 @@ name = "nym-wasm-storage"
version = "1.20.4"
dependencies = [
"async-trait",
"getrandom 0.2.16",
"indexed_db_futures",
"js-sys",
"nym-store-cipher",
"nym-wasm-utils",
"serde",
@@ -8473,7 +8470,7 @@ dependencies = [
[[package]]
name = "nymvisor"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"anyhow",
"bytes",
@@ -8829,6 +8826,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "peg"
version = "0.8.5"
@@ -14094,22 +14097,14 @@ dependencies = [
name = "zknym-lib"
version = "1.20.4"
dependencies = [
"anyhow",
"async-trait",
"bs58",
"getrandom 0.2.16",
"js-sys",
"nym-bin-common",
"nym-compact-ecash",
"nym-credentials",
"nym-crypto",
"nym-http-api-client",
"nym-wasm-utils",
"rand 0.8.5",
"reqwest 0.13.1",
"serde",
"thiserror 2.0.12",
"tokio",
"tsify",
"uuid",
"wasm-bindgen",
+21 -6
View File
@@ -74,7 +74,6 @@ members = [
"common/nym-id",
"common/nym-kcp",
"common/nym-lp",
"common/nym-lp-common",
"common/nym-kkt",
"common/nym-metrics",
"common/nym_offline_compact_ecash",
@@ -129,7 +128,6 @@ members = [
"nym-browser-extension/storage",
"nym-credential-proxy/nym-credential-proxy",
"nym-credential-proxy/nym-credential-proxy-requests",
"nym-credential-proxy/vpn-api-lib-wasm",
"nym-data-observatory",
"nym-ip-packet-client",
"nym-network-monitor",
@@ -173,8 +171,9 @@ members = [
"wasm/mix-fetch",
"wasm/node-tester",
"wasm/zknym-lib",
"nym-gateway-probe",
"integration-tests", "common/nym-lp-transport", "common/nym-kkt-ciphersuite",
# "nym-gateway-probe",
"integration-tests",
"common/nym-kkt-ciphersuite", "common/nym-kkt-context",
]
default-members = [
@@ -203,7 +202,7 @@ homepage = "https://nymtech.net"
documentation = "https://nymtech.net"
edition = "2024"
license = "Apache-2.0"
rust-version = "1.85"
rust-version = "1.87.0"
readme = "README.md"
version = "1.20.4"
@@ -274,6 +273,7 @@ futures = "0.3.31"
futures-util = "0.3"
generic-array = "0.14.7"
getrandom = "0.2.10"
getrandom03 = { package = "getrandom", version = "=0.3.3" }
glob = "0.3"
handlebars = "3.5.5"
hex = "0.4.3"
@@ -324,6 +324,7 @@ quote = "1"
rand = "0.8.5"
rand09 = { package = "rand", version = "=0.9.2" }
rand_chacha = "0.3"
rand_chacha09 = { package = "rand_chacha", version = "=0.9.0" }
rand_core = "0.6.3"
rand_distr = "0.4"
rayon = "1.5.1"
@@ -392,6 +393,17 @@ zeroize = "1.7.0"
prometheus = { version = "0.14.0" }
# libcrux
libcrux-kem = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-ecdh = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-curve25519 = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-chacha20poly1305 = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-psq = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-ml-kem = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-sha3 = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
libcrux-traits = { git = "https://github.com/cryspen/libcrux", rev = "b17f8687b67cdcfc10b55aeecc998bbbca28f775" }
# Workspace dep definitions required by crates.io publication - we need a workspace version since `cargo workspaces` doesn't work with path imports from crate manifests
nym-api-requests = { version = "1.20.4", path = "nym-api/nym-api-requests" }
nym-authenticator-requests = { version = "1.20.4", path = "common/authenticator-requests" }
@@ -436,7 +448,10 @@ nym-http-api-common = { version = "1.20.4", path = "common/http-api-common", def
nym-id = { version = "1.20.4", path = "common/nym-id" }
nym-ip-packet-client = { version = "1.20.4", path = "nym-ip-packet-client" }
nym-ip-packet-requests = { version = "1.20.4", path = "common/ip-packet-requests" }
nym-kkt-ciphersuite = { path = "common/nym-kkt-ciphersuite" }
nym-lp = { version = "1.20.4", path = "common/nym-lp" }
nym-kkt = { version = "0.1.0", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.20.4", path = "common/nym-kkt-ciphersuite" }
nym-kkt-context = { version = "1.20.4", path = "common/nym-kkt-context" }
nym-metrics = { version = "1.20.4", path = "common/nym-metrics" }
nym-mixnet-client = { version = "1.20.4", path = "common/client-libs/mixnet-client" }
nym-mixnet-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/mixnet-contract" }
+5 -4
View File
@@ -104,11 +104,11 @@ $(eval $(call add_cargo_workspace,wallet,nym-wallet))
sdk-wasm: sdk-wasm-build sdk-wasm-test sdk-wasm-lint
sdk-wasm-build:
$(MAKE) -C nym-browser-extension/storage wasm-pack
# $(MAKE) -C nym-browser-extension/storage wasm-pack
$(MAKE) -C wasm/client
$(MAKE) -C wasm/node-tester
$(MAKE) -C wasm/mix-fetch
$(MAKE) -C wasm/zknym-lib
# $(MAKE) -C wasm/zknym-lib
# $(MAKE) -C wasm/full-nym-wasm
# run this from npm/yarn to ensure tools are in the path, e.g. yarn build:sdk from root of repo
@@ -119,13 +119,14 @@ sdk-typescript-build:
yarn --cwd sdk/typescript/codegen/contract-clients build
# NOTE: These targets are part of the main workspace (but not as wasm32-unknown-unknown)
WASM_CRATES = extension-storage nym-client-wasm nym-node-tester-wasm zknym-lib
# WASM_CRATES = extension-storage nym-client-wasm nym-node-tester-wasm zknym-lib
WASM_CRATES = nym-client-wasm nym-node-tester-wasm
sdk-wasm-test:
#cargo test $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings
sdk-wasm-lint:
cargo clippy $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo clippy $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings
$(MAKE) -C wasm/mix-fetch check-fmt
# Add to top-level targets
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-client"
version = "1.1.71"
version = "1.1.72"
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.71"
version = "1.1.72"
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"
+4
View File
@@ -121,6 +121,10 @@ features = ["wasm-bindgen"]
workspace = true
features = ["full"]
[target."cfg(target_arch = \"wasm32\")".dependencies.getrandom03]
workspace = true
features = ["wasm_js"]
[dev-dependencies]
tempfile = { workspace = true }
@@ -15,3 +15,13 @@ pub(crate) fn get_time_now() -> Instant {
pub(crate) fn new_interval_stream(polling_rate: Duration) -> IntervalStream {
gloo_timers::future::IntervalStream::new(polling_rate.as_millis() as u32)
}
#[unsafe(no_mangle)]
unsafe extern "Rust" fn __getrandom_v03_custom(
dest: *mut u8,
len: usize,
) -> Result<(), getrandom03::Error> {
let _ = dest;
let _ = len;
Err(getrandom03::Error::UNSUPPORTED)
}
@@ -20,10 +20,10 @@ use nym_api_requests::ecash::{
};
use nym_api_requests::models::{
ApiHealthResponse, GatewayCoreStatusResponse, HistoricalPerformanceResponse,
MixnodeCoreStatusResponse, NymNodeDescriptionV1,
MixnodeCoreStatusResponse, NymNodeDescriptionV1, NymNodeDescriptionV2,
};
use nym_api_requests::nym_nodes::{
NodesByAddressesResponse, SemiSkimmedNodesWithMetadata, SkimmedNode, SkimmedNodesWithMetadata,
NodesByAddressesResponse, SemiSkimmedNodesWithMetadata, SkimmedNodeV1, SkimmedNodesWithMetadata,
};
use nym_coconut_dkg_common::types::EpochId;
use nym_http_api_client::UserAgent;
@@ -273,18 +273,18 @@ impl<C, S> Client<C, S> {
Ok(history)
}
// #[deprecated(note = "use get_all_cached_described_nodes_v2 instead")]
#[deprecated(note = "use get_all_cached_described_nodes_v2 instead")]
pub async fn get_all_cached_described_nodes(
&self,
) -> Result<Vec<NymNodeDescriptionV1>, ValidatorClientError> {
Ok(self.nym_api.get_all_described_nodes().await?)
}
// pub async fn get_all_cached_described_nodes_v2(
// &self,
// ) -> Result<Vec<NymNodeDescriptionV2>, ValidatorClientError> {
// Ok(self.nym_api.get_all_described_nodes_v2().await?)
// }
pub async fn get_all_cached_described_nodes_v2(
&self,
) -> Result<Vec<NymNodeDescriptionV2>, ValidatorClientError> {
Ok(self.nym_api.get_all_described_nodes_v2().await?)
}
pub async fn get_all_cached_bonded_nym_nodes(
&self,
@@ -354,12 +354,12 @@ impl NymApiClient {
}
#[deprecated(note = "use get_all_basic_active_mixing_assigned_nodes instead")]
pub async fn get_basic_mixnodes(&self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
pub async fn get_basic_mixnodes(&self) -> Result<Vec<SkimmedNodeV1>, ValidatorClientError> {
Ok(self.nym_api.get_basic_mixnodes().await?.nodes)
}
#[deprecated(note = "use get_all_basic_entry_assigned_nodes instead")]
pub async fn get_basic_gateways(&self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
pub async fn get_basic_gateways(&self) -> Result<Vec<SkimmedNodeV1>, ValidatorClientError> {
Ok(self.nym_api.get_basic_gateways().await?.nodes)
}
@@ -372,7 +372,7 @@ impl NymApiClient {
#[deprecated(note = "use get_all_basic_entry_assigned_nodes_with_metadata instead")]
pub async fn get_all_basic_entry_assigned_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
) -> Result<Vec<SkimmedNodeV1>, ValidatorClientError> {
self.get_all_basic_entry_assigned_nodes_with_metadata()
.await
.map(|res| res.nodes)
@@ -389,7 +389,7 @@ impl NymApiClient {
#[deprecated(note = "use get_all_basic_active_mixing_assigned_nodes_with_metadata instead")]
pub async fn get_all_basic_active_mixing_assigned_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
) -> Result<Vec<SkimmedNodeV1>, ValidatorClientError> {
self.get_all_basic_active_mixing_assigned_nodes_with_metadata()
.await
.map(|res| res.nodes)
@@ -406,7 +406,7 @@ impl NymApiClient {
#[deprecated(note = "use get_all_basic_mixing_capable_nodes_with_metadata instead")]
pub async fn get_all_basic_mixing_capable_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
) -> Result<Vec<SkimmedNodeV1>, ValidatorClientError> {
self.get_all_basic_mixing_capable_nodes_with_metadata()
.await
.map(|res| res.nodes)
@@ -420,7 +420,7 @@ impl NymApiClient {
/// retrieve basic information for all bonded nodes on the network
#[deprecated(note = "use get_all_basic_nodes_with_metadata instead")]
pub async fn get_all_basic_nodes(&self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
pub async fn get_all_basic_nodes(&self) -> Result<Vec<SkimmedNodeV1>, ValidatorClientError> {
self.get_all_basic_nodes_with_metadata()
.await
.map(|res| res.nodes)
@@ -473,7 +473,7 @@ impl NymApiClient {
Ok(self.nym_api.health().await?)
}
// #[deprecated(note = "use .get_all_described_nodes_v2 instead")]
#[deprecated(note = "use .get_all_described_nodes_v2 instead")]
pub async fn get_all_described_nodes(
&self,
) -> Result<Vec<NymNodeDescriptionV1>, ValidatorClientError> {
@@ -495,29 +495,29 @@ impl NymApiClient {
Ok(descriptions)
}
// pub async fn get_all_described_nodes_v2(
// &self,
// ) -> Result<Vec<NymNodeDescriptionV2>, ValidatorClientError> {
// // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
// let mut page = 0;
// let mut descriptions = Vec::new();
//
// loop {
// let mut res = self
// .nym_api
// .get_nodes_described_v2(Some(page), None)
// .await?;
//
// descriptions.append(&mut res.data);
// if descriptions.len() < res.pagination.total {
// page += 1
// } else {
// break;
// }
// }
//
// Ok(descriptions)
// }
pub async fn get_all_described_nodes_v2(
&self,
) -> Result<Vec<NymNodeDescriptionV2>, ValidatorClientError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
let mut descriptions = Vec::new();
loop {
let mut res = self
.nym_api
.get_nodes_described_v2(Some(page), None)
.await?;
descriptions.append(&mut res.data);
if descriptions.len() < res.pagination.total {
page += 1
} else {
break;
}
}
Ok(descriptions)
}
pub async fn get_all_bonded_nym_nodes(
&self,
@@ -4,6 +4,7 @@
use crate::nym_api::error::NymAPIError;
use crate::nym_api::routes::{ecash, CORE_STATUS_COUNT, SINCE_ARG};
use crate::nym_nodes::SkimmedNodesWithMetadata;
use crate::ValidatorClientError;
use async_trait::async_trait;
use nym_api_requests::ecash::models::{
AggregatedCoinIndicesSignatureResponse, AggregatedExpirationDateSignatureResponse,
@@ -17,14 +18,17 @@ use nym_api_requests::ecash::VerificationKeyResponse;
use nym_api_requests::models::{
AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainBlocksStatusResponse,
ChainStatusResponse, KeyRotationInfoResponse, NodePerformanceResponse, NodeRefreshBody,
NymNodeDescriptionV1, PerformanceHistoryResponse, RewardedSetResponse,
NymNodeDescriptionV1, NymNodeDescriptionV2, PerformanceHistoryResponse, RewardedSetResponse,
SignerInformationResponse,
};
use nym_api_requests::nym_nodes::{
NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponseV1,
PaginatedCachedNodesResponseV2,
};
use nym_api_requests::pagination::PaginatedResponse;
use nym_http_api_client::{ApiClient, NO_PARAMS};
use nym_mixnet_contract_common::{IdentityKeyRef, NodeId, NymNodeDetails};
use std::net::IpAddr;
use time::format_description::BorrowedFormatItem;
use time::Date;
use tracing::instrument;
pub use nym_api_requests::{
ecash::{
models::SpentCredentialsResponse, BlindSignRequestBody, BlindedSignatureResponse,
@@ -36,17 +40,14 @@ pub use nym_api_requests::{
MixnodeCoreStatusResponse, MixnodeStatusReportResponse, MixnodeStatusResponse,
MixnodeUptimeHistoryResponse, StakeSaturationResponse, UptimeResponse,
},
nym_nodes::{CachedNodesResponse, SemiSkimmedNode, SemiSkimmedNodesWithMetadata, SkimmedNode},
nym_nodes::{
CachedNodesResponse, NodesByAddressesRequestBody, NodesByAddressesResponse,
PaginatedCachedNodesResponseV1, PaginatedCachedNodesResponseV2, SemiSkimmedNodeV1,
SemiSkimmedNodeV3, SemiSkimmedNodesWithMetadata, SkimmedNodeV1,
},
NymNetworkDetailsResponse,
};
use nym_http_api_client::{ApiClient, NO_PARAMS};
use nym_mixnet_contract_common::{IdentityKeyRef, NodeId, NymNodeDetails};
use std::net::IpAddr;
use time::format_description::BorrowedFormatItem;
use time::Date;
use tracing::instrument;
use crate::ValidatorClientError;
pub use nym_coconut_dkg_common::types::EpochId;
pub mod error;
@@ -117,7 +118,7 @@ pub trait NymApiClientExt: ApiClient {
}
#[tracing::instrument(level = "debug", skip_all)]
// #[deprecated(note = "use .get_nodes_described_v2 instead")]
#[deprecated(note = "use .get_nodes_described_v2 instead")]
async fn get_nodes_described(
&self,
page: Option<u32>,
@@ -144,32 +145,32 @@ pub trait NymApiClientExt: ApiClient {
.await
}
// #[tracing::instrument(level = "debug", skip_all)]
// async fn get_nodes_described_v2(
// &self,
// page: Option<u32>,
// per_page: Option<u32>,
// ) -> Result<PaginatedResponse<NymNodeDescriptionV2>, NymAPIError> {
// let mut params = Vec::new();
//
// if let Some(page) = page {
// params.push(("page", page.to_string()))
// }
//
// if let Some(per_page) = per_page {
// params.push(("per_page", per_page.to_string()))
// }
//
// self.get_json(
// &[
// routes::V2_API_VERSION,
// routes::NYM_NODES_ROUTES,
// routes::NYM_NODES_DESCRIBED,
// ],
// &params,
// )
// .await
// }
#[tracing::instrument(level = "debug", skip_all)]
async fn get_nodes_described_v2(
&self,
page: Option<u32>,
per_page: Option<u32>,
) -> Result<PaginatedResponse<NymNodeDescriptionV2>, NymAPIError> {
let mut params = Vec::new();
if let Some(page) = page {
params.push(("page", page.to_string()))
}
if let Some(per_page) = per_page {
params.push(("per_page", per_page.to_string()))
}
self.get_json(
&[
routes::V2_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_DESCRIBED,
],
&params,
)
.await
}
async fn get_current_rewarded_set(&self) -> Result<RewardedSetResponse, NymAPIError> {
self.get_rewarded_set().await
@@ -302,8 +303,8 @@ pub trait NymApiClientExt: ApiClient {
Ok(SkimmedNodesWithMetadata::new(nodes, metadata))
}
// #[deprecated(note = "use .get_all_described_nodes_v2 instead")]
// #[allow(deprecated)]
#[deprecated(note = "use .get_all_described_nodes_v2 instead")]
#[allow(deprecated)]
async fn get_all_described_nodes(&self) -> Result<Vec<NymNodeDescriptionV1>, NymAPIError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
@@ -323,24 +324,24 @@ pub trait NymApiClientExt: ApiClient {
Ok(descriptions)
}
// async fn (&self) -> Result<Vec<NymNodeDescriptionV2>, NymAPIError> {
// // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
// let mut page = 0;
// let mut descriptions = Vec::new();
//
// loop {
// let mut res = self.get_nodes_described_v2(Some(page), None).await?;
//
// descriptions.append(&mut res.data);
// if descriptions.len() < res.pagination.total {
// page += 1
// } else {
// break;
// }
// }
//
// Ok(descriptions)
// }
async fn get_all_described_nodes_v2(&self) -> Result<Vec<NymNodeDescriptionV2>, NymAPIError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
let mut descriptions = Vec::new();
loop {
let mut res = self.get_nodes_described_v2(Some(page), None).await?;
descriptions.append(&mut res.data);
if descriptions.len() < res.pagination.total {
page += 1
} else {
break;
}
}
Ok(descriptions)
}
#[tracing::instrument(level = "debug", skip_all)]
async fn get_nym_nodes(
@@ -390,7 +391,7 @@ pub trait NymApiClientExt: ApiClient {
#[deprecated]
#[tracing::instrument(level = "debug", skip_all)]
async fn get_basic_mixnodes(&self) -> Result<CachedNodesResponse<SkimmedNode>, NymAPIError> {
async fn get_basic_mixnodes(&self) -> Result<CachedNodesResponse<SkimmedNodeV1>, NymAPIError> {
self.get_json(
&[
routes::V1_API_VERSION,
@@ -406,7 +407,7 @@ pub trait NymApiClientExt: ApiClient {
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_basic_gateways(&self) -> Result<CachedNodesResponse<SkimmedNode>, NymAPIError> {
async fn get_basic_gateways(&self) -> Result<CachedNodesResponse<SkimmedNodeV1>, NymAPIError> {
self.get_json(
&[
routes::V1_API_VERSION,
@@ -443,7 +444,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -485,7 +486,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -527,7 +528,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -569,7 +570,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -612,7 +613,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -654,7 +655,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -695,7 +696,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -733,7 +734,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -770,7 +771,7 @@ pub trait NymApiClientExt: ApiClient {
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
) -> Result<PaginatedCachedNodesResponseV2<SemiSkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SemiSkimmedNodeV1>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -797,6 +798,21 @@ pub trait NymApiClientExt: ApiClient {
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_expanded_nodes_v3(
&self,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SemiSkimmedNodeV3>, NymAPIError> {
let mut params = Vec::new();
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response("/v3/unstable/nym-nodes/semi-skimmed", &params)
.await
}
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_mixnode_report(
@@ -3,6 +3,7 @@
pub const V1_API_VERSION: &str = "v1";
pub const V2_API_VERSION: &str = "v2";
pub const V3_API_VERSION: &str = "v3";
pub const MIXNODES: &str = "mixnodes";
pub const GATEWAYS: &str = "gateways";
pub const DESCRIBED: &str = "described";
@@ -14,7 +14,7 @@ pub struct Args {
}
pub async fn query(args: Args, client: &QueryClientWithNyxd) {
match client.get_all_cached_described_nodes().await {
match client.get_all_cached_described_nodes_v2().await {
Ok(res) => match args.identity_key {
Some(identity_key) => {
let node = res.iter().find(|node| {
@@ -14,7 +14,7 @@ pub struct Args {
}
pub async fn query(args: Args, client: &QueryClientWithNyxd) {
match client.get_all_cached_described_nodes().await {
match client.get_all_cached_described_nodes_v2().await {
Ok(res) => match args.identity_key {
Some(identity_key) => {
let node = res.iter().find(|node| {
+6 -2
View File
@@ -21,10 +21,13 @@ generic-array = { workspace = true, optional = true }
hkdf = { workspace = true, optional = true }
hmac = { workspace = true, optional = true }
jwt-simple = { workspace = true, optional = true }
libcrux-psq = { workspace = true, optional = true }
libcrux-curve25519 = { workspace = true, optional = true }
cipher = { workspace = true, optional = true }
x25519-dalek = { workspace = true, features = ["static_secrets"], optional = true }
ed25519-dalek = { workspace = true, features = ["rand_core"], optional = true }
rand = { workspace = true, optional = true }
rand09 = { workspace = true, optional = true }
serde_bytes = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"], optional = true }
sha2 = { workspace = true, optional = true }
@@ -39,17 +42,18 @@ nym-pemstore = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
rand_chacha = { workspace = true }
serde_json = { workspace = true }
nym-test-utils = { workspace = true }
serde_json = { workspace = true }
[features]
default = []
aead = ["dep:aead", "aead/std", "aes-gcm-siv", "generic-array"]
naive_jwt = ["asymmetric", "jwt-simple"]
libcrux_x25519 = ["libcrux-psq", "libcrux-curve25519"]
serde = ["dep:serde", "serde_bytes", "ed25519-dalek/serde", "x25519-dalek/serde"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "curve25519-dalek", "sha2", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2", "zeroize", "rand09"]
stream_cipher = ["aes", "ctr", "cipher", "generic-array"]
sphinx = ["nym-sphinx-types", "nym-sphinx-types/sphinx"]
+101
View File
@@ -17,6 +17,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[cfg(feature = "serde")]
pub mod serde_helpers;
#[cfg(feature = "libcrux_x25519")]
pub use libcrux_psq::handshake::types::{DHKeyPair, DHPrivateKey, DHPublicKey};
/// Size of a X25519 private key
pub const PRIVATE_KEY_SIZE: usize = 32;
@@ -45,6 +48,9 @@ pub enum KeyRecoveryError {
#[source]
source: bs58::decode::Error,
},
#[error("the x25519 private key could not be converted to its PSQ representation")]
IncompatiblePSQPrivateKey,
}
#[derive(Zeroize, ZeroizeOnDrop)]
@@ -413,6 +419,88 @@ impl AsRef<[u8]> for PrivateKey {
}
}
// libcrux-psq conversion
#[cfg(feature = "libcrux_x25519")]
impl TryFrom<PrivateKey> for libcrux_psq::handshake::types::DHPrivateKey {
type Error = KeyRecoveryError;
fn try_from(
key: PrivateKey,
) -> Result<libcrux_psq::handshake::types::DHPrivateKey, Self::Error> {
Self::try_from(&key)
}
}
#[cfg(feature = "libcrux_x25519")]
impl From<libcrux_psq::handshake::types::DHPrivateKey> for PrivateKey {
fn from(key: libcrux_psq::handshake::types::DHPrivateKey) -> PrivateKey {
// SAFETY: the DHPrivateKey is guaranteed to be 32 bytes in length
#[allow(clippy::unwrap_used)]
PrivateKey::from_bytes(key.as_ref()).unwrap()
}
}
#[cfg(feature = "libcrux_x25519")]
impl TryFrom<&PrivateKey> for libcrux_psq::handshake::types::DHPrivateKey {
type Error = KeyRecoveryError;
fn try_from(
key: &PrivateKey,
) -> Result<libcrux_psq::handshake::types::DHPrivateKey, Self::Error> {
let mut private_key_bytes = zeroize::Zeroizing::new(key.to_bytes());
libcrux_curve25519::clamp(&mut private_key_bytes);
match libcrux_psq::handshake::types::DHPrivateKey::from_bytes(&private_key_bytes) {
Ok(key) => Ok(key),
Err(_) => Err(KeyRecoveryError::IncompatiblePSQPrivateKey),
}
}
}
#[cfg(feature = "libcrux_x25519")]
impl From<&libcrux_psq::handshake::types::DHPrivateKey> for PrivateKey {
fn from(key: &libcrux_psq::handshake::types::DHPrivateKey) -> PrivateKey {
// SAFETY: the DHPrivateKey is guaranteed to be 32 bytes in length
#[allow(clippy::unwrap_used)]
PrivateKey::from_bytes(key.as_ref()).unwrap()
}
}
#[cfg(feature = "libcrux_x25519")]
impl From<PublicKey> for libcrux_psq::handshake::types::DHPublicKey {
fn from(key: PublicKey) -> libcrux_psq::handshake::types::DHPublicKey {
libcrux_psq::handshake::types::DHPublicKey::from_bytes(key.as_bytes())
}
}
#[cfg(feature = "libcrux_x25519")]
impl From<libcrux_psq::handshake::types::DHPublicKey> for PublicKey {
fn from(key: libcrux_psq::handshake::types::DHPublicKey) -> PublicKey {
// SAFETY: the DHPublicKey is guaranteed to be 32 bytes in length
#[allow(clippy::unwrap_used)]
PublicKey::from_bytes(key.as_ref()).unwrap()
}
}
#[cfg(feature = "libcrux_x25519")]
impl TryFrom<KeyPair> for libcrux_psq::handshake::types::DHKeyPair {
type Error = KeyRecoveryError;
fn try_from(
key: KeyPair,
) -> Result<libcrux_psq::handshake::types::DHKeyPair, KeyRecoveryError> {
Ok(libcrux_psq::handshake::types::DHKeyPair::from(
libcrux_psq::handshake::types::DHPrivateKey::try_from(&key.private_key)?,
))
}
}
#[cfg(feature = "libcrux_x25519")]
impl From<libcrux_psq::handshake::types::DHKeyPair> for KeyPair {
fn from(key: libcrux_psq::handshake::types::DHKeyPair) -> KeyPair {
KeyPair::from(PrivateKey::from(key.sk()))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -421,6 +509,19 @@ mod tests {
fn assert_zeroize<T: Zeroize>() {}
#[test]
fn test_key_conversion() {
let dalek_kp = KeyPair::new(&mut rand::thread_rng());
let mut dalek_private_key_bytes = dalek_kp.private_key().as_bytes().to_owned();
libcrux_curve25519::clamp(&mut dalek_private_key_bytes);
let libcrux_private_key = DHPrivateKey::from_bytes(&dalek_private_key_bytes).unwrap();
let libcrux_public_key = libcrux_private_key.to_public();
assert_eq!(libcrux_public_key.as_ref(), dalek_kp.public_key.as_bytes());
}
#[test]
fn private_key_is_zeroized() {
assert_zeroize::<PrivateKey>();
@@ -44,3 +44,25 @@ pub mod option_bs58_x25519_pubkey {
}
}
}
#[cfg(feature = "libcrux_x25519")]
pub mod bs58_dh_public_key {
use crate::asymmetric::x25519;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(
key: &libcrux_psq::handshake::types::DHPublicKey,
serializer: S,
) -> Result<S::Ok, S::Error> {
let x25519: x25519::PublicKey = (*key).into();
serializer.serialize_str(&x25519.to_base58_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<libcrux_psq::handshake::types::DHPublicKey, D::Error> {
let s = String::deserialize(deserializer)?;
let x25519 = x25519::PublicKey::from_base58_string(s).map_err(serde::de::Error::custom)?;
Ok(x25519.into())
}
}
+149
View File
@@ -109,3 +109,152 @@ impl DerivationMaterial {
}
}
}
pub mod blake3 {
//! Key Derivation Functions using Blake3.
use blake3::Hasher;
use rand09::{RngCore, rng};
use zeroize::Zeroize;
pub fn derive_key_blake3_multi_input(
info: &str,
input_key_material: &[&[u8]],
salt: &[u8],
) -> [u8; 32] {
let mut hasher = Hasher::new_derive_key(info);
for input_key in input_key_material {
hasher.update(input_key);
}
hasher.update(salt);
hasher.finalize().as_bytes().to_owned()
}
/// 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
/// * `info` - Context string for domain separation (e.g., "nym-lp-psk-v1")
/// * `input_key_material` - Input key material (shared secret from ECDH, etc.)
/// * `salt` - Additional salt for freshness (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(info: &str, input_key_material: &[u8], salt: &[u8]) -> [u8; 32] {
derive_key_blake3_multi_input(info, &[input_key_material], salt)
}
pub fn derive_fresh_key_blake3_multi_input(
info: &str,
input_key_material: &[&[u8]],
) -> [u8; 32] {
let mut salt = [0u8; 32];
rng().fill_bytes(&mut salt);
let derived_key = derive_key_blake3_multi_input(info, input_key_material, &salt);
// Zeroize salt
salt.zeroize();
derived_key
}
/// Derives a fresh 32-byte key using Blake3's key derivation mode.
/// The function calls a random number generator to generate a fresh salt.
/// Uses Blake3's built-in `derive_key` function with domain separation via context string.
///
/// # Arguments
/// * `info` - Context string for domain separation (e.g., "nym-lp-psk-v1")
/// * `input_key_material` - Input key material (shared secret from ECDH, etc.)
///
/// # Returns
/// 32-byte derived key suitable for use as PSK
///
/// # Example
/// ```ignore
/// let psk = derive_fresh_key_blake3("nym-lp-psk-v1", shared_secret.as_bytes());
/// ```
pub fn derive_fresh_key_blake3(info: &str, input_key_material: &[u8]) -> [u8; 32] {
derive_fresh_key_blake3_multi_input(info, &[input_key_material])
}
#[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);
}
}
}
-98
View File
@@ -1,98 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Key Derivation Functions using Blake3.
/// Derives a 32-byte key using Blake3's key derivation mode.
///
/// Uses Blake3's built-in `derive_key` function with domain separation via context string.
///
/// # Arguments
/// * `context` - Context string for domain separation (e.g., "nym-lp-psk-v1")
/// * `key_material` - Input key material (shared secret from ECDH, etc.)
/// * `salt` - Additional salt for freshness (timestamp + nonce)
///
/// # Returns
/// 32-byte derived key suitable for use as PSK
///
/// # Example
/// ```ignore
/// let psk = derive_key_blake3("nym-lp-psk-v1", shared_secret.as_bytes(), &salt);
/// ```
pub fn derive_key_blake3(context: &str, key_material: &[u8], salt: &[u8]) -> [u8; 32] {
// Concatenate key_material and salt as input
let input = [key_material, salt].concat();
// Use Blake3's derive_key with context for domain separation
// blake3::derive_key returns [u8; 32] directly
blake3::derive_key(context, &input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deterministic_derivation() {
let context = "test-context";
let key_material = b"shared_secret_12345";
let salt = b"salt_67890";
let key1 = derive_key_blake3(context, key_material, salt);
let key2 = derive_key_blake3(context, key_material, salt);
assert_eq!(key1, key2, "Same inputs should produce same output");
}
#[test]
fn test_different_contexts_produce_different_keys() {
let key_material = b"shared_secret";
let salt = b"salt";
let key1 = derive_key_blake3("context1", key_material, salt);
let key2 = derive_key_blake3("context2", key_material, salt);
assert_ne!(
key1, key2,
"Different contexts should produce different keys"
);
}
#[test]
fn test_different_salts_produce_different_keys() {
let context = "test-context";
let key_material = b"shared_secret";
let key1 = derive_key_blake3(context, key_material, b"salt1");
let key2 = derive_key_blake3(context, key_material, b"salt2");
assert_ne!(key1, key2, "Different salts should produce different keys");
}
#[test]
fn test_different_key_material_produces_different_keys() {
let context = "test-context";
let salt = b"salt";
let key1 = derive_key_blake3(context, b"secret1", salt);
let key2 = derive_key_blake3(context, b"secret2", salt);
assert_ne!(
key1, key2,
"Different key material should produce different keys"
);
}
#[test]
fn test_output_length() {
let key = derive_key_blake3("test", b"key", b"salt");
assert_eq!(key.len(), 32, "Output should be exactly 32 bytes");
}
#[test]
fn test_empty_inputs() {
// Should not panic with empty inputs
let key = derive_key_blake3("test", b"", b"");
assert_eq!(key.len(), 32);
}
}
-2
View File
@@ -10,8 +10,6 @@ pub mod crypto_hash;
pub mod hkdf;
#[cfg(feature = "hashing")]
pub mod hmac;
#[cfg(feature = "hashing")]
pub mod kdf;
#[cfg(all(feature = "asymmetric", feature = "hashing", feature = "stream_cipher"))]
pub mod shared_key;
pub mod symmetric;
+1
View File
@@ -1401,6 +1401,7 @@ pub trait ApiClient: ApiClientCore {
/// 'get' data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
/// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
/// into the provided type `T` based on the content type header
#[instrument(level = "debug", skip_all, fields(path=?path))]
async fn get_response<P, T, K, V>(
&self,
path: P,
+3 -1
View File
@@ -9,15 +9,17 @@ license.workspace = true
rust-version.workspace = true
readme.workspace = true
version.workspace = true
publish = false
[dependencies]
thiserror = { workspace = true }
num_enum = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
semver = { workspace = true }
blake3 = { workspace = true, optional = true }
libcrux-sha3 = { git = "https://github.com/cryspen/libcrux", optional = true }
libcrux-sha3 = { workspace = true, optional = true }
[features]
digests = ["blake3", "libcrux-sha3"]
+75 -9
View File
@@ -3,10 +3,12 @@
use crate::error::KKTCiphersuiteError;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::fmt::Display;
use strum_macros::{Display, EnumIter, EnumString};
pub use strum::IntoEnumIterator;
pub mod error;
pub const DEFAULT_HASH_LEN: usize = 32;
@@ -45,10 +47,13 @@ pub mod xwing {
pub const PUBLIC_KEY_LENGTH: usize = x25519::PUBLIC_KEY_LENGTH + ml_kem768::PUBLIC_KEY_LENGTH;
}
pub type KEMKeyDigests = KeyDigests;
pub type SigningKeyDigests = KeyDigests;
pub type KEMKeyDigests = BTreeMap<HashFunction, Vec<u8>>;
pub type KeyDigests = HashMap<HashFunction, Vec<u8>>;
pub mod node_compatibility {
/// Indicates the initial version where kkt has been introduced
/// 1.27.0 Raclette release
pub const INTRODUCTION: semver::Version = semver::Version::new(1, 27, 0);
}
#[derive(
Clone,
@@ -62,6 +67,8 @@ pub type KeyDigests = HashMap<HashFunction, Vec<u8>>;
EnumIter,
EnumString,
Display,
Ord,
PartialOrd,
)]
#[strum(ascii_case_insensitive)]
#[strum(serialize_all = "lowercase")]
@@ -204,23 +211,26 @@ impl SignatureScheme {
EnumIter,
EnumString,
Display,
Default,
Ord,
PartialOrd,
)]
#[strum(ascii_case_insensitive)]
#[strum(serialize_all = "lowercase")]
#[repr(u8)]
pub enum KEM {
XWing = 0,
// unsupported
// XWing = 0,
#[default]
MlKem768 = 1,
McEliece = 2,
X25519 = 255,
}
impl KEM {
pub fn encapsulation_key_length(&self) -> usize {
pub const fn encapsulation_key_length(&self) -> usize {
match self {
KEM::MlKem768 => ml_kem768::PUBLIC_KEY_LENGTH,
KEM::XWing => xwing::PUBLIC_KEY_LENGTH,
KEM::X25519 => x25519::PUBLIC_KEY_LENGTH,
// KEM::XWing => xwing::PUBLIC_KEY_LENGTH,
KEM::McEliece => mceliece::PUBLIC_KEY_LENGTH,
}
}
@@ -238,6 +248,17 @@ pub struct Ciphersuite {
signature_length: usize,
}
impl Default for Ciphersuite {
fn default() -> Self {
Ciphersuite::new(
KEM::MlKem768,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
)
}
}
impl Ciphersuite {
pub fn new(
kem: KEM,
@@ -257,6 +278,51 @@ impl Ciphersuite {
}
}
/// Determine optimal `Ciphersuite` based on remote's node's version
pub fn from_node_version(semver: semver::Version) -> Option<Self> {
if semver < node_compatibility::INTRODUCTION {
// node can't possibly support any Ciphersuite
return None;
}
// currently there are no other branches known to the client
// once changes to defaults are introduced, follow pattern similar to the one implemented in
// `common/authenticator-requests/src/version.rs`
Some(Ciphersuite::new(
KEM::MlKem768,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
))
}
#[must_use]
pub fn with_kem(mut self, kem: KEM) -> Self {
self.kem = kem;
self.encapsulation_key_length = kem.encapsulation_key_length();
self
}
#[must_use]
pub fn with_signature_scheme(mut self, signature_scheme: SignatureScheme) -> Self {
self.signature_scheme = signature_scheme;
self.signing_key_length = signature_scheme.signing_key_length();
self.verification_key_length = signature_scheme.verification_key_length();
self.signature_length = signature_scheme.signature_length();
self
}
#[must_use]
pub fn with_hash_function(mut self, hash_function: HashFunction) -> Self {
self.hash_function = hash_function;
self
}
#[must_use]
pub fn with_hash_length(mut self, hash_length: HashLength) -> Self {
self.hash_length = hash_length;
self
}
pub fn kem_key_len(&self) -> usize {
self.encapsulation_key_length
}
@@ -1,6 +1,5 @@
[package]
name = "nym-lp-transport"
version = "0.1.0"
name = "nym-kkt-context"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -9,15 +8,14 @@ edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
version.workspace = true
publish = false
[dependencies]
tokio = { workspace = true, features = ["net", "io-util"] }
nym-test-utils = { path = "../test-utils", optional = true }
tracing = { workspace = true }
num_enum = { workspace = true }
thiserror = { workspace = true }
[features]
io-mocks = ["nym-test-utils"]
nym-kkt-ciphersuite = { path = "../nym-kkt-ciphersuite" }
[lints]
workspace = true
@@ -1,13 +1,38 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2026 - 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 nym_kkt_ciphersuite::{CIPHERSUITE_ENCODING_LEN, Ciphersuite};
use std::fmt::Display;
use thiserror::Error;
// This must be less than 4 bits
pub const KKT_VERSION: u8 = 1;
const _: () = assert!(KKT_VERSION < 1 << 4);
pub const KKT_CONTEXT_LEN: usize = 3 + CIPHERSUITE_ENCODING_LEN;
#[derive(Debug, Error)]
pub enum KKTContextEncodingError {
#[error("KKT Message Count Limit Reached")]
MessageCountLimitReached,
#[error("{version} is not a valid KKT version")]
InvalidVersion { version: u8 },
#[error("{raw} is not a valid KKTStatus")]
InvalidStatus { raw: u8 },
#[error("{raw} is not a valid KKTRole")]
InvalidRole { raw: u8 },
#[error("{raw} is not a valid KKTMode")]
InvalidMode { raw: u8 },
#[error(transparent)]
InvalidCiphersuite(#[from] nym_kkt_ciphersuite::error::KKTCiphersuiteError),
}
// bitmask used: 0b1110_0000
#[derive(Clone, Copy, PartialEq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
@@ -15,11 +40,11 @@ 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,
UnsupportedCiphersuite = 0b0110_0000,
UnsupportedKKTVersion = 0b1000_0000,
InvalidKey = 0b1010_0000,
Timeout = 0b1100_0000,
UnverifiedKEMKey = 0b1110_0000,
}
impl Display for KKTStatus {
@@ -28,10 +53,10 @@ impl Display for KKTStatus {
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::UnverifiedKEMKey => "Could not verify received encapsulation key",
KKTStatus::Timeout => "Timeout",
})
}
@@ -43,7 +68,16 @@ impl Display for KKTStatus {
pub enum KKTRole {
Initiator = 0b0000_0000,
Responder = 0b0000_0001,
AnonymousInitiator = 0b0000_0010,
}
impl KKTRole {
pub const fn is_initiator(&self) -> bool {
matches!(self, KKTRole::Initiator)
}
pub const fn is_responder(&self) -> bool {
matches!(self, KKTRole::Responder)
}
}
// bitmask used: 0b0001_1100
@@ -54,6 +88,16 @@ pub enum KKTMode {
Mutual = 0b0000_0100,
}
impl KKTMode {
pub const fn is_one_way(&self) -> bool {
matches!(self, KKTMode::OneWay)
}
pub const fn is_mutual(&self) -> bool {
matches!(self, KKTMode::Mutual)
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct KKTContext {
version: u8,
@@ -63,24 +107,20 @@ pub struct KKTContext {
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 {
pub fn new(role: KKTRole, mode: KKTMode, ciphersuite: Ciphersuite) -> Self {
Self {
version: KKT_VERSION,
message_sequence: 0,
status: KKTStatus::Ok,
mode,
role,
ciphersuite,
})
}
}
pub fn derive_responder_header(&self) -> Result<Self, KKTError> {
pub fn derive_responder_header(&self) -> Result<Self, KKTContextEncodingError> {
let mut responder_header = *self;
responder_header.increment_message_sequence_count()?;
@@ -89,12 +129,12 @@ impl KKTContext {
Ok(responder_header)
}
pub fn increment_message_sequence_count(&mut self) -> Result<(), KKTError> {
pub fn increment_message_sequence_count(&mut self) -> Result<(), KKTContextEncodingError> {
if self.message_sequence + 1 < (1 << 4) {
self.message_sequence += 1;
Ok(())
} else {
Err(KKTError::MessageCountLimitReached)
Err(KKTContextEncodingError::MessageCountLimitReached)
}
}
@@ -118,9 +158,10 @@ impl KKTContext {
}
pub fn body_len(&self) -> usize {
if self.status != KKTStatus::Ok
|| (self.mode == KKTMode::OneWay
&& (self.role == KKTRole::Initiator || self.role == KKTRole::AnonymousInitiator))
if (self.status != KKTStatus::Ok && self.status != KKTStatus::UnverifiedKEMKey)
||
// no payload
(self.mode == KKTMode::OneWay && self.role == KKTRole::Initiator)
{
0
} else {
@@ -128,37 +169,18 @@ impl KKTContext {
}
}
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()
self.body_len() + self.header_len()
}
pub fn encode(&self) -> Result<[u8; KKT_CONTEXT_LEN], KKTError> {
pub fn encode(&self) -> Result<[u8; KKT_CONTEXT_LEN], KKTContextEncodingError> {
let mut header_bytes = [0u8; KKT_CONTEXT_LEN];
if self.message_sequence >= 1 << 4 {
return Err(KKTError::MessageCountLimitReached);
return Err(KKTContextEncodingError::MessageCountLimitReached);
}
let ciphersuite_bytes = self.ciphersuite.encode();
@@ -175,15 +197,17 @@ impl KKTContext {
Ok(header_bytes)
}
pub fn try_decode(header_bytes: [u8; KKT_CONTEXT_LEN]) -> Result<Self, KKTError> {
pub fn try_decode(
header_bytes: [u8; KKT_CONTEXT_LEN],
) -> Result<Self, KKTContextEncodingError> {
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}"),
return Err(KKTContextEncodingError::InvalidVersion {
version: kkt_version,
});
}
@@ -191,16 +215,15 @@ impl KKTContext {
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 status = KKTStatus::try_from(raw_kkt_status).map_err(|_| {
KKTContextEncodingError::InvalidStatus {
raw: raw_kkt_status,
}
})?;
let role = KKTRole::try_from(raw_kkt_role)
.map_err(|_| KKTContextEncodingError::InvalidRole { raw: raw_kkt_role })?;
let mode = KKTMode::try_from(raw_kkt_mode)
.map_err(|_| KKTContextEncodingError::InvalidMode { raw: raw_kkt_mode })?;
// SAFETY: we're taking exactly `CIPHERSUITE_ENCODING_LEN` bytes
#[allow(clippy::unwrap_used)]
@@ -228,9 +251,8 @@ mod tests {
let valid_context = KKTContext::new(
KKTRole::Initiator,
KKTMode::Mutual,
Ciphersuite::decode([255, 1, 0, 0]).unwrap(),
)
.unwrap();
Ciphersuite::decode([1, 1, 0, 0]).unwrap(),
);
let encoded = valid_context.encode().unwrap();
let decoded = KKTContext::try_decode(encoded).unwrap();
+9 -13
View File
@@ -7,35 +7,31 @@ license.workspace = true
publish = false
[dependencies]
blake3 = { workspace = true }
thiserror = { workspace = true }
num_enum = { workspace = true }
strum = { workspace = true }
# internal
nym-crypto = { path = "../crypto", features = ["asymmetric", "serde"] }
nym-crypto = { workspace = true, features = ["hashing"] }
nym-kkt-ciphersuite = { workspace = true, features = ["digests"] }
nym-kkt-context = { workspace = true }
nym-pemstore = { workspace = true }
libcrux-kem = { git = "https://github.com/cryspen/libcrux" }
libcrux-ecdh = { git = "https://github.com/cryspen/libcrux", features = ["codec"] }
libcrux-chacha20poly1305 = { git = "https://github.com/cryspen/libcrux" }
libcrux-kem = { workspace = true }
libcrux-ecdh = { workspace = true, features = ["codec"] }
libcrux-chacha20poly1305 = { workspace = true }
# rand 0.9 for libcrux integration (libcrux uses rand 0.9)
rand09 = { workspace = true }
zeroize = { workspace = true, features = ["zeroize_derive"] }
classic-mceliece-rust = { git = "https://github.com/georgio/classic-mceliece-rust", features = ["mceliece460896f", "zeroize"] }
libcrux-psq = { workspace = true, features = ["classic-mceliece"] }
libcrux-ml-kem = { workspace = true }
[dev-dependencies]
rand_chacha = "0.9.0"
anyhow = { workspace = true }
criterion = { workspace = true }
nym-test-utils = { workspace = true }
[[bench]]
name = "benches"
harness = false
[lints]
workspace = true
-480
View File
@@ -1,480 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// fine in benchmarking code
#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
use criterion::{Criterion, criterion_group, criterion_main};
use nym_crypto::asymmetric::ed25519;
use nym_kkt::{
ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM, SignatureScheme},
context::KKTMode,
frame::KKTFrame,
key_utils::{generate_keypair_libcrux, generate_keypair_mceliece, hash_encapsulation_key},
session::{
anonymous_initiator_process, initiator_ingest_response, initiator_process,
responder_ingest_message, responder_process,
},
};
use rand09::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];
rand09::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 rand09::rng()).unwrap()
});
});
}
pub fn kkt_benchmark(c: &mut Criterion) {
let mut rng = rand09::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 (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 (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(
&r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&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(
&i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let obtained_key = initiator_ingest_response(
&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 (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 (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(
&r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&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(
&i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let i_obtained_key = initiator_ingest_response(
&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 (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 (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(
&r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
.unwrap()
});
},
);
let r_frame = responder_process(
&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(
&i_context,
&r_frame,
&r_frame.context().unwrap(),
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
.unwrap()
});
},
);
let obtained_key = initiator_ingest_response(
&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);
+189
View File
@@ -0,0 +1,189 @@
use libcrux_chacha20poly1305::TAG_LEN;
use libcrux_psq::handshake::types::{DHKeyPair, DHPublicKey};
use nym_crypto::hkdf::blake3::derive_key_blake3;
use rand09::{CryptoRng, RngCore};
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
use crate::error::KKTError;
// This is arbitrary
pub const MAX_PAYLOAD_LEN: usize = 1_000_000;
const CARRIER_KDF_INFO_TX: &str = "CARRIER_V1_KDF_TX";
const CARRIER_KDF_INFO_RX: &str = "CARRIER_V1_KDF_RX";
const CARRIER_KKT_AAD: &[u8] = b"kkt-carrier-v1";
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct Carrier {
tx_key: [u8; 32],
rx_key: [u8; 32],
tx_counter: u64,
rx_counter: u64,
}
pub enum CarrierRole {
Initiator,
Responder,
}
fn increment_nonce(nonce: &mut u64) -> Result<(), KKTError> {
match nonce.checked_add(1) {
Some(incremented_nonce) => {
*nonce = incremented_nonce;
Ok(())
}
None => Err(KKTError::AEADError {
info: "Nonce maxed out.",
}),
}
}
fn as_nonce_bytes(nonce: u64) -> [u8; 12] {
let mut bytes = [0u8; 12];
let nonce_bytes = nonce.to_le_bytes();
bytes[4..].clone_from_slice(&nonce_bytes);
bytes
}
impl Carrier {
fn init(tx_key: [u8; 32], rx_key: [u8; 32]) -> Self {
Self {
tx_key,
rx_key,
tx_counter: 1,
rx_counter: 1,
}
}
pub fn new<R>(
rng: &mut R,
remote_public_key: &DHPublicKey,
context: &[u8],
is_initiator: bool,
) -> Result<(Self, DHPublicKey), KKTError>
where
R: RngCore + CryptoRng,
{
let ephemeral_keypair = DHKeyPair::new(rng);
let shared_secret = ephemeral_keypair
.sk()
.diffie_hellman(remote_public_key)
.map_err(|_| KKTError::X25519Error {
info: "Key Derivation Error",
})?;
Ok((
Self::from_secret_slice(shared_secret.as_ref(), context, is_initiator),
ephemeral_keypair.pk,
))
}
pub(crate) fn from_secret_slice(secret: &[u8], context: &[u8], is_initiator: bool) -> Self {
let (tx_key, rx_key) = if is_initiator {
(
derive_key_blake3(CARRIER_KDF_INFO_TX, secret, context),
derive_key_blake3(CARRIER_KDF_INFO_RX, secret, context),
)
} else {
(
derive_key_blake3(CARRIER_KDF_INFO_RX, secret, context),
derive_key_blake3(CARRIER_KDF_INFO_TX, secret, context),
)
};
Self::init(tx_key, rx_key)
}
pub fn from_secret(secret: [u8; 32], context: &[u8], is_initiator: bool) -> Self {
Self::from_secret_slice(Zeroizing::new(secret).as_slice(), context, is_initiator)
}
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, KKTError> {
if plaintext.len() > MAX_PAYLOAD_LEN {
return Err(KKTError::AEADError {
info: "Plaintext too large",
});
}
let mut output_buffer = vec![0; plaintext.len() + TAG_LEN];
libcrux_chacha20poly1305::encrypt(
&self.tx_key,
plaintext,
&mut output_buffer,
CARRIER_KKT_AAD,
&as_nonce_bytes(self.tx_counter),
)?;
increment_nonce(&mut self.tx_counter)?;
Ok(output_buffer)
}
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, KKTError> {
if ciphertext.len() > MAX_PAYLOAD_LEN + TAG_LEN {
return Err(KKTError::AEADError {
info: "Ciphertext too large",
});
}
let mut output_buffer = vec![0; ciphertext.len() - TAG_LEN];
libcrux_chacha20poly1305::decrypt(
&self.rx_key,
&mut output_buffer,
ciphertext,
CARRIER_KKT_AAD,
&as_nonce_bytes(self.rx_counter),
)?;
increment_nonce(&mut self.rx_counter)?;
Ok(output_buffer)
}
}
#[cfg(test)]
mod tests {
use crate::{carrier::Carrier, key_utils::generate_lp_keypair_x25519};
use rand09::RngCore;
#[test]
fn test_e2e() {
let mut rng = rand09::rng();
// generate responder x25519 keys
let r_x25519 = generate_lp_keypair_x25519(&mut rng);
let mut context: [u8; 32] = [0u8; 32];
rng.fill_bytes(&mut context);
let ephemeral_keypair = generate_lp_keypair_x25519(&mut rng);
let i_shared_secret = ephemeral_keypair.sk().diffie_hellman(&r_x25519.pk).unwrap();
let r_shared_secret = r_x25519.sk().diffie_hellman(&ephemeral_keypair.pk).unwrap();
let mut i_carrier = Carrier::from_secret_slice(i_shared_secret.as_ref(), &context, true);
let mut r_carrier = Carrier::from_secret_slice(r_shared_secret.as_ref(), &context, false);
let test1 = b"test1: i>r #1";
let ct1 = i_carrier.encrypt(test1).unwrap();
let pt1 = r_carrier.decrypt(&ct1).unwrap();
assert_eq!(pt1, test1);
let test2 = b"test2: r>i #1";
let ct2 = i_carrier.encrypt(test2).unwrap();
let pt2 = r_carrier.decrypt(&ct2).unwrap();
assert_eq!(pt2, test2);
let test3 = b"test3: i>r #2";
let ct3 = i_carrier.encrypt(test3).unwrap();
let pt3 = r_carrier.decrypt(&ct3).unwrap();
assert_eq!(pt3, test3);
let test4 = b"test4: i>r #3";
let ct4 = i_carrier.encrypt(test4).unwrap();
let pt4 = r_carrier.decrypt(&ct4).unwrap();
assert_eq!(pt4, test4);
let test5 = b"test5: r>i #2";
let ct5 = i_carrier.encrypt(test5).unwrap();
let pt5 = r_carrier.decrypt(&ct5).unwrap();
assert_eq!(pt5, test5);
}
}
-74
View File
@@ -1,74 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::KKTError;
use libcrux_kem::Algorithm;
pub use nym_kkt_ciphersuite::*;
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()),
}
}
}
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",
}),
}
}
-253
View File
@@ -1,253 +0,0 @@
// Copyright 2025-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{KKT_INITIAL_FRAME_AAD, context::KKTContext, error::KKTError, frame::KKTFrame};
use blake3::Hasher;
use libcrux_chacha20poly1305::{NONCE_LEN, TAG_LEN};
use nym_crypto::asymmetric::x25519;
use rand09::{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..x25519::PUBLIC_KEY_SIZE]);
// 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() + x25519::PUBLIC_KEY_SIZE);
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() < x25519::PUBLIC_KEY_SIZE + 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..x25519::PUBLIC_KEY_SIZE],
)?;
let (kkt_frame, kkt_context) = decrypt_kkt_frame(
&shared_secret,
&encrypted_frame_bytes[x25519::PUBLIC_KEY_SIZE..],
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::DEFAULT_HASH_LEN,
encryption::{KKTSessionSecret, decrypt, encrypt},
key_utils::generate_keypair_x25519,
};
use rand09::{RngCore, SeedableRng, rng};
#[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; DEFAULT_HASH_LEN];
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 = rand_chacha::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(())
}
}
+54 -16
View File
@@ -3,53 +3,91 @@
use crate::context::KKTStatus;
use nym_kkt_ciphersuite::error::KKTCiphersuiteError;
use nym_kkt_ciphersuite::{HashFunction, KEM};
use nym_kkt_context::KKTContextEncodingError;
use std::fmt::Debug;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum KKTError {
#[error("Signature constructor error")]
SigConstructorError,
#[error("Signature verification error")]
SigVerifError,
#[error(transparent)]
CiphersuiteDecodingError(#[from] KKTCiphersuiteError),
#[error("KEM mapping failure: {}", info)]
#[error(transparent)]
MaskedByteError(#[from] MaskedByteError),
#[error("KEM mapping failure: {info}")]
KEMMapping { info: &'static str },
#[error("Insecure Encapsulation Key Hash Length")]
InsecureHashLen,
#[error("KKT Frame Decoding Error: {}", info)]
#[error("KKT Frame Decoding Error: {info}")]
FrameDecodingError { info: String },
#[error("KKT Frame Encoding Error: {}", info)]
#[error("KKT Frame Encoding Error: {info}")]
FrameEncodingError { info: String },
#[error("KKT Incompatibility Error: {}", info)]
#[error("KKT Incompatibility Error: {info}")]
IncompatibilityError { info: &'static str },
#[error("KKT Responder Flagged Error: {}", status)]
#[error("KKT Responder Flagged Error: {status}")]
ResponderFlaggedError { status: KKTStatus },
#[error("KKT Message Count Limit Reached")]
MessageCountLimitReached,
#[error("PSQ KEM Error: {}", info)]
#[error("PSQ KEM Error: {info}")]
KEMError { info: &'static str },
#[error("Local Function Input Error: {}", info)]
#[error("Local Function Input Error: {info}")]
FunctionInputError { info: &'static str },
#[error("{}", info)]
#[error("{info}")]
X25519Error { info: &'static str },
#[error("{}", info)]
#[error("{info}")]
AEADError { info: &'static str },
#[error("{info}")]
DecodingError { info: &'static str },
#[error("{info}")]
UnsupportedAlgorithm { info: &'static str },
#[error("Generic libcrux error")]
LibcruxError,
#[error("failed to derive shared secret: {inner:?}")]
SharedSecretDerivationFailure {
inner: libcrux_psq::handshake::HandshakeError,
},
#[error("the received encapsulation key hash does not match the expected value")]
MismatchedKEMHash,
#[error(
"there are no known digests for initiator's KEM key with {kem} KEM and {hash_function} hash function"
)]
NoKnownKEMKeyDigests {
kem: KEM,
hash_function: HashFunction,
},
#[error(transparent)]
MalformedContext(#[from] KKTContextEncodingError),
}
impl KKTError {
pub fn shared_secret_derivation_failure(inner: libcrux_psq::handshake::HandshakeError) -> Self {
KKTError::SharedSecretDerivationFailure { inner }
}
}
#[derive(Error, Debug)]
pub enum MaskedByteError {
#[error("invalid Masked Byte Length: Expected({expected}), Actual({actual})")]
InvalidLength { expected: usize, actual: usize },
#[error("failed to Unmask Byte")]
Failure,
}
impl From<libcrux_kem::Error> for KKTError {
+114 -71
View File
@@ -7,90 +7,158 @@
// [2..=5] => Ciphersuite
// [6] => Reserved
use crate::context::{KKTMode, KKTRole};
use crate::message::{
DecryptedRequestFrame, KKTRequest, KKTRequestEncryptionResult, KKTRequestPlaintext,
};
use crate::{
context::{KKT_CONTEXT_LEN, KKTContext},
error::KKTError,
};
use libcrux_psq::handshake::types::{DHKeyPair, DHPublicKey};
use nym_kkt_ciphersuite::KEM;
use rand09::{CryptoRng, RngCore};
pub const KKT_SESSION_ID_LEN: usize = 16;
pub type KKTSessionId = [u8; KKT_SESSION_ID_LEN];
pub(crate) const KKT_CARRIER_CONTEXT: &[u8] = b"CARRIER_V1_KKT_V1_KDF";
#[derive(Debug, PartialEq, Clone)]
pub struct KKTFrame {
context: [u8; KKT_CONTEXT_LEN],
session_id: KKTSessionId,
context: KKTContext,
body: Vec<u8>,
signature: Vec<u8>,
payload: 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.
// if oneway and message coming from initiator => body is empty.
// if mutual and message coming from initiator => body has the initiator's kem public key.
// if coming from responder => body has the responder's kem public key.
impl KKTFrame {
pub fn new(
context: [u8; KKT_CONTEXT_LEN],
body: &[u8],
session_id: [u8; KKT_SESSION_ID_LEN],
signature: &[u8],
) -> Self {
pub fn new(context: KKTContext, body: &[u8], payload: Vec<u8>) -> Self {
Self {
context,
body: Vec::from(body),
session_id,
signature: Vec::from(signature),
payload,
}
}
pub fn context_ref(&self) -> &[u8] {
pub const fn size_excluding_payload(role: KKTRole, mode: KKTMode, kem: KEM) -> usize {
match role {
KKTRole::Initiator => {
match mode {
KKTMode::OneWay => {
// if oneway and message coming from initiator => body is empty.
KKT_CONTEXT_LEN
}
KKTMode::Mutual => {
// if mutual and message coming from initiator => body has the initiator's kem public key.
KKT_CONTEXT_LEN + kem.encapsulation_key_length()
}
}
}
KKTRole::Responder => {
// if coming from responder => body has the responder's kem public key.
KKT_CONTEXT_LEN + kem.encapsulation_key_length()
}
}
}
pub fn size(&self) -> usize {
self.payload.len()
+ Self::size_excluding_payload(
self.context.role(),
self.context.mode(),
self.context.ciphersuite().kem(),
)
}
pub fn context(&self) -> &KKTContext {
&self.context
}
pub fn context(&self) -> Result<KKTContext, KKTError> {
KKTContext::try_decode(self.context)
pub fn payload(&self) -> &[u8] {
self.payload.as_ref()
}
pub fn signature_ref(&self) -> &[u8] {
&self.signature
pub fn encrypt_initiator_frame<R>(
self,
rng: &mut R,
responder_public_key: &DHPublicKey,
version_byte: u8,
) -> Result<KKTRequestEncryptionResult, KKTError>
where
R: CryptoRng + RngCore,
{
let ephemeral_keypair = DHKeyPair::new(rng);
let plaintext =
KKTRequestPlaintext::new(ephemeral_keypair.pk, responder_public_key, version_byte);
let mut carrier =
plaintext.derive_initiator_carrier(ephemeral_keypair.sk(), responder_public_key)?;
let full_kkt_message = plaintext.into_request(&mut carrier, self)?;
Ok(KKTRequestEncryptionResult {
carrier,
request: full_kkt_message,
})
}
pub fn decrypt_initiator_frame(
responder_keypair: &DHKeyPair,
message: KKTRequest,
supported_versions: &[u8],
request_payload_len: usize,
) -> Result<DecryptedRequestFrame, KKTError> {
let mask = message.plaintext.version_mask(&responder_keypair.pk);
// check mask
// this could be used later when we have multiple versions
// if this call fails, it does before the server has to run a DH
let outer_protocol_version = message
.plaintext
.masked_version_bytes
.unmask_check_version(&mask, supported_versions)?;
// after verifying the version, we can perform the DH and continue processing the request
let mut carrier = message
.plaintext
.derive_responder_carrier(responder_keypair)?;
let decrypted_message = carrier.decrypt(&message.encrypted_frame)?;
let frame = KKTFrame::from_bytes(&decrypted_message, request_payload_len)?;
Ok(DecryptedRequestFrame {
carrier,
remote_frame: frame,
outer_protocol_version,
})
}
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 body(self) -> Vec<u8> {
self.body
}
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()
KKT_CONTEXT_LEN + self.body.len()
}
pub fn to_bytes(&self) -> Vec<u8> {
pub fn try_to_bytes(&self) -> Result<Vec<u8>, KKTError> {
let mut bytes = Vec::with_capacity(self.frame_length());
bytes.extend_from_slice(&self.context);
bytes.extend_from_slice(&self.context.encode()?);
bytes.extend_from_slice(&self.body);
bytes.extend_from_slice(&self.session_id);
bytes.extend_from_slice(&self.signature);
bytes
bytes.extend_from_slice(&self.payload);
Ok(bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, KKTContext), KKTError> {
pub fn from_bytes(bytes: &[u8], payload_len: usize) -> Result<Self, KKTError> {
let len = bytes.len();
if bytes.len() < KKT_CONTEXT_LEN {
return Err(KKTError::FrameDecodingError {
@@ -105,7 +173,7 @@ impl KKTFrame {
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() {
if bytes.len() != context.full_message_len() + payload_len {
return Err(KKTError::FrameDecodingError {
info: format!(
"Frame is shorter than expected: actual {len} != expected {}",
@@ -115,7 +183,6 @@ impl KKTFrame {
}
let mut body = Vec::new();
let mut signature = Vec::new();
// decode body
if context.body_len() > 0 {
@@ -123,33 +190,9 @@ impl KKTFrame {
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();
// decode payload. this could be empty.
let payload: Vec<u8> = Vec::from(&bytes[KKT_CONTEXT_LEN + context.body_len()..]);
// // 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,
))
Ok(KKTFrame::new(context, &body, payload))
}
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2025-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use libcrux_psq::handshake::types::DHPublicKey;
use nym_kkt_ciphersuite::Ciphersuite;
use rand09::{CryptoRng, RngCore};
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::keys::EncapsulationKey;
use crate::message::{KKTRequest, KKTResponse, ProcessedKKTResponse};
use crate::{
carrier::Carrier,
context::{KKTContext, KKTMode, KKTRole, KKTStatus},
error::KKTError,
frame::KKTFrame,
key_utils::validate_encapsulation_key,
};
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct KKTInitiator<'a> {
carrier: Carrier,
#[zeroize(skip)]
context: KKTContext,
#[zeroize(skip)]
expected_hash: &'a [u8],
}
impl<'a> KKTInitiator<'a> {
// to be used by clients
pub fn generate_one_way_request<R>(
rng: &mut R,
ciphersuite: Ciphersuite,
responder_dh_public_key: &DHPublicKey,
expected_hash: &'a [u8],
outer_protocol_version: u8,
payload: Option<Vec<u8>>,
) -> Result<(Self, KKTRequest), KKTError>
where
R: CryptoRng + RngCore,
{
Self::generate_encrypted_request(
rng,
KKTMode::OneWay,
ciphersuite,
None,
responder_dh_public_key,
expected_hash,
outer_protocol_version,
payload,
)
}
// to be used by nodes
pub fn generate_mutual_request<'b, R>(
rng: &mut R,
ciphersuite: Ciphersuite,
local_encapsulation_key: &'b [u8],
responder_dh_public_key: &DHPublicKey,
expected_hash: &'a [u8],
outer_protocol_version: u8,
payload: Option<Vec<u8>>,
) -> Result<(Self, KKTRequest), KKTError>
where
R: CryptoRng + RngCore,
{
Self::generate_encrypted_request(
rng,
KKTMode::Mutual,
ciphersuite,
Some(local_encapsulation_key),
responder_dh_public_key,
expected_hash,
outer_protocol_version,
payload,
)
}
#[allow(clippy::too_many_arguments)]
fn generate_encrypted_request<'b, R>(
rng: &mut R,
mode: KKTMode,
ciphersuite: Ciphersuite,
local_encapsulation_key: Option<&'b [u8]>,
responder_dh_public_key: &DHPublicKey,
expected_hash: &'a [u8],
outer_protocol_version: u8,
payload: Option<Vec<u8>>,
) -> Result<(Self, KKTRequest), KKTError>
where
R: CryptoRng + RngCore,
{
let frame = initiator_process(mode, ciphersuite, local_encapsulation_key, payload)?;
let context = *frame.context();
let request =
frame.encrypt_initiator_frame(rng, responder_dh_public_key, outer_protocol_version)?;
Ok((
Self {
carrier: request.carrier,
context,
expected_hash,
},
request.request,
))
}
pub fn process_response(
&mut self,
response: KKTResponse,
response_payload_len: usize,
) -> Result<ProcessedKKTResponse, KKTError> {
let decrypted_response_bytes = self.carrier.decrypt(&response.encrypted_frame)?;
let response_frame = KKTFrame::from_bytes(&decrypted_response_bytes, response_payload_len)?;
initiator_ingest_response(&self.context, &response_frame, self.expected_hash)
}
}
pub fn initiator_process(
mode: KKTMode,
ciphersuite: Ciphersuite,
own_encapsulation_key: Option<&[u8]>,
payload: Option<Vec<u8>>,
) -> Result<KKTFrame, KKTError> {
let context = KKTContext::new(KKTRole::Initiator, mode, ciphersuite);
let body: &[u8] = match mode {
KKTMode::OneWay => &[],
KKTMode::Mutual => match own_encapsulation_key {
Some(encaps_key) => encaps_key,
// Missing key
None => {
return Err(KKTError::FunctionInputError {
info: "KEM Key Not Provided",
});
}
},
};
Ok(KKTFrame::new(context, body, payload.unwrap_or_default()))
}
pub fn initiator_ingest_response(
own_context: &KKTContext,
remote_frame: &KKTFrame,
expected_hash: &[u8],
) -> Result<ProcessedKKTResponse, KKTError> {
let remote_context = remote_frame.context();
let verified_initiator_kem_key = match remote_context.status() {
KKTStatus::Ok | KKTStatus::UnverifiedKEMKey => {
match validate_encapsulation_key(
own_context.ciphersuite().hash_function(),
own_context.ciphersuite().hash_len(),
remote_frame.body_ref(),
expected_hash,
) {
true => remote_context.status() != KKTStatus::UnverifiedKEMKey,
// The key does not match the hash obtained from the directory
false => return Err(KKTError::MismatchedKEMHash),
}
}
_ => {
return Err(KKTError::ResponderFlaggedError {
status: remote_context.status(),
});
}
};
let kem = own_context.ciphersuite().kem();
let kem_bytes = remote_frame.body_ref();
let encapsulation_key = EncapsulationKey::try_from_bytes(kem_bytes.to_vec(), kem)?;
Ok(ProcessedKKTResponse {
encapsulation_key,
verified_initiator_kem_key,
response_payload: remote_frame.payload().to_vec(),
})
}
+17 -68
View File
@@ -1,74 +1,35 @@
use crate::ciphersuite::HashFunction;
use std::collections::HashMap;
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use classic_mceliece_rust::keypair_boxed;
use nym_kkt_ciphersuite::{DEFAULT_HASH_LEN, KeyDigests};
use libcrux_ml_kem::mlkem768::MlKem768KeyPair;
use libcrux_psq::handshake::types::DHKeyPair;
use nym_kkt_ciphersuite::{DEFAULT_HASH_LEN, HashFunction, KEMKeyDigests};
use rand09::{CryptoRng, RngCore};
use std::collections::BTreeMap;
pub fn generate_keypair_ed25519<R>(
rng: &mut R,
index: Option<u32>,
) -> nym_crypto::asymmetric::ed25519::KeyPair
pub fn generate_lp_keypair_x25519<R>(rng: &mut R) -> DHKeyPair
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))
DHKeyPair::new(rng)
}
pub fn generate_keypair_x25519<R>(rng: &mut R) -> nym_crypto::asymmetric::x25519::KeyPair
pub fn generate_keypair_mlkem<R>(rng: &mut R) -> MlKem768KeyPair
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()
libcrux_ml_kem::mlkem768::rand::generate_key_pair(rng)
}
// (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>
pub fn generate_keypair_mceliece<R>(rng: &mut R) -> libcrux_psq::classic_mceliece::KeyPair
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
R: RngCore + CryptoRng,
{
let (encapsulation_key, decapsulation_key) = keypair_boxed(rng);
(decapsulation_key, encapsulation_key)
libcrux_psq::classic_mceliece::KeyPair::generate_key_pair(rng)
}
pub fn hash_key_bytes(
hash_function: &HashFunction,
hash_function: HashFunction,
hash_length: usize,
key_bytes: &[u8],
) -> Vec<u8> {
@@ -77,9 +38,9 @@ pub fn hash_key_bytes(
/// attempt to produce digests of the provided key using all known [HashFunction] with a default
/// hash length where variable output is available
pub fn produce_key_digests(key_bytes: &[u8]) -> KeyDigests {
pub fn produce_key_digests(key_bytes: &[u8]) -> KEMKeyDigests {
use strum::IntoEnumIterator;
let mut digests = HashMap::new();
let mut digests = BTreeMap::new();
for hash in HashFunction::iter() {
digests.insert(hash, hash.digest(key_bytes, DEFAULT_HASH_LEN));
}
@@ -93,7 +54,7 @@ fn compare_hashes(a: &[u8], b: &[u8]) -> bool {
}
pub fn validate_encapsulation_key(
hash_function: &HashFunction,
hash_function: HashFunction,
hash_length: usize,
encapsulation_key: &[u8],
expected_hash_bytes: &[u8],
@@ -104,20 +65,8 @@ pub fn validate_encapsulation_key(
)
}
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_function: HashFunction,
hash_length: usize,
encapsulation_key: &[u8],
) -> Vec<u8> {
+440
View File
@@ -0,0 +1,440 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::KKTError;
use libcrux_psq::handshake::types::PQEncapsulationKey;
use nym_kkt_ciphersuite::{KEM, KEMKeyDigests};
use std::collections::BTreeMap;
use std::fmt::{Debug, Formatter};
use std::sync::Arc;
use crate::key_utils::produce_key_digests;
pub use libcrux_ml_kem::mlkem768::{MlKem768KeyPair, MlKem768PrivateKey, MlKem768PublicKey};
pub use libcrux_psq::classic_mceliece as mceliece;
pub use libcrux_psq::handshake::types::{DHKeyPair, DHPrivateKey, DHPublicKey};
/// Wrapper around keys used for the KEM exchange
/// with cheap clones thanks to Arc wrappers
#[derive(Clone)]
pub struct KEMKeys {
mc_eliece_pk: Arc<mceliece::PublicKey>,
mc_eliece_sk: Arc<mceliece::SecretKey>,
ml_kem768_pk: Arc<MlKem768PublicKey>,
ml_kem768_sk: Arc<MlKem768PrivateKey>,
}
impl Debug for KEMKeys {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KEMKeys")
.field("mc_eliece", &"<redacted>")
.field("ml_kem768", &"<redacted>")
.finish()
}
}
impl KEMKeys {
pub fn new(mc_eliece: mceliece::KeyPair, ml_kem768: MlKem768KeyPair) -> Self {
let (ml_kem768_sk, ml_kem768_pk) = ml_kem768.into_parts();
Self {
mc_eliece_pk: Arc::new(mc_eliece.pk),
mc_eliece_sk: Arc::new(mc_eliece.sk),
ml_kem768_pk: Arc::new(ml_kem768_pk),
ml_kem768_sk: Arc::new(ml_kem768_sk),
}
}
pub fn encapsulation_keys_digests(&self) -> BTreeMap<KEM, KEMKeyDigests> {
let mut digests = BTreeMap::new();
let mlkem_digests = produce_key_digests(self.ml_kem768_pk.as_slice());
let mceliece_digests = produce_key_digests(self.mc_eliece_pk.as_ref().as_ref());
digests.insert(KEM::MlKem768, mlkem_digests);
digests.insert(KEM::McEliece, mceliece_digests);
digests
}
pub fn encoded_encapsulation_key(&self, kem: KEM) -> Option<&[u8]> {
match kem {
KEM::McEliece => Some(self.mc_eliece_pk.as_ref().as_ref()),
KEM::MlKem768 => Some(self.ml_kem768_pk.as_slice()),
// _ => None,
}
}
pub fn encapsulation_key(&self, kem: KEM) -> Option<EncapsulationKey> {
match kem {
KEM::McEliece => Some(EncapsulationKey::McEliece(self.mc_eliece_pk.clone())),
KEM::MlKem768 => Some(EncapsulationKey::MlKem768(self.ml_kem768_pk.clone())),
// _ => None,
}
}
pub fn mc_eliece_encapsulation_key(&self) -> &mceliece::PublicKey {
&self.mc_eliece_pk
}
pub fn ml_kem768_encapsulation_key(&self) -> &MlKem768PublicKey {
self.ml_kem768_pk.as_ref()
}
pub fn mc_eliece_decapsulation_key(&self) -> &mceliece::SecretKey {
&self.mc_eliece_sk
}
pub fn ml_kem768_decapsulation_key(&self) -> &MlKem768PrivateKey {
&self.ml_kem768_sk
}
}
#[derive(Clone)]
pub enum EncapsulationKey {
McEliece(Arc<mceliece::PublicKey>),
MlKem768(Arc<MlKem768PublicKey>),
}
impl Debug for EncapsulationKey {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
EncapsulationKey::McEliece(_) => write!(f, "EncapsulationKey::McEliece"),
EncapsulationKey::MlKem768(_) => write!(f, "EncapsulationKey::MlKem768"),
}
}
}
impl EncapsulationKey {
pub fn kem(&self) -> KEM {
match self {
EncapsulationKey::McEliece(_) => KEM::McEliece,
EncapsulationKey::MlKem768(_) => KEM::MlKem768,
}
}
pub fn as_pq_encapsulation_key(&self) -> PQEncapsulationKey<'_> {
match self {
EncapsulationKey::McEliece(pk) => PQEncapsulationKey::CMC(pk),
EncapsulationKey::MlKem768(pk) => PQEncapsulationKey::MlKem(pk),
}
}
pub fn try_from_bytes(bytes: Vec<u8>, kem: KEM) -> Result<EncapsulationKey, KKTError> {
match kem {
KEM::MlKem768 => Ok(EncapsulationKey::MlKem768(Arc::new(
MlKem768PublicKey::try_from(bytes.as_slice()).map_err(|_| KKTError::KEMError {
info: "mlkem768 key of invalid length",
})?,
))),
KEM::McEliece => {
let boxed_array: Box<[u8; nym_kkt_ciphersuite::mceliece::PUBLIC_KEY_LENGTH]> =
bytes
.into_boxed_slice()
.try_into()
.map_err(|_| KKTError::KEMError {
info: "mceliece key of invalid length",
})?;
Ok(EncapsulationKey::McEliece(Arc::new(
mceliece::PublicKey::from(boxed_array),
)))
}
}
}
pub fn as_bytes(&self) -> &[u8] {
match self {
EncapsulationKey::McEliece(k) => k.as_ref().as_ref(),
EncapsulationKey::MlKem768(k) => k.as_ref().as_ref(),
}
}
}
// storage helpers
pub mod storage_wrappers {
use nym_pemstore::traits::PemStorableKey;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MalformedStoredKeyError {
#[error("{typ} stored key has an invalid length")]
InvalidKeyLength { typ: &'static str },
#[error("{typ} stored key is malformed: {message}")]
MalformedData { typ: &'static str, message: String },
#[error("attempted to take ownership of a stored {typ} key representation")]
IllegalStoredConversion { typ: &'static str },
}
pub trait StorableKey: Sized {
type StorableRepresentation<'a>: PemStorableKey
+ From<&'a Self>
+ TryInto<Self, Error = MalformedStoredKeyError>
+ Sized
where
Self: 'a;
fn to_storable(&self) -> Self::StorableRepresentation<'_> {
self.into()
}
fn from_storable(
repr: Self::StorableRepresentation<'_>,
) -> Result<Self, MalformedStoredKeyError> {
repr.try_into()
}
}
macro_rules! declare_key_wrappers {
($pub_key_type:ty, $private_key_type:ty) => {
pub enum StorablePublicKey<'a> {
Owned(Box<$pub_key_type>),
Borrowed(&'a $pub_key_type),
}
impl AsRef<$pub_key_type> for StorablePublicKey<'_> {
fn as_ref(&self) -> &$pub_key_type {
match self {
StorablePublicKey::Owned(k) => k,
StorablePublicKey::Borrowed(k) => k,
}
}
}
pub enum StorablePrivateKey<'a> {
Owned(Box<$private_key_type>),
Borrowed(&'a $private_key_type),
}
impl AsRef<$private_key_type> for StorablePrivateKey<'_> {
fn as_ref(&self) -> &$private_key_type {
match self {
StorablePrivateKey::Owned(k) => k,
StorablePrivateKey::Borrowed(k) => k,
}
}
}
impl<'a> From<&'a $pub_key_type> for StorablePublicKey<'a> {
fn from(value: &'a $pub_key_type) -> Self {
StorablePublicKey::Borrowed(value)
}
}
impl<'a> TryFrom<StorablePublicKey<'a>> for $pub_key_type {
type Error = MalformedStoredKeyError;
fn try_from(value: StorablePublicKey<'a>) -> Result<Self, Self::Error> {
match value {
StorablePublicKey::Owned(value) => Ok(*value),
StorablePublicKey::Borrowed(_) => {
Err(MalformedStoredKeyError::IllegalStoredConversion {
typ: <StorablePublicKey as PemStorableKey>::pem_type(),
})
}
}
}
}
impl<'a> From<&'a $private_key_type> for StorablePrivateKey<'a> {
fn from(value: &'a $private_key_type) -> Self {
StorablePrivateKey::Borrowed(value)
}
}
impl<'a> TryFrom<StorablePrivateKey<'a>> for $private_key_type {
type Error = MalformedStoredKeyError;
fn try_from(value: StorablePrivateKey<'a>) -> Result<Self, Self::Error> {
match value {
StorablePrivateKey::Owned(value) => Ok(*value),
StorablePrivateKey::Borrowed(_) => {
Err(MalformedStoredKeyError::IllegalStoredConversion {
typ: <StorablePrivateKey as PemStorableKey>::pem_type(),
})
}
}
}
}
impl $crate::keys::storage_wrappers::StorableKey for $pub_key_type {
type StorableRepresentation<'a> = StorablePublicKey<'a>;
}
impl $crate::keys::storage_wrappers::StorableKey for $private_key_type {
type StorableRepresentation<'a> = StorablePrivateKey<'a>;
}
};
}
pub mod mceliece {
use crate::keys::storage_wrappers::MalformedStoredKeyError;
use libcrux_psq::classic_mceliece;
use nym_pemstore::traits::PemStorableKey;
declare_key_wrappers!(classic_mceliece::PublicKey, classic_mceliece::SecretKey);
impl<'a> PemStorableKey for StorablePrivateKey<'a> {
type Error = MalformedStoredKeyError;
fn pem_type() -> &'static str {
"MCELIECE PRIVATE KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.as_ref().as_ref().to_vec()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes: Box<[u8; nym_kkt_ciphersuite::mceliece::SECRET_KEY_LENGTH]> =
bytes.to_vec().into_boxed_slice().try_into().map_err(|_| {
MalformedStoredKeyError::InvalidKeyLength {
typ: Self::pem_type(),
}
})?;
Ok(StorablePrivateKey::Owned(Box::new(
classic_mceliece::SecretKey::from(bytes),
)))
}
}
impl<'a> PemStorableKey for StorablePublicKey<'a> {
type Error = MalformedStoredKeyError;
fn pem_type() -> &'static str {
"MCELIECE PUBLIC KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.as_ref().as_ref().to_vec()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes: Box<[u8; nym_kkt_ciphersuite::mceliece::PUBLIC_KEY_LENGTH]> =
bytes.to_vec().into_boxed_slice().try_into().map_err(|_| {
MalformedStoredKeyError::InvalidKeyLength {
typ: Self::pem_type(),
}
})?;
Ok(StorablePublicKey::Owned(Box::new(
classic_mceliece::PublicKey::from(bytes),
)))
}
}
}
pub mod mlkem768 {
use crate::keys::storage_wrappers::MalformedStoredKeyError;
use libcrux_ml_kem::mlkem768::{MlKem768PrivateKey, MlKem768PublicKey};
use nym_pemstore::traits::PemStorableKey;
declare_key_wrappers!(MlKem768PublicKey, MlKem768PrivateKey);
impl<'a> PemStorableKey for StorablePrivateKey<'a> {
type Error = MalformedStoredKeyError;
fn pem_type() -> &'static str {
"MLKEM768 PRIVATE KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.as_ref().as_slice().to_vec()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
let inner = MlKem768PrivateKey::try_from(bytes).map_err(|message| {
MalformedStoredKeyError::MalformedData {
typ: Self::pem_type(),
message: message.to_string(),
}
})?;
Ok(StorablePrivateKey::Owned(Box::new(inner)))
}
}
impl<'a> PemStorableKey for StorablePublicKey<'a> {
type Error = MalformedStoredKeyError;
fn pem_type() -> &'static str {
"MLKEM768 PUBLIC KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.as_ref().as_slice().to_vec()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
let inner = MlKem768PublicKey::try_from(bytes).map_err(|message| {
MalformedStoredKeyError::MalformedData {
typ: Self::pem_type(),
message: message.to_string(),
}
})?;
Ok(StorablePublicKey::Owned(Box::new(inner)))
}
}
}
pub mod x25519 {
use crate::keys::storage_wrappers::MalformedStoredKeyError;
use libcrux_psq::handshake::types::{DHPrivateKey, DHPublicKey};
use nym_pemstore::traits::PemStorableKey;
declare_key_wrappers!(DHPublicKey, DHPrivateKey);
impl<'a> PemStorableKey for StorablePrivateKey<'a> {
type Error = MalformedStoredKeyError;
fn pem_type() -> &'static str {
"LP X25519 PRIVATE KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.as_ref().as_ref().to_vec()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes =
bytes
.try_into()
.map_err(|_| MalformedStoredKeyError::InvalidKeyLength {
typ: Self::pem_type(),
})?;
Ok(StorablePrivateKey::Owned(Box::new(
DHPrivateKey::from_bytes(&bytes).map_err(|err| {
MalformedStoredKeyError::MalformedData {
typ: Self::pem_type(),
message: format!("{err:?}"),
}
})?,
)))
}
}
impl<'a> PemStorableKey for StorablePublicKey<'a> {
type Error = MalformedStoredKeyError;
fn pem_type() -> &'static str {
"LP X25519 PUBLIC KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.as_ref().as_ref().to_vec()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes =
bytes
.try_into()
.map_err(|_| MalformedStoredKeyError::InvalidKeyLength {
typ: Self::pem_type(),
})?;
Ok(StorablePublicKey::Owned(Box::new(DHPublicKey::from_bytes(
&bytes,
))))
}
}
}
}
-449
View File
@@ -1,449 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Convenience wrappers around KKT protocol functions for easier integration.
//!
//! This module provides simplified APIs for the common use case of exchanging
//! KEM public keys between a client (initiator) and gateway (responder).
//!
//! The underlying KKT protocol is implemented in the `session` module.
use nym_crypto::asymmetric::{ed25519, x25519};
use rand09::{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;
/// 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(
/// &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 = rand09::rng();
rng.fill_bytes(&mut bytes);
x25519::PrivateKey::from_secret(bytes)
}
#[test]
fn test_kkt_wrappers_oneway_authenticated() {
let mut rng = rand09::rng();
// Generate Ed25519 keypairs for both parties
let mut initiator_secret = [0u8; 32];
rng.fill_bytes(&mut initiator_secret);
let 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, 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(
&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 = rand09::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 (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(
&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 = rand09::rng();
let mut initiator_secret = [0u8; 32];
rng.fill_bytes(&mut initiator_secret);
let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0);
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1);
let 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 = rand09::rng();
let mut initiator_secret = [0u8; 32];
rng.fill_bytes(&mut initiator_secret);
let initiator_keypair = ed25519::KeyPair::from_secret(initiator_secret, 0);
let mut responder_secret = [0u8; 32];
rng.fill_bytes(&mut responder_secret);
let responder_keypair = ed25519::KeyPair::from_secret(responder_secret, 1);
let 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, 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(
&context,
&session_key,
responder_keypair.public_key(),
&wrong_hash, // Wrong!
&response_frame,
);
// Should fail hash validation
assert!(result.is_err());
}
}
+237 -438
View File
@@ -1,498 +1,297 @@
// 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 carrier;
pub mod error;
pub mod frame;
pub mod initiator;
pub mod key_utils;
// pub mod kkt;
pub mod session;
pub mod keys;
pub mod masked_byte;
pub mod message;
pub mod rekey;
pub mod responder;
// This must be less than 4 bits
pub const KKT_VERSION: u8 = 1;
const _: () = assert!(KKT_VERSION < 1 << 4);
pub const KKT_RESPONSE_AAD: &[u8] = b"KKT_Response";
pub(crate) const KKT_INITIAL_FRAME_AAD: &[u8] = b"KKT_INITIAL_FRAME";
pub use nym_kkt_context as context;
#[cfg(test)]
mod test {
use crate::keys::KEMKeys;
use crate::{
KKT_RESPONSE_AAD,
ciphersuite::{Ciphersuite, EncapsulationKey, HashFunction, KEM},
encryption::{
decrypt_initial_kkt_frame, decrypt_kkt_frame, encrypt_initial_kkt_frame,
encrypt_kkt_frame,
},
frame::KKTFrame,
initiator::KKTInitiator,
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,
generate_keypair_mceliece, generate_keypair_mlkem, generate_lp_keypair_x25519,
hash_encapsulation_key,
},
responder::KKTResponder,
};
use nym_kkt_ciphersuite::{Ciphersuite, HashFunction, HashLength, KEM, SignatureScheme};
use nym_test_utils::helpers::deterministic_rng_09;
use rand09::RngCore;
use std::collections::BTreeMap;
#[test]
fn test_kkt_psq_e2e_clear() {
fn test_kkt_psq_e2e_one_way_encrypted_carrier() {
let mut rng = rand09::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));
let mut payload: Vec<u8> = vec![0u8; 900_000];
rng.fill_bytes(&mut payload);
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
] {
// generate responder x25519 keys
let responder_x25519_keypair = generate_lp_keypair_x25519(&mut rng);
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
] {
// generate kem public keys
let responder_mlkem_keypair = generate_keypair_mlkem(&mut rng);
let responder_mceliece_keypair = generate_keypair_mceliece(&mut rng);
let responder_kem = KEMKeys::new(responder_mceliece_keypair, responder_mlkem_keypair);
let r_dir_hash_mlkem = hash_encapsulation_key(
hash_function,
HashLength::Default.value(),
responder_kem.ml_kem768_encapsulation_key().as_slice(),
);
let r_dir_hash_mceliece = hash_encapsulation_key(
hash_function,
HashLength::Default.value(),
responder_kem.mc_eliece_encapsulation_key().as_ref(),
);
let init_hashes = BTreeMap::new();
let responder = KKTResponder::new(
&responder_x25519_keypair,
&responder_kem,
&init_hashes,
&[
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
],
&[SignatureScheme::Ed25519],
&[1],
)
.unwrap();
// OneWay - MlKem
{
let ciphersuite = Ciphersuite::resolve_ciphersuite(
kem,
KEM::MlKem768,
hash_function,
crate::ciphersuite::SignatureScheme::Ed25519,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (mut initiator, request) = KKTInitiator::generate_one_way_request(
&mut rng,
ciphersuite,
&responder_x25519_keypair.pk,
&r_dir_hash_mlkem,
1u8,
Some(payload.clone()),
)
.unwrap();
// generate kem public keys
let processed_request = responder.process_request(request, payload.len()).unwrap();
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),
),
};
assert_eq!(processed_request.request_payload, payload);
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 (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 (r_context, _) =
responder_ingest_message(&r_context, None, None, &i_frame_r).unwrap();
let r_frame = responder_process(
&r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
let result = initiator
.process_response(processed_request.response, 0)
.unwrap();
let r_bytes = r_frame.to_bytes();
assert_eq!(
result.encapsulation_key.as_bytes(),
responder_kem.ml_kem768_encapsulation_key().as_slice(),
)
}
let (i_frame_r, i_context_r) = KKTFrame::from_bytes(&r_bytes).unwrap();
// OneWay - McEliece
{
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::McEliece,
hash_function,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (mut initiator, request) = KKTInitiator::generate_one_way_request(
&mut rng,
ciphersuite,
&responder_x25519_keypair.pk,
&r_dir_hash_mceliece,
1u8,
Some(payload.clone()),
)
.unwrap();
let i_obtained_key = initiator_ingest_response(
&i_context,
&i_frame_r,
&i_context_r,
responder_ed25519_keypair.public_key(),
&r_dir_hash,
)
let processed_request = responder.process_request(request, payload.len()).unwrap();
assert_eq!(processed_request.request_payload, payload);
let processed_response = initiator
.process_response(processed_request.response, 0)
.unwrap();
assert_eq!(i_obtained_key.encode(), r_kem_key_bytes)
}
// Initiator, OneWay
{
let (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 (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(
&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(
&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 (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 (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(
&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(
&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)
}
assert_eq!(
processed_response.encapsulation_key.as_bytes(),
responder_kem.mc_eliece_encapsulation_key().as_ref()
)
}
}
}
#[test]
fn test_kkt_psq_e2e_encrypted() {
let mut rng = rand09::rng();
fn test_kkt_psq_e2e_mutual_encrypted_carrier() {
let mut rng = deterministic_rng_09();
// 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));
let mut payload: Vec<u8> = vec![0u8; 50000];
rng.fill_bytes(&mut payload);
// generate responder x25519 keys
let responder_x25519_keypair = generate_keypair_x25519(&mut rng);
// generate kem public keys
let initiator_mlkem_keypair = generate_keypair_mlkem(&mut rng);
let initiator_mceliece_keypair = generate_keypair_mceliece(&mut rng);
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
let responder_mlkem_keypair = generate_keypair_mlkem(&mut rng);
let responder_mceliece_keypair = generate_keypair_mceliece(&mut rng);
let responder_x25519_keypair = generate_lp_keypair_x25519(&mut rng);
let initiator_kem = KEMKeys::new(initiator_mceliece_keypair, initiator_mlkem_keypair);
let responder_kem = KEMKeys::new(responder_mceliece_keypair, responder_mlkem_keypair);
let init_hashes = initiator_kem.encapsulation_keys_digests();
let responder = KKTResponder::new(
&responder_x25519_keypair,
&responder_kem,
&init_hashes,
&[
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
] {
],
&[SignatureScheme::Ed25519],
&[1],
)
.unwrap();
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
] {
let r_dir_hash_mlkem = hash_encapsulation_key(
hash_function,
HashLength::Default.value(),
responder_kem.ml_kem768_encapsulation_key().as_slice(),
);
let r_dir_hash_mceliece = hash_encapsulation_key(
hash_function,
HashLength::Default.value(),
responder_kem.mc_eliece_encapsulation_key().as_ref(),
);
// Mutual - MlKem
{
let ciphersuite = Ciphersuite::resolve_ciphersuite(
kem,
KEM::MlKem768,
hash_function,
crate::ciphersuite::SignatureScheme::Ed25519,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (mut initiator, request) = KKTInitiator::generate_mutual_request(
&mut rng,
ciphersuite,
initiator_kem
.encoded_encapsulation_key(KEM::MlKem768)
.unwrap(),
&responder_x25519_keypair.pk,
&r_dir_hash_mlkem,
1u8,
Some(payload.clone()),
)
.unwrap();
// generate kem public keys
let processed_request = responder.process_request(request, payload.len()).unwrap();
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,
assert_eq!(processed_request.request_payload, payload);
assert_eq!(
processed_request
.remote_encapsulation_key
.unwrap()
.as_bytes(),
initiator_kem
.encapsulation_key(KEM::MlKem768)
.unwrap()
.as_bytes()
);
let r_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&r_kem_key_bytes,
let processed_response = initiator
.process_response(processed_request.response, 0)
.unwrap();
assert_eq!(
processed_response.encapsulation_key.as_bytes(),
responder_kem.ml_kem768_encapsulation_key().as_slice(),
)
}
// Mutual - McEliece is not supported due to the key being too large
{
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::McEliece,
hash_function,
SignatureScheme::Ed25519,
None,
)
.unwrap();
let (mut initiator, request) = KKTInitiator::generate_mutual_request(
&mut rng,
ciphersuite,
initiator_kem
.encoded_encapsulation_key(KEM::McEliece)
.unwrap(),
&responder_x25519_keypair.pk,
&r_dir_hash_mceliece,
1u8,
Some(payload.clone()),
)
.unwrap();
let processed_request = responder.process_request(request, payload.len()).unwrap();
assert_eq!(processed_request.request_payload, payload);
assert_eq!(
processed_request
.remote_encapsulation_key
.unwrap()
.as_bytes(),
initiator_kem
.encapsulation_key(KEM::McEliece)
.unwrap()
.as_bytes()
);
// Anonymous Initiator, OneWay
{
let (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,
)
let processed_response = initiator
.process_response(processed_request.response, 0)
.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 (r_context, _) =
responder_ingest_message(&i_context_r, None, None, &i_frame_r).unwrap();
let r_frame = responder_process(
&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(
&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 (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 (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(
&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(
&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 (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 (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(
&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(
&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)
}
assert_eq!(
processed_response.encapsulation_key.as_bytes(),
responder_kem.mc_eliece_encapsulation_key().as_ref()
)
}
}
}
+189
View File
@@ -0,0 +1,189 @@
use nym_crypto::{blake3, hmac::hmac::digest::ExtendableOutput};
use crate::error::{
MaskedByteError,
MaskedByteError::{Failure, InvalidLength},
};
pub const MASKED_BYTE_LEN: usize = 16;
pub const MASKED_BYTE_CONTEXT_STR: &[u8] = b"NYM_MASKED_BYTE_V1";
const U8_RANGE: [u8; 256] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49,
50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73,
74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97,
98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116,
117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135,
136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154,
155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173,
174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192,
193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211,
212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230,
231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249,
250, 251, 252, 253, 254, 255,
];
#[derive(Clone, Copy)]
pub struct MaskedByte([u8; MASKED_BYTE_LEN]);
impl MaskedByte {
/// Mask a byte by hashing it with some mask.
/// Outputs Blake3_Hash(MASKED_BYTE_CONTEXT_STR || mask || 0xFF || byte)
pub fn new(byte: u8, mask: &[u8]) -> Self {
let mut output: [u8; MASKED_BYTE_LEN] = [0u8; MASKED_BYTE_LEN];
let mut hasher = blake3::Hasher::new();
hasher.update(MASKED_BYTE_CONTEXT_STR);
hasher.update(mask);
// avoid zero update
hasher.update(&[0xFF, byte]);
hasher.finalize_xof_into(&mut output);
Self(output)
}
/// Unmasks a byte by trial hashing.
/// This function runs Blake3_Hash(MASKED_BYTE_CONTEXT_STR || mask || 0xFF).
/// This Hasher state is then cloned updated with `i: u8` in (0..=u8::max).
/// If we find an `i` which yields back the hash input, then we found the masked byte.
/// Otherwise, the function returns an error.
pub fn unmask(&self, mask: &[u8]) -> Result<u8, MaskedByteError> {
self.unmask_check_version(mask, &U8_RANGE)
}
// This could be more efficient than unmask,
// because we just could check against a smaller list of supported versions.
pub fn unmask_check_version(
&self,
mask: &[u8],
supported_versions: &[u8],
) -> Result<u8, MaskedByteError> {
let mut buf: [u8; MASKED_BYTE_LEN] = [0u8; MASKED_BYTE_LEN];
let mut hasher = blake3::Hasher::new();
hasher.update(MASKED_BYTE_CONTEXT_STR);
hasher.update(mask);
// avoid zero update
hasher.update(&[0xFF]);
for i in supported_versions {
let mut t_hasher = hasher.clone();
t_hasher.update(&[*i]);
t_hasher.finalize_xof_into(&mut buf);
if buf == self.0 {
return Ok(*i);
}
}
Err(Failure)
}
pub fn as_slice(&self) -> &[u8] {
&self.0
}
pub fn to_bytes(self) -> [u8; MASKED_BYTE_LEN] {
self.0
}
}
impl From<[u8; MASKED_BYTE_LEN]> for MaskedByte {
fn from(value: [u8; MASKED_BYTE_LEN]) -> Self {
MaskedByte(value)
}
}
impl From<&[u8; MASKED_BYTE_LEN]> for MaskedByte {
fn from(value: &[u8; MASKED_BYTE_LEN]) -> Self {
MaskedByte(*value)
}
}
impl TryFrom<&[u8]> for MaskedByte {
type Error = MaskedByteError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let Ok(inner) = value.try_into() else {
return Err(InvalidLength {
expected: MASKED_BYTE_LEN,
actual: value.len(),
});
};
Ok(MaskedByte(inner))
}
}
#[cfg(test)]
mod test {
use crate::masked_byte::MASKED_BYTE_LEN;
use super::MaskedByte;
use rand09::{Rng, RngCore, rng};
#[test]
fn test_masking() {
let mut mask: [u8; 256] = [0u8; 256];
let mut wire_bytes: [u8; MASKED_BYTE_LEN];
// why not
for i in 0..=u8::MAX {
// gen mask
rng().fill_bytes(&mut mask);
let masked_byte = MaskedByte::new(i, &mask);
wire_bytes = masked_byte.to_bytes();
let decoded_masked_byte = MaskedByte::from(wire_bytes);
let output = decoded_masked_byte.unmask(&mask).unwrap();
assert_eq!(i, output);
// flip bit
let mut with_flipped_bit = decoded_masked_byte.to_bytes();
let byte_idx: usize = rng().random_range(0..MASKED_BYTE_LEN);
let bit_idx = rng().random_range(0..8);
with_flipped_bit[byte_idx] ^= 1 << bit_idx;
let decoded_masked_byte = MaskedByte::from(with_flipped_bit);
assert!(decoded_masked_byte.unmask(&mask).is_err());
}
}
#[test]
fn test_decoding() {
let mut mask: [u8; 256] = [0u8; 256];
// gen mask
rng().fill_bytes(&mut mask);
let byte = rng().random();
let masked_byte = MaskedByte::new(byte, &mask);
let wire_bytes: [u8; MASKED_BYTE_LEN] = masked_byte.to_bytes();
// should succeed
let decoded_masked_byte = MaskedByte::try_from(wire_bytes.as_slice()).unwrap();
let output = decoded_masked_byte.unmask(&mask).unwrap();
assert_eq!(byte, output);
let empty_slice: &[u8] = &[];
// should fail
assert!(MaskedByte::try_from(empty_slice).is_err());
let mut wire_bytes_messy = Vec::from(wire_bytes);
// add more one more byte
wire_bytes_messy.push(0x42);
assert_eq!(wire_bytes_messy.len(), MASKED_BYTE_LEN + 1);
// should fail
assert!(MaskedByte::try_from(wire_bytes_messy.as_slice()).is_err());
// pop the added byte
_ = wire_bytes_messy.pop();
assert_eq!(wire_bytes_messy.len(), MASKED_BYTE_LEN);
// should succeed
assert!(MaskedByte::try_from(wire_bytes_messy.as_slice()).is_ok());
// pop one more byte
_ = wire_bytes_messy.pop();
assert_eq!(wire_bytes_messy.len(), MASKED_BYTE_LEN - 1);
// should fail
assert!(MaskedByte::try_from(wire_bytes_messy.as_slice()).is_err());
}
}
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::carrier::Carrier;
use crate::context::{KKTContext, KKTMode, KKTRole};
use crate::error::KKTError;
use crate::frame::KKTFrame;
use crate::keys::EncapsulationKey;
use crate::masked_byte::{MASKED_BYTE_LEN, MaskedByte};
use libcrux_chacha20poly1305::TAG_LEN;
use libcrux_psq::handshake::types::{DHKeyPair, DHPrivateKey, DHPublicKey};
use nym_kkt_ciphersuite::{KEM, x25519};
pub struct KKTRequest {
/// The plaintext part of the request
pub(crate) plaintext: KKTRequestPlaintext,
/// Ciphertext of an initial request `KKTFrame`
pub(crate) encrypted_frame: Vec<u8>,
}
impl KKTRequest {
// the size of KKTRequest is the plaintext data followed by the frame and the encryption tag
pub const fn size_excluding_payload(mode: KKTMode, kem: KEM) -> usize {
KKTRequestPlaintext::SIZE
+ KKTFrame::size_excluding_payload(KKTRole::Initiator, mode, kem)
+ TAG_LEN
}
pub fn size(&self) -> usize {
self.encrypted_frame.len() + KKTRequestPlaintext::SIZE
}
pub fn into_bytes(mut self) -> Vec<u8> {
let mut out = self.plaintext.to_bytes();
out.append(&mut self.encrypted_frame);
out
}
pub fn try_from_bytes(b: &[u8]) -> Result<Self, KKTError> {
if b.len() < x25519::PUBLIC_KEY_LENGTH + MASKED_BYTE_LEN {
return Err(KKTError::FrameDecodingError {
info: "the KKTRequest frame has invalid length".to_string(),
});
}
let plaintext =
KKTRequestPlaintext::try_from_bytes(&b[..x25519::PUBLIC_KEY_LENGTH + MASKED_BYTE_LEN])?;
Ok(KKTRequest {
plaintext,
encrypted_frame: b[x25519::PUBLIC_KEY_LENGTH + MASKED_BYTE_LEN..].to_vec(),
})
}
}
pub(crate) struct KKTRequestPlaintext {
/// Ephemeral Diffie-Hellman public key of the initiator
pub(crate) dh_pubkey: DHPublicKey,
/// Masked bytes representing the outer protocol version information
pub(crate) masked_version_bytes: MaskedByte,
}
impl KKTRequestPlaintext {
pub const SIZE: usize = x25519::PUBLIC_KEY_LENGTH + MASKED_BYTE_LEN;
pub(crate) fn new(
initiator_pubkey: DHPublicKey,
responder_pubkey: &DHPublicKey,
outer_protocol_version: u8,
) -> Self {
let mask = Self::create_version_mask(&initiator_pubkey, responder_pubkey);
let masked_version_bytes = MaskedByte::new(outer_protocol_version, &mask);
KKTRequestPlaintext {
dh_pubkey: initiator_pubkey,
masked_version_bytes,
}
}
pub(crate) fn into_request(
self,
carrier: &mut Carrier,
frame: KKTFrame,
) -> Result<KKTRequest, KKTError> {
let frame_bytes = frame.try_to_bytes()?;
let frame_ciphertext = carrier.encrypt(&frame_bytes)?;
Ok(KKTRequest {
plaintext: self,
encrypted_frame: frame_ciphertext,
})
}
pub(crate) fn create_version_mask(
initiator_pubkey: &DHPublicKey,
responder_pubkey: &DHPublicKey,
) -> Vec<u8> {
let mut mask = Vec::with_capacity(2 * x25519::PUBLIC_KEY_LENGTH);
mask.extend_from_slice(initiator_pubkey.as_ref());
mask.extend_from_slice(responder_pubkey.as_ref());
mask
}
fn create_carrier_ctx(
masked_version: &MaskedByte,
initiator_pubkey: &DHPublicKey,
responder_pubkey: &DHPublicKey,
) -> Vec<u8> {
let mut context = Vec::new();
context.extend_from_slice(masked_version.as_slice());
context.extend_from_slice(crate::frame::KKT_CARRIER_CONTEXT);
context.extend_from_slice(initiator_pubkey.as_ref());
context.extend_from_slice(responder_pubkey.as_ref());
context
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(Self::SIZE);
out.extend_from_slice(self.dh_pubkey.as_ref());
out.extend_from_slice(self.masked_version_bytes.as_slice());
out
}
pub(crate) fn try_from_bytes(b: &[u8]) -> Result<Self, KKTError> {
if b.len() != Self::SIZE {
return Err(KKTError::FrameDecodingError {
info: "the KKTRequest frame has invalid length".to_string(),
});
}
// SAFETY: we're using exactly 32 byte
#[allow(clippy::unwrap_used)]
let dh_pubkey =
DHPublicKey::from_bytes(&b[..x25519::PUBLIC_KEY_LENGTH].try_into().unwrap());
let masked_version_bytes = MaskedByte::try_from(&b[x25519::PUBLIC_KEY_LENGTH..])?;
Ok(KKTRequestPlaintext {
dh_pubkey,
masked_version_bytes,
})
}
pub(crate) fn version_mask(&self, responder_pubkey: &DHPublicKey) -> Vec<u8> {
Self::create_version_mask(&self.dh_pubkey, responder_pubkey)
}
pub(crate) fn derive_initiator_carrier(
&self,
initiator_sk: &DHPrivateKey,
responder_pubkey: &DHPublicKey,
) -> Result<Carrier, KKTError> {
let ctx = Self::create_carrier_ctx(
&self.masked_version_bytes,
&self.dh_pubkey,
responder_pubkey,
);
let shared_secret = initiator_sk
.diffie_hellman(responder_pubkey)
.map_err(KKTError::shared_secret_derivation_failure)?;
Ok(Carrier::from_secret_slice(
shared_secret.as_ref(),
&ctx,
true,
))
}
pub(crate) fn derive_responder_carrier(
&self,
responder_keys: &DHKeyPair,
) -> Result<Carrier, KKTError> {
let ctx = Self::create_carrier_ctx(
&self.masked_version_bytes,
&self.dh_pubkey,
&responder_keys.pk,
);
let shared_secret = responder_keys
.sk()
.diffie_hellman(&self.dh_pubkey)
.map_err(KKTError::shared_secret_derivation_failure)?;
Ok(Carrier::from_secret_slice(
shared_secret.as_ref(),
&ctx,
false,
))
}
}
pub struct KKTRequestEncryptionResult {
/// Derived carrier used for decrypting this frame and encrypting the response
pub(crate) carrier: Carrier,
/// The underlying request that is going to get sent to the remote
pub(crate) request: KKTRequest,
}
pub struct DecryptedRequestFrame {
/// Derived carrier used for decrypting this frame and encrypting the response
pub(crate) carrier: Carrier,
/// The remote frame sent in the message
pub(crate) remote_frame: KKTFrame,
/// The unmasked byte representing the outer protocol version sent by the initiator
pub(crate) outer_protocol_version: u8,
}
impl DecryptedRequestFrame {
pub(crate) fn remote_context(&self) -> &KKTContext {
self.remote_frame.context()
}
}
pub struct ProcessedKKTRequest {
pub response: KKTResponse,
/// The obtained encapsulation key of the remote
pub remote_encapsulation_key: Option<EncapsulationKey>,
/// The KEM key requested in the original request
pub requested_kem: KEM,
/// The unmasked byte representing the outer protocol version sent by the initiator
pub outer_protocol_version: u8,
// Request payload data (Could be empty. Contents are unrelated to current KKT execution).
pub request_payload: Vec<u8>,
}
pub struct KKTResponse {
/// Encrypted KKT frame that is going to be sent back to the initiator
pub encrypted_frame: Vec<u8>,
}
impl KKTResponse {
// the size of KKTRequest is the plaintext data followed by the frame and the encryption tag
pub const fn size_excluding_payload(kem: KEM) -> usize {
// `KKTMode` argument makes no difference for the Responder role
KKTFrame::size_excluding_payload(KKTRole::Responder, KKTMode::OneWay, kem) + TAG_LEN
}
pub fn size(&self) -> usize {
self.encrypted_frame.len()
}
pub fn from_bytes(bytes: Vec<u8>) -> KKTResponse {
KKTResponse {
encrypted_frame: bytes,
}
}
pub fn into_bytes(self) -> Vec<u8> {
self.encrypted_frame
}
}
pub struct ProcessedKKTResponse {
/// The obtained encapsulation key of the remote
pub encapsulation_key: EncapsulationKey,
/// Indicates whether responder was able to verify the initiator's kem key,
pub verified_initiator_kem_key: bool,
/// Optional response payload (Could be empty. Contents are unrelated to current KKT execution).
pub response_payload: Vec<u8>,
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Post-Quantum Re-Key Protocol
/// This module implements a stateless post-quantum re-keying protocol in one round-trip.
/// We currently support MlKem768.
///
/// This protocol is safe if it runs under a trusted secure channel.
///
/// Bandwidth costs:
/// Request (MlKem768): 1216 bytes
/// Response (MlKem768): 1088 bytes
use libcrux_kem::*;
use nym_crypto::hkdf::blake3::derive_key_blake3;
use nym_kkt_ciphersuite::{KEM, mceliece, ml_kem768, x25519, xwing};
use rand09::{CryptoRng, RngCore};
use std::fmt::{Debug, Formatter};
use zeroize::Zeroize;
use crate::error::KKTError;
/// Context string to be used with the Blake3 KDF.
const REKEY_CONTEXT: &str = "NYM_PQ_REKEY_v1";
pub struct RekeyInitiator {
algorithm: Algorithm,
decapsulation_key: PrivateKey,
salt: [u8; 32],
}
impl Debug for RekeyInitiator {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let key_typ = match self.decapsulation_key {
PrivateKey::X25519(_) => "x25519",
PrivateKey::P256(_) => "p256",
PrivateKey::MlKem512(_) => "ml512",
PrivateKey::MlKem768(_) => "mlkem768",
PrivateKey::X25519MlKem768Draft00(_) => "x25519-mlkem768",
PrivateKey::XWingKemDraft06(_) => "xwing",
PrivateKey::MlKem1024(_) => "ml1024",
};
f.debug_struct("RekeyInitiator")
.field("algorithm", &self.algorithm)
.field("decapsulation_key", &key_typ)
.field("salt", &self.salt)
.finish()
}
}
impl RekeyInitiator {
/// The Initiator generates an ephemeral KEM keypair and a 32-byte salt.
/// The Initiator keeps the decapsulation key and generates a request message.
/// The request message contains the salt and an encoding of the encapsulation key as follows
/// salt encapsulation_key
/// [0 ........ 32 | 32 .............. ]
///
/// Inputs:
/// rng: something that implements CryptoRng + RngCore
/// kem: a KEM algorithm (we currently support MlKem768 only)
///
/// Outputs:
/// RekeyInitiator: A struct which contains the decapsulation key, the salt and the kem algorithm in use.
/// Vec<u8>: The request message as explained above. This is to be sent to the responder as-is.
pub fn generate_request<R>(rng: &mut R, kem: KEM) -> Result<(RekeyInitiator, Vec<u8>), KKTError>
where
R: CryptoRng + RngCore,
{
let (algorithm, buffer_size) = match kem {
// KEM::XWing => (Algorithm::XWingKemDraft06, 32 + xwing::PUBLIC_KEY_LENGTH),
KEM::MlKem768 => (Algorithm::MlKem768, 32 + ml_kem768::PUBLIC_KEY_LENGTH),
// We don't support McEliece because the keys are massive.
// If this is a deal-breaker, users can start a new session with PSQ which can use McEliece.
KEM::McEliece => {
return Err(KKTError::UnsupportedAlgorithm {
info: "McEliece is not supported for re-keying",
});
}
};
// Generate the Initiator's salt
let mut salt = [0u8; 32];
rng.fill_bytes(&mut salt);
// Create the buffer for the request message and copy the salt into it.
let mut request_buffer = Vec::with_capacity(buffer_size);
request_buffer.extend_from_slice(&salt);
// Generate the ephemeral KEM keypair based on the algorithm from the function's input.
let (decapsulation_key, encapsulation_key) = key_gen(algorithm, rng)?;
// Append the encoding of the KEM encapsulation key to the initiator's randomness.
request_buffer.extend(encapsulation_key.encode());
Ok((
// The Initiator should store this until they use `RekeyInitiator::finalize`.
RekeyInitiator {
algorithm,
decapsulation_key,
salt,
},
// This is to be sent to the responder.
request_buffer,
))
}
/// The Initiator will attempt to decapsulate the `pre_key` generated by the responder
/// secret. This `pre_key` will be combined with the Initiator's previously generated salt
/// as input to a Blake3 KDF call to generate the new shared secret.
///
/// This function fails if the ciphertext cannot be decoded or decapsulated.
///
/// Input:
/// response_message: the responder's message which contains an encapsulation of `pre_key`.
/// Output:
/// [u8; 32]: the new shared secret.
pub fn finalize(mut self, response_message: &[u8]) -> Result<[u8; 32], KKTError> {
// Decode the responder's ciphertext.
let ciphertext = Ct::decode(self.algorithm, response_message)?;
// Decapsulate the `pre_key` using the Initiator's decapsulation key.
let pre_key = ciphertext.decapsulate(&self.decapsulation_key)?;
// Encode the `pre_key` into bytes
let pre_key_bytes = pre_key.encode();
let new_secret: [u8; 32] = derive_key_blake3(REKEY_CONTEXT, &pre_key_bytes, &self.salt);
// Zeroize the Initiator's salt
self.salt.zeroize();
// TODO: zeroize the decapsulation key
Ok(new_secret)
}
}
/// The responder parses the request message.
/// The first 32 bytes are the Initiator's salt,
/// and the remainder is the encoding of the public key.
/// Given that XWing and MlKem768 have different key lengths,
/// we could deduce the algorithm from that.
///
/// If the message is badly formatted, or the encapsulation received is invalid,
/// this function will produce an error.
///
/// If everything is alright, the responder generates and encapsulates a key `pre_key` to send to the Initiator.
/// Then, the responder calls a Blake3 KDF over `pre_key` and the Initiator's salt to obtain
/// the new shared secret.
///
/// Inputs:
/// rng: something that implements CryptoRng + RngCore
/// request_message: the Initiator's request message (contains the salt and encapsulation key)
///
/// Outputs:
/// [u8; 32]: new shared secret
/// Vec<u8>: response which contains an encapsulation of a secret value generated by the responder.
/// This is to be sent back to the Initiator as-is.
pub fn responder_process<R>(
rng: &mut R,
mut request_message: Vec<u8>,
) -> Result<([u8; 32], Vec<u8>), KKTError>
where
R: CryptoRng + RngCore,
{
// Deduce the KEM algorithm from the message length
let algorithm = match request_message.len().checked_sub(32) {
//
Some(num) => match num {
// If message length is 1216 (32 + 1184) then the algorithm should be MlKem768
ml_kem768::PUBLIC_KEY_LENGTH => Algorithm::MlKem768,
// If message length is 1248 (32 + 1216) then the algorithm should be xwing
xwing::PUBLIC_KEY_LENGTH => Algorithm::XWingKemDraft06,
// We don't support McEliece because the keys are massive.
// If this is a deal-breaker, users can start a new session with PSQ which can use McEliece.
mceliece::PUBLIC_KEY_LENGTH => {
return Err(KKTError::UnsupportedAlgorithm {
info: "McEliece is not supported for re-keying",
});
}
// We don't support X25519 because it's not post-quantum secure.
x25519::PUBLIC_KEY_LENGTH => {
return Err(KKTError::UnsupportedAlgorithm {
info: "McEliece is not supported for re-keying",
});
}
// Reject if the size does not match any of the above.
_ => {
return Err(KKTError::UnsupportedAlgorithm {
info: "Unknown Algorithm",
});
}
},
// Reject if message length is less than 32.
None => {
return Err(KKTError::DecodingError {
info: "Invalid rekey request: size is too small",
});
}
};
// Split the message to get the Initiator's salt (first 32 bytes)
// and the encoding of the Initiator's public key.
let (remote_salt, remote_encapsulation_key_bytes) = request_message.split_at_mut(32);
// Attempt to decode the Initiator's encapsulation key.
let remote_encapsulation_key = PublicKey::decode(algorithm, remote_encapsulation_key_bytes)?;
// Encapsulate a fresh `pre_key` using the Initiator's encapsulation key into `ciphertext`.
let (pre_key, ciphertext) = remote_encapsulation_key.encapsulate(rng)?;
// Encode the ciphertext into bytes to send back to the initiator.
let message = ciphertext.encode();
// Encode the `pre_key` into bytes
let pre_key_bytes = pre_key.encode();
let new_secret: [u8; 32] = derive_key_blake3(REKEY_CONTEXT, &pre_key_bytes, remote_salt);
// Zeroize the Initiator's salt
remote_salt.zeroize();
Ok((new_secret, message))
}
#[cfg(test)]
mod tests {
use crate::error::KKTError;
use crate::rekey::{RekeyInitiator, responder_process};
use nym_kkt_ciphersuite::KEM;
#[test]
fn rekey_test() {
let mut rng = rand09::rng();
let (rekey_state, request_message) =
RekeyInitiator::generate_request(&mut rng, KEM::MlKem768).unwrap();
let (responder_secret, response_message) =
responder_process(&mut rng, request_message).unwrap();
let initiator_secret = rekey_state.finalize(&response_message).unwrap();
assert_eq!(initiator_secret, responder_secret);
// mceliece should fail
let err = RekeyInitiator::generate_request(&mut rng, KEM::McEliece).unwrap_err();
assert_eq!(
err.to_string(),
KKTError::UnsupportedAlgorithm {
info: "McEliece is not supported for re-keying",
}
.to_string()
)
}
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::key_utils::validate_encapsulation_key;
use crate::keys::{EncapsulationKey, KEMKeys};
use crate::message::{KKTRequest, KKTResponse, ProcessedKKTRequest};
use crate::{
context::{KKTContext, KKTMode, KKTRole, KKTStatus},
error::KKTError,
frame::KKTFrame,
};
use libcrux_psq::handshake::types::DHKeyPair;
use nym_kkt_ciphersuite::{Ciphersuite, HashFunction, KEM, KEMKeyDigests, SignatureScheme};
use std::collections::BTreeMap;
/// Representation of a KKT Responder
pub struct KKTResponder<'a> {
/// Long-term x25519 DH key pair of this Responder
x25519_keypair: &'a DHKeyPair,
/// KEM keys of this responder
kem_keys: &'a KEMKeys,
/// Digests of the initiator's kem key
expected_initiator_kem_digests: &'a BTreeMap<KEM, KEMKeyDigests>,
/// List of supported Hash Functions by this Responder
supported_hash_functions: Vec<HashFunction>,
/// List of supported Signature Schemes by this Responder
supported_signature_schemes: Vec<SignatureScheme>,
/// List of supported outer (LP) protocol version by this Responder
supported_outer_protocol_versions: Vec<u8>,
}
impl<'a> KKTResponder<'a> {
pub fn new(
x25519_keypair: &'a DHKeyPair,
kem_keys: &'a KEMKeys,
expected_initiator_kem_digests: &'a BTreeMap<KEM, KEMKeyDigests>,
supported_hash_functions: &[HashFunction],
supported_signature_schemes: &[SignatureScheme],
supported_outer_protocol_versions: &[u8],
) -> Result<Self, KKTError> {
if supported_hash_functions.is_empty() {
return Err(KKTError::FunctionInputError {
info: "Did not provide a supported HashFunction when instantiating a KKTResponder",
});
}
if supported_signature_schemes.is_empty() {
return Err(KKTError::FunctionInputError {
info: "Did not provide a supported SignatureScheme when instantiating a KKTResponder",
});
}
if supported_outer_protocol_versions.is_empty() {
return Err(KKTError::FunctionInputError {
info: "Did not provide a supported outer protocol version when instantiating a KKTResponder",
});
}
Ok(Self {
x25519_keypair,
kem_keys,
expected_initiator_kem_digests,
supported_hash_functions: supported_hash_functions.to_vec(),
supported_signature_schemes: supported_signature_schemes.to_vec(),
supported_outer_protocol_versions: supported_outer_protocol_versions.to_vec(),
})
}
/// Attempt to retrieve expected KEM key hash of the initiator based on the received `Ciphersuite`
pub(crate) fn expected_initiator_kem_digest(
&self,
ciphersuite: Ciphersuite,
) -> Result<&Vec<u8>, KKTError> {
let kem = ciphersuite.kem();
let hash_function = ciphersuite.hash_function();
self.expected_initiator_kem_digests
.get(&kem)
.ok_or(KKTError::NoKnownKEMKeyDigests { kem, hash_function })?
.get(&hash_function)
.ok_or(KKTError::NoKnownKEMKeyDigests { kem, hash_function })
}
fn check_ciphersuite_compatiblity(
&self,
remote_ciphersuite: Ciphersuite,
) -> Result<(), KKTError> {
let r_hash = remote_ciphersuite.hash_function();
let r_sig = remote_ciphersuite.signature_scheme();
if !self.supported_hash_functions.contains(&r_hash) {
return Err(KKTError::IncompatibilityError {
info: "Unsupported HashFunction",
});
}
if !self.supported_signature_schemes.contains(&r_sig) {
return Err(KKTError::IncompatibilityError {
info: "Unsupported SignatureScheme",
});
}
Ok(())
}
// When this function fails, we do that silently (i.e. we don't generate a response to the initiator).
pub fn process_request(
&self,
request: KKTRequest,
request_payload_len: usize,
) -> Result<ProcessedKKTRequest, KKTError> {
let processed_req = KKTFrame::decrypt_initiator_frame(
self.x25519_keypair,
request,
&self.supported_outer_protocol_versions,
request_payload_len,
)?;
let remote_context = *processed_req.remote_context();
let remote_frame = processed_req.remote_frame;
let request_payload = remote_frame.payload().to_vec();
let mut carrier = processed_req.carrier;
self.check_ciphersuite_compatiblity(remote_context.ciphersuite())?;
let (local_context, remote_encapsulation_key) = match remote_context.mode() {
KKTMode::OneWay => responder_ingest_message(None, remote_frame)?,
KKTMode::Mutual => {
let digest = self.expected_initiator_kem_digest(remote_context.ciphersuite())?;
responder_ingest_message(Some(digest), remote_frame)?
}
};
let kem = local_context.ciphersuite().kem();
let Some(kem_key) = self.kem_keys.encoded_encapsulation_key(kem) else {
return Err(KKTError::IncompatibilityError {
info: "Unsupported KEM",
});
};
// for now the response payload is empty
let response_payload = Vec::new();
let frame = KKTFrame::new(local_context, kem_key, response_payload);
// encryption - responder frame
let encrypted_frame = carrier.encrypt(&frame.try_to_bytes()?)?;
Ok(ProcessedKKTRequest {
response: KKTResponse { encrypted_frame },
remote_encapsulation_key,
requested_kem: remote_context.ciphersuite().kem(),
outer_protocol_version: processed_req.outer_protocol_version,
request_payload,
})
}
}
pub fn responder_ingest_message(
expected_hash: Option<&[u8]>,
remote_frame: KKTFrame,
) -> Result<(KKTContext, Option<EncapsulationKey>), KKTError> {
let remote_context = remote_frame.context();
let mut own_context = remote_context.derive_responder_header()?;
let cs = own_context.ciphersuite();
match remote_context.role() {
KKTRole::Initiator => {
// 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 => {
let Some(expected_hash) = expected_hash else {
own_context.update_status(KKTStatus::UnverifiedKEMKey);
// we don't store an unverified key
// changing the status notifies the initiator that we didn't
return Ok((own_context, None));
};
if !validate_encapsulation_key(
cs.hash_function(),
cs.hash_len(),
remote_frame.body_ref(),
expected_hash,
) {
// The key does not match the hash obtained from the directory
return Err(KKTError::MismatchedKEMHash);
}
let remote_key =
EncapsulationKey::try_from_bytes(remote_frame.body(), cs.kem())?;
Ok((own_context, Some(remote_key)))
}
}
}
KKTRole::Responder => Err(KKTError::IncompatibilityError {
info: "Responder received a request from another responder.",
}),
}
}
-230
View File
@@ -1,230 +0,0 @@
use nym_crypto::asymmetric::ed25519::{self, Signature};
use rand09::{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: &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: &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(())
}
-8
View File
@@ -1,8 +0,0 @@
[package]
name = "nym-lp-common"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
publish = false
[dependencies]
-139
View File
@@ -1,139 +0,0 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#[cfg(feature = "io-mocks")]
use nym_test_utils::mocks::async_read_write::MockIOStream;
use std::net::SocketAddr;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tracing::debug;
// only used in internal code (and tests)
#[allow(async_fn_in_trait)]
pub trait LpTransport: Sized {
async fn connect(endpoint: SocketAddr) -> std::io::Result<Self>;
fn set_no_delay(&mut self, nodelay: bool) -> std::io::Result<()>;
/// Sends a serialised (and optionally encrypted) LP packet over the data stream with length-prefixed framing.
///
/// Format: 4-byte big-endian u32 length + packet bytes
///
/// # Arguments
/// * `packet_data` - The serialised LP packet to send
///
/// # Errors
/// Returns an error on network transmission fails.
async fn send_serialised_packet(&mut self, packet_data: &[u8]) -> std::io::Result<()>;
/// Receives an LP packet from a TCP stream with length-prefixed framing.
///
/// Format: 4-byte big-endian u32 length + packet bytes
///
/// # Errors
/// Returns an error on network transmission fails.
async fn receive_raw_packet(&mut self) -> std::io::Result<Vec<u8>>;
}
async fn send_serialised_packet_async_write<W>(
writer: &mut W,
packet_data: &[u8],
) -> std::io::Result<()>
where
W: AsyncWrite + Unpin,
{
// Send 4-byte length prefix (u32 big-endian)
let len = packet_data.len() as u32;
writer
.write_all(&len.to_be_bytes())
.await
.inspect_err(|e| debug!("Failed to send packet length: {e}"))?;
// Send the actual packet data
writer
.write_all(packet_data)
.await
.inspect_err(|e| debug!("Failed to send packet data: {e}"))?;
// Flush to ensure data is sent immediately
writer
.flush()
.await
.inspect_err(|e| debug!("Failed to flush stream: {e}"))?;
tracing::trace!(
"Sent LP packet ({} bytes + 4 byte header)",
packet_data.len()
);
Ok(())
}
async fn receive_raw_packet_async_read<R>(reader: &mut R) -> std::io::Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
// Read 4-byte length prefix (u32 big-endian)
let mut len_buf = [0u8; 4];
reader
.read_exact(&mut len_buf)
.await
.inspect_err(|e| debug!("Failed to read packet length: {e}"))?;
let packet_len = u32::from_be_bytes(len_buf) as usize;
// Sanity check to prevent huge allocations
const MAX_PACKET_SIZE: usize = 65536; // 64KB max
if packet_len > MAX_PACKET_SIZE {
return Err(std::io::Error::other(format!(
"Packet size {packet_len} exceeds maximum {MAX_PACKET_SIZE}",
)));
}
// Read the actual packet data
let mut packet_buf = vec![0u8; packet_len];
reader
.read_exact(&mut packet_buf)
.await
.inspect_err(|e| debug!("Failed to read packet data: {e}"))?;
tracing::trace!("Received LP packet ({packet_len} bytes + 4 byte header)");
Ok(packet_buf)
}
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)
}
async fn send_serialised_packet(&mut self, packet_data: &[u8]) -> std::io::Result<()> {
send_serialised_packet_async_write(self, packet_data).await
}
async fn receive_raw_packet(&mut self) -> std::io::Result<Vec<u8>> {
receive_raw_packet_async_read(self).await
}
}
#[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(())
}
async fn send_serialised_packet(&mut self, packet_data: &[u8]) -> std::io::Result<()> {
send_serialised_packet_async_write(self, packet_data).await
}
async fn receive_raw_packet(&mut self) -> std::io::Result<Vec<u8>> {
receive_raw_packet_async_read(self).await
}
}
+20 -28
View File
@@ -1,56 +1,48 @@
[package]
name = "nym-lp"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
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
version.workspace = true
publish = false
[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 = { workspace = true }
tls_codec = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util"] }
nym-crypto = { path = "../crypto", features = ["hashing", "asymmetric"] }
nym-kkt = { path = "../nym-kkt" }
nym-lp-common = { path = "../nym-lp-common" }
nym-lp-transport = { path = "../nym-lp-transport" }
nym-crypto = { workspace = true, features = ["hashing"] }
nym-kkt = { workspace = true }
nym-kkt-ciphersuite = { workspace = true }
# 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 }
libcrux-psq = { workspace = true, features = ["test-utils"] }
num_enum = { workspace = true }
chacha20poly1305 = { workspace = true }
zeroize = { workspace = true, features = ["zeroize_derive"] }
# needed for the 'mock 'feature
nym-test-utils = { workspace = true, optional = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
#rand_chacha = "0.3"
mock_instant = { workspace = true }
nym-crypto = { path = "../crypto", features = ["rand"] }
criterion = { workspace = true, features = ["html_reports"] }
nym-test-utils = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
nym-lp-transport = { path = "../nym-lp-transport", features = ["io-mocks"] }
[features]
mock = ["nym-test-utils", "nym-crypto/rand"]
mock = ["nym-test-utils"]
[[bench]]
name = "replay_protection"
harness = false
harness = false
+9 -14
View File
@@ -1,9 +1,8 @@
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
use nym_lp::replay::ReceivingKeyCounterValidator;
use nym_test_utils::helpers::u64_seeded_rng;
use parking_lot::Mutex;
use rand::Rng;
use std::sync::Arc;
use nym_test_utils::helpers::deterministic_rng_09;
use rand09::Rng;
use std::sync::{Arc, Mutex};
fn bench_sequential_counters(c: &mut Criterion) {
let mut group = c.benchmark_group("replay_sequential");
@@ -47,8 +46,8 @@ fn bench_out_of_order_counters(c: &mut Criterion) {
let validator = ReceivingKeyCounterValidator::default();
// Create random counters within a valid window
let mut rng = u64_seeded_rng(42);
let counters: Vec<u64> = (0..size).map(|_| rng.gen_range(0..1024)).collect();
let mut rng = deterministic_rng_09();
let counters: Vec<u64> = (0..size).map(|_| rng.random_range(0..1024)).collect();
b.iter(|| {
let mut validator = validator.clone();
@@ -75,19 +74,15 @@ fn bench_thread_safety(c: &mut Criterion) {
BenchmarkId::new("thread_safe_validator", size),
&size,
|b, &size| {
let validator = Arc::new(Mutex::new(ReceivingKeyCounterValidator::default()));
let mut validator = 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))
};
let result = { black_box(validator.will_accept_branchless(counter)) };
if result.is_ok() {
let mut guard = validator.lock();
let _ = black_box(guard.mark_did_receive_branchless(counter));
let _ = black_box(validator.mark_did_receive_branchless(counter));
}
}
});
@@ -202,7 +197,7 @@ fn bench_concurrency_scaling(c: &mut Criterion) {
let mut success_count = 0;
for i in 0..100 {
let counter = t * 1000 + i;
let mut guard = validator_clone.lock();
let mut guard = validator_clone.lock().unwrap();
if guard.mark_did_receive_branchless(counter as u64).is_ok() {
success_count += 1;
}
+233 -1263
View File
File diff suppressed because it is too large Load Diff
-79
View File
@@ -1,79 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Configuration for LP protocol.
//!
//! LP security stack = KKT (key fetch) → PSQ (PQ PSK) → Noise (transport).
//! KEM algorithm selection affects only PSQ layer. Noise always uses X25519 DH.
//! Migration to PQ KEMs (MlKem768, XWing) requires only config change.
use nym_kkt::ciphersuite::KEM;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Default PSK time-to-live (1 hour, matches psk.rs implementation).
pub const DEFAULT_PSK_TTL_SECS: u64 = 3600;
/// Configuration for LP protocol.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpConfig {
/// KEM algorithm for PSQ key encapsulation.
/// X25519 = classical (testing), MlKem768 = PQ, XWing = hybrid.
#[serde(with = "kem_serde")]
pub kem_algorithm: KEM,
/// PSK time-to-live in seconds.
pub psk_ttl_secs: u64,
/// Enable KKT for authenticated key distribution.
pub enable_kkt: bool,
}
impl Default for LpConfig {
fn default() -> Self {
Self {
kem_algorithm: KEM::X25519,
psk_ttl_secs: DEFAULT_PSK_TTL_SECS,
enable_kkt: true,
}
}
}
impl LpConfig {
/// Returns PSK TTL as Duration.
pub fn psk_ttl(&self) -> Duration {
Duration::from_secs(self.psk_ttl_secs)
}
}
mod kem_serde {
use nym_kkt::ciphersuite::KEM;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(kem: &KEM, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match kem {
KEM::X25519 => "X25519",
KEM::MlKem768 => "MlKem768",
KEM::XWing => "XWing",
KEM::McEliece => "McEliece",
}
.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<KEM, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"X25519" => Ok(KEM::X25519),
"MlKem768" => Ok(KEM::MlKem768),
"XWing" => Ok(KEM::XWing),
"McEliece" => Ok(KEM::McEliece),
_ => Err(serde::de::Error::custom(format!("Unknown KEM: {}", s))),
}
}
}
+63 -49
View File
@@ -1,11 +1,16 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::message::MessageType;
use crate::{noise_protocol::NoiseError, replay::ReplayError};
use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
use nym_kkt::ciphersuite::{HashFunction, KEM};
use crate::packet::MalformedLpPacketError;
use crate::peer_config::LpReceiverIndex;
use crate::replay::ReplayError;
use crate::transport::LpTransportError;
use libcrux_psq::handshake::HandshakeError;
use libcrux_psq::handshake::builders::BuilderError;
use libcrux_psq::session::SessionError;
// use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
use nym_kkt::error::KKTError;
use nym_kkt_ciphersuite::{HashFunction, KEM};
use thiserror::Error;
#[derive(Error, Debug)]
@@ -13,33 +18,18 @@ 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("There already exists an LP session with receiver index {0}")]
DuplicateSessionId(LpReceiverIndex),
#[error("Internal error: {0}")]
Internal(String),
@@ -52,15 +42,12 @@ pub enum LpError {
#[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),
UnknownSessionId(LpReceiverIndex),
/// Invalid state transition attempt in the state machine.
#[error("Invalid input '{input}' for current state '{state}'")]
@@ -75,27 +62,14 @@ pub enum LpError {
LpSessionProcessing,
/// State machine not found.
#[error("State machine not found for lp_id: {lp_id}")]
StateMachineNotFound { lp_id: u32 },
#[error("State machine not found for lp_id: {0}")]
StateMachineNotFound(LpReceiverIndex),
/// Ed25519 to X25519 conversion error.
#[error("Ed25519 key conversion error: {0}")]
Ed25519RecoveryError(#[from] Ed25519RecoveryError),
#[error("attempted to create an LP responder without providing a valid KEM keys")]
ResponderWithMissingKEMKeys,
/// 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 },
#[error("attempted to create an LP responder without providing a valid KEM key")]
ResponderWithMissingKEMKey,
#[error("attempted to create an LP mutual initiator without providing a valid KEM key")]
PSQMutualInitiatorMissingKemKey,
#[error(
"there are no known digests for remote's KEM key with {kem} KEM and {hash_function} hash function"
@@ -113,16 +87,56 @@ pub enum LpError {
#[from]
source: KKTError,
},
#[error(transparent)]
MalformedPacket(#[from] MalformedLpPacketError),
#[error("version {version} is not supported")]
UnsupportedVersion { version: u8 },
#[error("failed to build PSQ responder: {inner:?}")]
PSQResponderBuilderFailure { inner: BuilderError },
#[error("failed to build PSQ initiator: {inner:?}")]
PSQInitiatorBuilderFailure { inner: BuilderError },
#[error("failed to complete the PSQ handshake: {inner:?}")]
PSQHandshakeFailure { inner: HandshakeError },
#[error("failed to run the PSQ session: {inner:?}")]
PSQSessionFailure { inner: SessionError },
#[error("failed to derive a transport channel: {inner:?}")]
TransportDerivationFailure { inner: SessionError },
#[error("the initiator authenticator is not available after ingesting PSQ msg1")]
MissingInitiatorAuthenticator,
#[error("transport failure: {0}")]
TransportFailure(#[from] LpTransportError),
#[error("the current session is not in transport state")]
NotInTransport,
}
impl LpError {
pub fn kkt_psq_handshake(msg: impl Into<String>) -> Self {
Self::KKTPSQHandshake(msg.into())
}
}
pub fn unexpected_handshake_response(got: MessageType, expected: MessageType) -> LpError {
Self::KKTPSQHandshake(format!(
"received unexpected response, got: {got:?}, expected: {expected:?}"
))
impl From<HandshakeError> for LpError {
fn from(handshake_error: HandshakeError) -> Self {
Self::PSQHandshakeFailure {
inner: handshake_error,
}
}
}
impl From<SessionError> for LpError {
fn from(session_error: SessionError) -> Self {
Self::PSQSessionFailure {
inner: session_error,
}
}
}
-492
View File
@@ -1,492 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! KKT (Key Encapsulation Transport) orchestration for nym-lp sessions.
//!
//! This module provides functions to perform KKT key exchange before establishing
//! an nym-lp session. The KKT protocol allows secure distribution of post-quantum
//! KEM public keys, which are then used with PSQ to derive a strong pre-shared key
//! for the Noise protocol.
//!
//! # Protocol Flow
//!
//! 1. **Client (Initiator)**:
//! - Calls `create_request()` to generate a KKT request
//! - Sends `LpMessage::KKTRequest` to gateway
//! - Receives `LpMessage::KKTResponse` from gateway
//! - Calls `process_response()` to validate and extract gateway's KEM key
//!
//! 2. **Gateway (Responder)**:
//! - Receives `LpMessage::KKTRequest` from client
//! - Calls `handle_request()` to validate request and generate response
//! - Sends `LpMessage::KKTResponse` to client
//!
//! # Example
//!
//! ```ignore
//! use nym_lp::kkt_orchestrator::{create_request, process_response, handle_request};
//! use nym_lp::message::{KKTRequestData, KKTResponseData};
//! use nym_kkt::ciphersuite::{Ciphersuite, KEM, HashFunction, SignatureScheme, EncapsulationKey};
//!
//! // Setup ciphersuite
//! let ciphersuite = Ciphersuite::resolve_ciphersuite(
//! KEM::X25519,
//! HashFunction::Blake3,
//! SignatureScheme::Ed25519,
//! None,
//! ).unwrap();
//!
//! // Client: Create request
//! let (session_secret, client_context, request_data) = create_request(
//! ciphersuite,
//! &client_signing_key,
//! &responder_dh_public_key
//! ).unwrap();
//!
//! // Gateway: Handle request
//! let response_data = handle_request(
//! &request_data,
//! Some(&client_verification_key),
//! &gateway_signing_key,
//! &gateway_dh_private_key,
//! &gateway_kem_public_key,
//! ).unwrap();
//!
//! // Client: Process response
//! let gateway_kem_key = process_response(
//! client_context,
//! &session_secret,
//! &gateway_verification_key,
//! &expected_key_hash,
//! &response_data,
//! ).unwrap();
//! ```
use crate::LpError;
use crate::message::{KKTRequestData, KKTResponseData};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{Ciphersuite, EncapsulationKey};
use nym_kkt::context::KKTContext;
use nym_kkt::encryption::KKTSessionSecret;
use nym_kkt::kkt::{handle_kem_request, request_kem_key, validate_kem_response};
/// Creates a KKT request to obtain the responder's KEM public key.
///
/// This is called by the **client (initiator)** to begin the KKT exchange.
/// The returned context must be used when processing the response.
///
/// # Arguments
/// * `ciphersuite` - Negotiated ciphersuite (KEM, hash, signature algorithms)
/// * `signing_key` - Client's Ed25519 signing key for authentication
/// * `responder_dh_public_key` - Gateway's x25519 public key (from directory)
///
/// # Returns
/// * `KKTSessionSecret` - Session secret key to encrypt/decrypt KKT messages for this session
/// * `KKTContext` - Context to use when validating the response
/// * `KKTRequestData` - Serialized KKT request frame to send to gateway
///
/// # Errors
/// Returns `LpError::KKTError` if KKT request generation fails.
pub fn create_request(
ciphersuite: Ciphersuite,
signing_key: &ed25519::PrivateKey,
responder_dh_public_key: &x25519::PublicKey,
) -> Result<(KKTSessionSecret, KKTContext, KKTRequestData), LpError> {
// Note: Uses rand 0.9's thread_rng() to match nym-kkt's rand version
let mut rng = rand09::rng();
let (session_secret, context, request_bytes) =
request_kem_key(&mut rng, ciphersuite, signing_key, responder_dh_public_key)
.map_err(|e| LpError::KKTError(e.to_string()))?;
Ok((session_secret, context, KKTRequestData(request_bytes)))
}
/// Processes a KKT response and extracts the responder's KEM public key.
///
/// This is called by the **client (initiator)** after receiving a KKT response
/// from the gateway. It verifies the signature and validates the key hash.
///
/// # Arguments
/// * `context` - Context from the initial `create_request()` call
/// * `session_secret` - The KKT session secret key from the initial `create_request()` call
/// * `responder_vk` - Responder's Ed25519 verification key (from directory)
/// * `expected_key_hash` - Expected hash of responder's KEM key (from directory)
/// * `response_data` - Serialized KKT response frame from responder
///
/// # Returns
/// * `EncapsulationKey` - Authenticated KEM public key of the responder
///
/// # Errors
/// Returns `LpError::KKTError` if:
/// - Response deserialization fails
/// - Signature verification fails
/// - Key hash doesn't match expected value
pub fn process_response<'a>(
mut context: KKTContext,
session_secret: &KKTSessionSecret,
responder_vk: &ed25519::PublicKey,
expected_key_hash: &[u8],
response_data: &KKTResponseData,
) -> Result<EncapsulationKey<'a>, LpError> {
validate_kem_response(
&mut context,
session_secret,
responder_vk,
expected_key_hash,
&response_data.0,
)
.map_err(|e| LpError::KKTError(e.to_string()))
}
/// Handles a KKT request and generates a signed response with the responder's KEM key.
///
/// This is called by the **gateway (responder)** when receiving a KKT request
/// from a client. It validates the request signature (if authenticated) and
/// responds with the gateway's KEM public key, signed for authenticity.
///
/// # Arguments
/// * `request_data` - Serialized KKT request frame from initiator
/// * `initiator_vk` - Initiator's Ed25519 verification key (None for anonymous)
/// * `responder_signing_key` - Gateway's Ed25519 signing key
/// * `responder_dh_private_key` - Gateway's x25519 private key
/// * `responder_kem_key` - Gateway's KEM public key to send
///
/// # Returns
/// * `KKTResponseData` - Signed response frame containing the KEM public key
///
/// # Errors
/// Returns `LpError::KKTError` if:
/// - Request deserialization fails
/// - Signature verification fails (if authenticated)
/// - Response generation fails
pub fn handle_request<'a>(
request_data: &KKTRequestData,
initiator_vk: Option<&ed25519::PublicKey>,
responder_signing_key: &ed25519::PrivateKey,
responder_dh_private_key: &x25519::PrivateKey,
responder_kem_key: &EncapsulationKey<'a>,
) -> Result<KKTResponseData, LpError> {
let mut rng = rand09::rng();
// Handle the request and generate response
let response_bytes = handle_kem_request(
&mut rng,
&request_data.0,
initiator_vk,
responder_signing_key,
responder_dh_private_key,
responder_kem_key,
)
.map_err(|e| LpError::KKTError(e.to_string()))?;
Ok(KKTResponseData(response_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::peer::mock_peers;
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 (init, resp) = mock_peers();
let responder_kem_key = resp.encapsulate_kem_key().unwrap();
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,
init.ed25519.private_key(),
resp.x25519.public_key(),
)
.unwrap();
let response_data = handle_request(
&request_data,
Some(init.ed25519.public_key()),
resp.ed25519.private_key(),
resp.x25519.private_key(),
&responder_kem_key,
)
.unwrap();
// Client validates with WRONG hash
let result = process_response(
context,
&session_secret,
resp.ed25519.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");
}
}
}
+94 -323
View File
@@ -2,378 +2,149 @@
// 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 peer;
pub mod psk;
mod psq;
pub mod peer_config;
pub mod psq;
pub mod replay;
pub mod session;
mod session_integration;
pub mod session_manager;
pub mod state_machine;
pub mod transport;
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;
pub use session_manager::SessionManager;
pub use state_machine::LpStateMachine;
pub use nym_kkt_ciphersuite::{
Ciphersuite, HashFunction, HashLength, KEM, KEMKeyDigests, SignatureScheme,
};
pub const NOISE_PATTERN: &str = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
pub const NOISE_PSK_INDEX: u8 = 3;
#[cfg(any(feature = "mock", test))]
pub use replay::{ReceivingKeyCounterValidator, ReplayError};
pub use session::LpTransportSession;
pub use session_manager::SessionManager;
#[cfg(any(feature = "mock", test))]
use nym_test_utils::helpers::u64_seeded_rng_09;
#[cfg(any(feature = "mock", test))]
use crate::psq::{PSQ_MSG2_SIZE, initiator, psq_msg1_size, responder};
#[cfg(any(feature = "mock", test))]
use crate::session::PersistentSessionBinding;
#[cfg(any(feature = "mock", test))]
use libcrux_psq::{Channel, IntoSession};
#[cfg(any(feature = "mock", test))]
pub struct SessionsMock {
pub initiator: LpSession,
pub responder: LpSession,
pub initiator: LpTransportSession,
pub responder: LpTransportSession,
}
#[cfg(any(feature = "mock", test))]
impl SessionsMock {
pub fn mock_post_handshake(session_id: u32) -> SessionsMock {
pub fn mock_seeded_post_handshake(seed: u64, kem: KEM) -> SessionsMock {
use crate::peer::mock_peers;
use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey};
use crate::peer_config::LpReceiverIndex;
use rand09::Rng;
let (init, resp) = mock_peers();
let resp_remote = resp.as_remote();
let init_remote = init.as_remote();
let salt = [42u8; 32];
let session_id_bytes = session_id.to_le_bytes();
let mut init_rng = u64_seeded_rng_09(seed);
let resp_rng = u64_seeded_rng_09(seed + 1);
let receiver_index: LpReceiverIndex = init_rng.random();
let kem_keys = resp.kem_keypairs.as_ref().unwrap();
// skip KKT by just deriving the kem key locally
let kem_keys = resp.kem_psq.as_ref().unwrap();
let encapsulation_key = kem_keys.encapsulation_key(kem).unwrap();
let enc_key = encapsulation_key.clone();
let libcrux_private_key = libcrux_kem::PrivateKey::decode(
libcrux_kem::Algorithm::X25519,
kem_keys.private_key().as_bytes(),
)
.unwrap();
let decapsulation_key = DecapsulationKey::X25519(libcrux_private_key);
let initiator_ciphersuite =
initiator::build_psq_ciphersuite(&init, &resp_remote, &enc_key).unwrap();
let mut initiator =
initiator::build_psq_principal(init_rng, 1, initiator_ciphersuite).unwrap();
let libcrux_public_key = libcrux_kem::PublicKey::decode(
libcrux_kem::Algorithm::X25519,
kem_keys.public_key().as_bytes(),
)
.unwrap();
let encapsulation_key = EncapsulationKey::X25519(libcrux_public_key);
let responder_ciphersuite = responder::build_psq_ciphersuite(&resp, kem).unwrap();
let mut responder =
responder::build_psq_principal(resp_rng, 1, responder_ciphersuite).unwrap();
// INIT -> RESP: PSQ MSG1
let psq_initiator = crate::psk::psq_initiator_create_message(
init.x25519.private_key(),
&resp_remote.x25519_public,
&encapsulation_key,
init.ed25519.private_key(),
init.ed25519.public_key(),
&salt,
&session_id_bytes,
)
.unwrap();
// run PSQ
let mut payload_buf_responder = vec![0u8; 4096];
let mut payload_buf_initiator = vec![0u8; 4096];
let psk = psq_initiator.psk;
let psq_payload = psq_initiator.payload;
let outer_aead_key = crate::codec::OuterAeadKey::from_psk(&psk);
// Send first message
let mut buf = vec![0u8; psq_msg1_size(kem)];
let len_i = initiator.write_message(&[], &mut buf).unwrap();
assert_eq!(len_i, buf.len());
let noise_state_init = snow::Builder::new(crate::noise_protocol::NoiseProtocol::params())
.local_private_key(init.x25519().private_key().as_bytes())
.remote_public_key(resp_remote.x25519_public.as_bytes())
.psk(crate::NOISE_PSK_INDEX, &psk)
.build_initiator()
// Read first message
let (_, _) = responder
.read_message(&buf, &mut payload_buf_responder)
.unwrap();
let mut noise_protocol_init = crate::noise_protocol::NoiseProtocol::new(noise_state_init);
let noise_msg1 = noise_protocol_init.get_bytes_to_send().unwrap().unwrap();
let psq_responder = crate::psk::psq_responder_process_message(
resp.x25519.private_key(),
&init_remote.x25519_public,
(&decapsulation_key, &encapsulation_key),
&init_remote.ed25519_public,
&psq_payload,
&salt,
&session_id_bytes,
)
.unwrap();
// Get the authenticator out here, so we can deserialize the session later.
let Some(initiator_authenticator) = responder.initiator_authenticator() else {
panic!("No initiator authenticator found")
};
let noise_state_resp = snow::Builder::new(crate::noise_protocol::NoiseProtocol::params())
.local_private_key(resp.x25519().private_key().as_bytes())
.remote_public_key(init_remote.x25519_public.as_bytes())
.psk(crate::NOISE_PSK_INDEX, &psk)
.build_responder()
// Respond
let mut buf = [0u8; PSQ_MSG2_SIZE];
let len_r = responder.write_message(&[], &mut buf).unwrap();
assert_eq!(len_r, buf.len());
// Finalize on registration initiator
let (_, _) = initiator
.read_message(&buf, &mut payload_buf_initiator)
.unwrap();
let mut noise_protocol_resp = crate::noise_protocol::NoiseProtocol::new(noise_state_resp);
noise_protocol_resp.read_message(&noise_msg1).unwrap();
let noise_msg2 = noise_protocol_resp.get_bytes_to_send().unwrap().unwrap();
noise_protocol_init.read_message(&noise_msg2).unwrap();
let noise_msg3 = noise_protocol_init.get_bytes_to_send().unwrap().unwrap();
assert!(initiator.is_handshake_finished());
assert!(responder.is_handshake_finished());
assert!(noise_protocol_init.is_handshake_finished());
noise_protocol_resp.read_message(&noise_msg3).unwrap();
assert!(noise_protocol_resp.is_handshake_finished());
let binding = PersistentSessionBinding {
initiator_authenticator,
responder_ecdh_pk: resp_remote.x25519_public,
responder_pq_pk: Some(encapsulation_key),
initiator_pq_pk: None,
};
SessionsMock {
initiator: LpSession::new(
session_id,
initiator: LpTransportSession::new(
initiator.into_session().unwrap(),
binding.clone(),
receiver_index,
1,
outer_aead_key.clone(),
init,
resp_remote,
crate::session::PqSharedSecret::new(psq_initiator.pq_shared_secret),
noise_protocol_init,
),
responder: LpSession::new(
session_id,
)
.unwrap(),
responder: LpTransportSession::new(
responder.into_session().unwrap(),
binding,
receiver_index,
1,
outer_aead_key,
resp,
init_remote,
crate::session::PqSharedSecret::new(psq_responder.pq_shared_secret),
noise_protocol_resp,
),
)
.unwrap(),
}
}
pub fn mock_post_handshake(kem: KEM) -> SessionsMock {
Self::mock_seeded_post_handshake(1, kem)
}
// we just need a dummy 'valid' session for simpler tests
pub fn mock_initiator() -> LpSession {
Self::mock_post_handshake(1234).initiator
pub fn mock_initiator() -> LpTransportSession {
Self::mock_post_handshake(KEM::default()).initiator
}
}
#[cfg(any(feature = "mock", test))]
pub fn sessions_for_tests() -> (LpSession, LpSession) {
let sessions = SessionsMock::mock_post_handshake(69);
pub fn sessions_for_tests() -> (LpTransportSession, LpTransportSession) {
let sessions = SessionsMock::mock_post_handshake(KEM::default());
(sessions.initiator, sessions.responder)
}
#[cfg(any(feature = "mock", test))]
pub fn mock_session_for_test() -> LpSession {
pub fn mock_session_for_test() -> LpTransportSession {
SessionsMock::mock_initiator()
}
#[cfg(test)]
mod tests {
use crate::message::LpMessage;
use crate::packet::{LpHeader, LpPacket, TRAILER_LEN};
use crate::session_manager::SessionManager;
use crate::{LpError, SessionsMock, mock_session_for_test};
use bytes::BytesMut;
// Import the new standalone functions
use crate::codec::{parse_lp_packet, serialize_lp_packet};
#[test]
fn test_replay_protection_integration() {
// Create session
let mut session = mock_session_for_test();
// === 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() {
// Create session manager
let mut local_manager = SessionManager::new();
let mut remote_manager = SessionManager::new();
// Use fixed receiver_index for deterministic test
let receiver_index: u32 = 54321;
let sessions = SessionsMock::mock_post_handshake(receiver_index);
let local_session = sessions.initiator;
let remote_session = sessions.responder;
// Create a session via manager
let _ = local_manager.create_session_state_machine(local_session);
let _ = remote_manager.create_session_state_machine(remote_session);
// === 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
}
}
-892
View File
@@ -1,892 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::packet::LpHeader;
use crate::peer::LpRemotePeer;
use crate::{BOOTSTRAP_RECEIVER_IDX, LpError, LpPacket};
use bytes::{BufMut, BytesMut};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use nym_crypto::asymmetric::{ed25519, x25519};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::fmt::{self, Display};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
/// Data structure for the ClientHello message
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
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;
pub fn into_lp_packet(self, protocol_version: u8) -> LpPacket {
LpPacket::new(
LpHeader::new(
BOOTSTRAP_RECEIVER_IDX, // session_id not yet established
0, // counter starts at 0
protocol_version,
),
LpMessage::ClientHello(self),
)
}
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
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(),
})
}
/// Attempt to construct remote peer information based on the data provided in this packet.
pub fn to_remote_peer(&self) -> LpRemotePeer {
LpRemotePeer::new(self.client_ed25519_public_key, self.client_lp_public_key)
}
}
#[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,
/// General error
Error = 0x00FF,
}
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 {
pub(crate) fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
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 {
#[allow(dead_code)]
pub(crate) fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
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 {
pub(crate) fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
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 {
pub(crate) fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
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()))
}
}
/// General human-readable error message
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorPacketData {
pub message: String,
}
impl ErrorPacketData {
pub(crate) fn new(message: impl Into<String>) -> Self {
ErrorPacketData {
message: message.into(),
}
}
fn len(&self) -> usize {
// length-encoding + message
4 + self.message.len()
}
fn encode(&self, dst: &mut BytesMut) {
dst.put_u32_le(self.message.len() as u32);
dst.put_slice(self.message.as_bytes());
}
fn decode(bytes: &[u8]) -> Result<Self, LpError> {
if bytes.len() < 4 {
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ErrorPacketData. got {}",
bytes.len()
)));
}
let message_len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
if bytes[4..].len() != message_len {
return Err(LpError::DeserializationError(format!(
"Wrong number of bytes to deserialise ErrorPacketData. got {}. Expected {}",
bytes.len(),
4 + message_len
)));
}
let message = String::from_utf8_lossy(&bytes[4..]).to_string();
Ok(ErrorPacketData { message })
}
}
/// 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],
/// Target gateway's LP address (IP:port string)
pub target_lp_address: SocketAddr,
/// 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 {
pub fn new(
target_gateway_identity: ed25519::PublicKey,
target_lp_address: SocketAddr,
inner_packet_bytes: Vec<u8>,
) -> Self {
ForwardPacketData {
target_gateway_identity: target_gateway_identity.to_bytes(),
target_lp_address,
inner_packet_bytes,
}
}
fn len(&self) -> usize {
// 32 bytes target gateway identity
// +
// 1 byte length of target lp address type
// +
// {4,16} target_lp_address IPv{4,6}
// +
// 2 bytes target_lp_address port
// +
// 4 bytes of length of inner packet bytes
// +
// inner_packet_bytes.len()
match self.target_lp_address {
SocketAddr::V4(_) => 32 + 1 + 4 + 2 + 4 + self.inner_packet_bytes.len(),
SocketAddr::V6(_) => 32 + 1 + 16 + 2 + 4 + self.inner_packet_bytes.len(),
}
}
fn encode(&self, dst: &mut BytesMut) {
let (is_ipv6, ip_bytes) = match &self.target_lp_address {
SocketAddr::V4(address) => (false, address.ip().octets().to_vec()),
SocketAddr::V6(address) => (true, address.ip().octets().to_vec()),
};
dst.put_slice(&self.target_gateway_identity);
dst.put_u8(is_ipv6 as u8); // IP type , 0 for ipv4
dst.put_slice(&ip_bytes); // IP bytes
dst.put_u16_le(self.target_lp_address.port()); // Port
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 ipv4 and empty data
if bytes.len() < 43 {
// 32 + 1 + 4 + 2 + 4 + 0
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ForwardPacketData. 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_is_ipv6 = bytes[32] != 0;
let (target_lp_address, next_index) = if target_lp_address_is_ipv6 {
// IPv6, first check we have actually enough bytes
// smallest possible packet with ipv6 and empty data
if bytes.len() < 55 {
// 32 + 1 + 16 + 2 + 4 + 0
return Err(LpError::DeserializationError(format!(
"Too few bytes to deserialise ipv6 ForwardPacketData. got {}",
bytes.len()
)));
}
// SAFETY: we ensured we have sufficient data, and the length is correct for casting
#[allow(clippy::unwrap_used)]
let ipv6 = IpAddr::V6(Ipv6Addr::from_octets(bytes[33..49].try_into().unwrap()));
let port = u16::from_le_bytes([bytes[49], bytes[50]]);
(SocketAddr::new(ipv6, port), 51)
} else {
// IPv4. Length check done at the start
// SAFETY: we ensured we have sufficient data, and the length is correct for casting
#[allow(clippy::unwrap_used)]
let ipv4 = IpAddr::V4(Ipv4Addr::from_octets(bytes[33..37].try_into().unwrap()));
let port = u16::from_le_bytes([bytes[37], bytes[38]]);
(SocketAddr::new(ipv4, port), 39)
};
let inner_packet_bytes_len = u32::from_le_bytes([
bytes[next_index],
bytes[next_index + 1],
bytes[next_index + 2],
bytes[next_index + 3],
]);
if bytes[next_index + 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[next_index + 4..].len()
)));
}
let inner_packet_bytes = bytes[next_index + 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,
/// An error has occurred
Error(ErrorPacketData),
}
impl From<HandshakeData> for LpMessage {
fn from(value: HandshakeData) -> Self {
LpMessage::Handshake(value)
}
}
impl From<EncryptedDataPayload> for LpMessage {
fn from(value: EncryptedDataPayload) -> Self {
LpMessage::EncryptedData(value)
}
}
impl From<ClientHelloData> for LpMessage {
fn from(value: ClientHelloData) -> Self {
LpMessage::ClientHello(value)
}
}
impl From<KKTRequestData> for LpMessage {
fn from(value: KKTRequestData) -> Self {
LpMessage::KKTRequest(value)
}
}
impl From<KKTResponseData> for LpMessage {
fn from(value: KKTResponseData) -> Self {
LpMessage::KKTResponse(value)
}
}
impl From<ForwardPacketData> for LpMessage {
fn from(value: ForwardPacketData) -> Self {
LpMessage::ForwardPacket(value)
}
}
impl From<SubsessionKK1Data> for LpMessage {
fn from(value: SubsessionKK1Data) -> Self {
LpMessage::SubsessionKK1(value)
}
}
impl From<SubsessionKK2Data> for LpMessage {
fn from(value: SubsessionKK2Data) -> Self {
LpMessage::SubsessionKK2(value)
}
}
impl From<SubsessionReadyData> for LpMessage {
fn from(value: SubsessionReadyData) -> Self {
LpMessage::SubsessionReady(value)
}
}
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"),
LpMessage::Error(_) => write!(f, "Error"),
}
}
}
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 => &[],
LpMessage::Error(_) => &[], // Structured data, serialized in encode_content (?)
}
}
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
LpMessage::Error(_) => false,
}
}
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,
LpMessage::Error(payload) => payload.len(),
}
}
pub fn typ(&self) -> MessageType {
match self {
LpMessage::Busy => MessageType::Busy,
LpMessage::Handshake(_) => MessageType::Handshake,
LpMessage::EncryptedData(_) => MessageType::EncryptedData,
LpMessage::ClientHello(_) => MessageType::ClientHello,
LpMessage::KKTRequest(_) => MessageType::KKTRequest,
LpMessage::KKTResponse(_) => MessageType::KKTResponse,
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,
LpMessage::Error(_) => MessageType::Error,
}
}
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 */ }
LpMessage::Error(data) => data.encode(dst),
}
}
/// 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)
}
MessageType::Error => Ok(LpMessage::Error(ErrorPacketData::decode(content)?)),
}
}
}
/// 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);
}
}
-337
View File
@@ -1,337 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Sans-IO Noise protocol state machine, adapted from noise-psq.
use snow::{TransportState, params::NoiseParams};
use thiserror::Error;
// --- Error Definition ---
/// Errors related to the Noise protocol state machine.
#[derive(Error, Debug)]
pub enum NoiseError {
#[error("encountered a Noise decryption error")]
DecryptionError,
#[error("encountered a Noise Protocol error - {0}")]
ProtocolError(snow::Error),
#[error("operation is invalid in the current protocol state")]
IncorrectStateError,
#[error("attempted transport mode operation without real PSK injection")]
PskNotInjected,
#[error("Other Noise-related error: {0}")]
Other(String),
#[error("session is read-only after demotion")]
SessionReadOnly,
}
impl From<snow::Error> for NoiseError {
fn from(err: snow::Error) -> Self {
match err {
snow::Error::Decrypt => NoiseError::DecryptionError,
err => NoiseError::ProtocolError(err),
}
}
}
// --- Protocol State and Structs ---
/// Represents the possible states of the Noise protocol machine.
#[derive(Debug)]
pub enum NoiseProtocolState {
/// The protocol is currently performing the handshake.
/// Contains the Snow handshake state.
Handshaking(Box<snow::HandshakeState>),
/// The handshake is complete, and the protocol is in transport mode.
/// Contains the Snow transport state.
Transport(TransportState),
/// The protocol has encountered an unrecoverable error.
/// Stores the error description.
Failed(String),
}
/// The core sans-io Noise protocol state machine.
#[derive(Debug)]
pub struct NoiseProtocol {
state: NoiseProtocolState,
// We might need buffers for incoming/outgoing data later if we add internal buffering
// read_buffer: Vec<u8>,
// write_buffer: Vec<u8>,
}
/// Represents the outcome of processing received bytes via `read_message`.
#[derive(Debug, PartialEq)]
pub enum ReadResult {
/// A handshake or transport message was successfully processed, but yielded no application data
/// and did not complete the handshake.
NoOp,
/// A complete application data message was decrypted.
DecryptedData(Vec<u8>),
/// The handshake successfully completed during this read operation.
HandshakeComplete,
// NOTE: NeedMoreBytes variant removed as read_message expects full frames.
}
// --- Implementation ---
impl NoiseProtocol {
pub fn params() -> NoiseParams {
// SAFETY: the hardcoded pattern must be valid
// and if for some reason it was not, we MUST fail non-gracefully for there is no possible recovery
#[allow(clippy::unwrap_used)]
crate::NOISE_PATTERN.parse().unwrap()
}
/// 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)),
}
}
fn prepare_handshake_state<'a>(
local_private_key: &'a [u8],
remote_public_key: &'a [u8],
psk: &'a [u8],
) -> snow::Builder<'a> {
let psk_index = crate::NOISE_PSK_INDEX;
let noise_params = NoiseProtocol::params();
snow::Builder::new(noise_params)
.local_private_key(local_private_key)
.remote_public_key(remote_public_key)
.psk(psk_index, psk)
}
/// Builds a new `NoiseProtocol` initiator instance with the provided local private key,
/// remote public key and psk
pub fn build_new_initiator(
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<Self, NoiseError> {
let handshake_state =
Self::prepare_handshake_state(local_private_key, remote_public_key, psk)
.build_initiator()?;
Ok(Self::new(handshake_state))
}
/// Builds a new `NoiseProtocol` responder instance with the provided local private key,
/// remote public key and psk
pub fn build_new_responder(
local_private_key: &[u8],
remote_public_key: &[u8],
psk: &[u8],
) -> Result<Self, NoiseError> {
let handshake_state =
Self::prepare_handshake_state(local_private_key, remote_public_key, psk)
.build_responder()?;
Ok(Self::new(handshake_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),
}
}
}
-289
View File
@@ -1,289 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::LpError;
use crate::message::{LpMessage, MessageType};
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],
}
}
pub fn typ(&self) -> MessageType {
self.message.typ()
}
/// 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, protocol_version: u8) -> Self {
Self {
protocol_version,
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,
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
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MalformedLpPacketError {
#[error("failed to deserialise received data: {0}")]
DeserialisationFailure(String),
#[error("provided insufficient data to fully deserialise the struct")]
InsufficientData,
#[error("{0} is not a valid LpDataKind")]
InvalidLpDataKind(u16),
#[error("invalid payload size: expected {expected}, got {actual}")]
InvalidPayloadSize { expected: usize, actual: usize },
/// 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 },
}
impl MalformedLpPacketError {
pub fn invalid_data_kind(message_type: u16) -> Self {
MalformedLpPacketError::InvalidLpDataKind(message_type)
}
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::packet::version;
use crate::{packet::error::MalformedLpPacketError, peer_config::LpReceiverIndex};
use bytes::{BufMut, BytesMut};
use tracing::warn;
/// 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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OuterHeader {
pub receiver_idx: LpReceiverIndex,
pub counter: u64,
}
impl OuterHeader {
pub const SIZE: usize = 12; // receiver_idx(4) + counter(8)
pub fn new(receiver_idx: LpReceiverIndex, counter: u64) -> Self {
Self {
receiver_idx,
counter,
}
}
pub fn parse(src: &[u8]) -> Result<Self, MalformedLpPacketError> {
if src.len() < Self::SIZE {
return Err(MalformedLpPacketError::InsufficientData);
}
#[allow(clippy::unwrap_used)]
Ok(Self {
receiver_idx: LpReceiverIndex::from_le_bytes(src[0..4].try_into().unwrap()),
counter: u64::from_le_bytes(src[4..12].try_into().unwrap()),
})
}
pub fn to_bytes(&self) -> [u8; Self::SIZE] {
let mut bytes = [0u8; Self::SIZE];
bytes[0..4].copy_from_slice(&self.receiver_idx.to_le_bytes());
bytes[4..12].copy_from_slice(&self.counter.to_le_bytes());
bytes
}
/// Encode directly into a BytesMut buffer
pub fn encode(&self, dst: &mut BytesMut) {
dst.put_slice(&self.receiver_idx.to_le_bytes());
dst.put_slice(&self.counter.to_le_bytes());
}
}
/// InnerHeader header (8 bytes) - encrypted, used for message parsing
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InnerHeader {
pub protocol_version: u8,
pub reserved: [u8; 3],
}
impl InnerHeader {
pub const SIZE: usize = 4; // protocol_version(1) + reserved(3)
pub fn encode(&self, dst: &mut BytesMut) {
// protocol version
dst.put_u8(self.protocol_version);
// reserved
dst.put_slice(&self.reserved);
}
pub fn parse(src: &[u8]) -> Result<Self, MalformedLpPacketError> {
if src.len() < Self::SIZE {
return Err(MalformedLpPacketError::InsufficientData);
}
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(MalformedLpPacketError::IncompatibleFuturePacketVersion {
got: protocol_version,
highest_supported: version::CURRENT,
});
}
if protocol_version < version::CURRENT {
return Err(MalformedLpPacketError::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:?}");
}
Ok(InnerHeader {
protocol_version,
reserved,
})
}
}
/// 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)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LpHeader {
pub outer: OuterHeader,
pub inner: InnerHeader,
}
impl LpHeader {
pub fn new(receiver_idx: LpReceiverIndex, counter: u64, protocol_version: u8) -> Self {
Self {
outer: OuterHeader {
receiver_idx,
counter,
},
inner: InnerHeader {
protocol_version,
reserved: [0u8; 3],
},
}
}
pub(crate) fn dbg_encode(&self, dst: &mut BytesMut) {
self.outer.encode(dst);
self.inner.encode(dst);
}
/// Get the counter value from the header
pub fn counter(&self) -> u64 {
self.outer.counter
}
/// Get the sender index from the header
pub fn receiver_idx(&self) -> LpReceiverIndex {
self.outer.receiver_idx
}
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::packet::error::MalformedLpPacketError;
use bytes::{BufMut, Bytes, BytesMut};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
#[derive(Debug, Clone, PartialEq)]
pub struct LpMessageHeader {
pub kind: LpMessageType,
pub message_attributes: [u8; 14],
}
impl LpMessageHeader {
pub const SIZE: usize = 16; // message_kind(2) + message_attributes(14)
pub fn new(kind: LpMessageType, message_attributes: [u8; 14]) -> Self {
Self {
kind,
message_attributes,
}
}
pub fn new_no_attributes(kind: LpMessageType) -> Self {
Self {
kind,
message_attributes: [0; 14],
}
}
/// Encode directly into a BytesMut buffer
pub fn encode(&self, dst: &mut BytesMut) {
dst.put_u16_le(self.kind as u16);
dst.put_slice(&self.message_attributes);
}
pub fn parse(src: &[u8]) -> Result<Self, MalformedLpPacketError> {
if src.len() < Self::SIZE {
return Err(MalformedLpPacketError::InsufficientData);
}
let raw_kind = u16::from_le_bytes([src[0], src[1]]);
let kind = LpMessageType::try_from(raw_kind)
.map_err(|_| MalformedLpPacketError::invalid_data_kind(raw_kind))?;
#[allow(clippy::unwrap_used)]
let message_attributes = src[2..16].try_into().unwrap();
Ok(Self {
kind,
message_attributes,
})
}
}
/// Represent application data being sent in Transport mode
#[derive(Debug, Clone, PartialEq)]
pub struct LpMessage {
pub header: LpMessageHeader,
pub content: Bytes,
}
impl AsRef<[u8]> for LpMessage {
fn as_ref(&self) -> &[u8] {
&self.content
}
}
impl LpMessage {
pub fn new(kind: LpMessageType, content: impl Into<Bytes>) -> Self {
Self {
header: LpMessageHeader::new_no_attributes(kind),
content: content.into(),
}
}
pub fn encode(&self, dst: &mut BytesMut) {
self.header.encode(dst);
dst.put_slice(&self.content);
}
pub fn decode(src: &[u8]) -> Result<Self, MalformedLpPacketError> {
let header = LpMessageHeader::parse(src)?;
let content = src[LpMessageHeader::SIZE..].to_vec().into();
Ok(Self { header, content })
}
pub fn kind(&self) -> LpMessageType {
self.header.kind
}
pub fn new_opaque(content: impl Into<Bytes>) -> Self {
Self::new(LpMessageType::Opaque, content)
}
pub fn new_registration(data: impl Into<Bytes>) -> Self {
Self::new(LpMessageType::Registration, data)
}
pub fn new_forward(data: impl Into<Bytes>) -> Self {
Self::new(LpMessageType::Forward, data)
}
pub(crate) fn len(&self) -> usize {
LpMessageHeader::SIZE + self.content.len()
}
}
/// Represent kind of application data being sent in Transport mode
#[derive(Clone, Copy, PartialEq, Eq, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u16)]
pub enum LpMessageType {
Opaque = 0,
Registration = 1,
Forward = 2,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpectedResponseSize {
/// We've sent a handshake message and expect response of predefined size
Handshake(u32),
/// We've sent a transport message and the response is length-prefixed
Transport,
}
impl ExpectedResponseSize {
pub fn to_bytes(&self) -> [u8; 4] {
// there are no empty handshake messages, so we use 0 bytes to indicate Transport variant
match self {
ExpectedResponseSize::Handshake(size) => size.to_le_bytes(),
ExpectedResponseSize::Transport => [0u8; 4],
}
}
pub fn from_bytes(b: [u8; 4]) -> Self {
let size = u32::from_le_bytes(b);
if size == 0 {
ExpectedResponseSize::Transport
} else {
ExpectedResponseSize::Handshake(size)
}
}
}
/// Packet forwarding request with embedded inner LP packet
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForwardPacketData {
/// Target gateway's LP address (IP:port string)
pub target_lp_address: SocketAddr,
/// Indication of the expected size of the response
/// to allow the proxy to read correct data from the stream
pub expected_response_size: ExpectedResponseSize,
/// 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 {
pub fn new(
target_lp_address: SocketAddr,
expected_response_size: ExpectedResponseSize,
inner_packet_bytes: Vec<u8>,
) -> Self {
ForwardPacketData {
target_lp_address,
expected_response_size,
inner_packet_bytes,
}
}
// 0 || [4B ipv4] || [2B port] || [4B res size] || [4B plen] || payload
// 1 || [16B ipv6] || [2B port] || [4B res size] || [4B plen] || payload
fn encode(&self, dst: &mut BytesMut) {
let (is_ipv6, ip_bytes) = match &self.target_lp_address {
SocketAddr::V4(address) => (false, address.ip().octets().to_vec()),
SocketAddr::V6(address) => (true, address.ip().octets().to_vec()),
};
dst.put_u8(is_ipv6 as u8); // IP type , 0 for ipv4
dst.put_slice(&ip_bytes); // IP bytes
dst.put_u16_le(self.target_lp_address.port()); // Port
dst.put_slice(&self.expected_response_size.to_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(b: &[u8]) -> Result<Self, MalformedLpPacketError> {
// smallest possible packet with ipv4 and empty data
if b.len() < 15 {
// 1 + 4 + 2 + 4 + 4 + 0
return Err(MalformedLpPacketError::DeserialisationFailure(format!(
"Too few bytes to deserialise ForwardPacketData. got {}",
b.len()
)));
}
let target_lp_address_is_ipv6 = b[0] != 0;
let (target_lp_address, i) = if target_lp_address_is_ipv6 {
// IPv6, first check we have actually enough bytes
// smallest possible packet with ipv6 and empty data
if b.len() < 27 {
// 1 + 16 + 2 + 4 + 4+ 0
return Err(MalformedLpPacketError::DeserialisationFailure(format!(
"Too few bytes to deserialise ipv6 ForwardPacketData. got {}",
b.len()
)));
}
// Ipv6Addr::from_octets is not available until 1.91 so we have to use
// the slightly less obvious u128 conversion
// SAFETY: we ensured we have sufficient data, and the length is correct for casting
#[allow(clippy::unwrap_used)]
let ipv6 = IpAddr::V6(Ipv6Addr::from_bits(u128::from_be_bytes(
b[1..17].try_into().unwrap(),
)));
let port = u16::from_le_bytes([b[17], b[18]]);
(SocketAddr::new(ipv6, port), 19)
} else {
// IPv4. Length check done at the start
// Ipv4Addr::from_octets is not available until 1.91
let ipv4 = IpAddr::V4(Ipv4Addr::new(b[1], b[2], b[3], b[4]));
let port = u16::from_le_bytes([b[5], b[6]]);
(SocketAddr::new(ipv4, port), 7)
};
let expected_response_size_bytes = [b[i], b[i + 1], b[i + 2], b[i + 3]];
let inner_packet_bytes_len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]);
if b[i + 8..].len() != inner_packet_bytes_len as usize {
return Err(MalformedLpPacketError::DeserialisationFailure(format!(
"Expected {inner_packet_bytes_len} bytes to deserialise inner packet bytes of ForwardPacketData. got {}",
b[i + 8..].len()
)));
}
let inner_packet_bytes = b[i + 8..].to_vec();
Ok(ForwardPacketData {
target_lp_address,
expected_response_size: ExpectedResponseSize::from_bytes(expected_response_size_bytes),
inner_packet_bytes,
})
}
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::packet::utils::format_debug_bytes;
use bytes::{BufMut, BytesMut};
use std::fmt::{Debug, Formatter};
pub use error::MalformedLpPacketError;
pub use header::{InnerHeader, LpHeader, OuterHeader};
pub use message::{ForwardPacketData, LpMessage};
pub mod error;
pub mod header;
pub mod message;
pub mod replay;
pub mod utils;
pub mod version {
/// The current version of the Lewes Protocol that is put into each new constructed header.
pub const CURRENT: u8 = 1;
}
#[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(crate) const UDP_PAYLOAD_SIZE: usize = MTU - UDP_OVERHEAD;
#[derive(Clone)]
pub struct EncryptedLpPacket {
// The outer header that's sent in plaintext
pub(crate) outer_header: OuterHeader,
// The ciphertext containing the inner header and the payload
pub(crate) ciphertext: Vec<u8>,
}
impl std::fmt::Debug for EncryptedLpPacket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", format_debug_bytes(&self.debug_bytes())?)
}
}
impl EncryptedLpPacket {
pub fn new(outer_header: OuterHeader, ciphertext: Vec<u8>) -> EncryptedLpPacket {
EncryptedLpPacket {
outer_header,
ciphertext,
}
}
pub fn encoded_length(&self) -> usize {
OuterHeader::SIZE + self.ciphertext.len()
}
pub(crate) fn debug_bytes(&self) -> Vec<u8> {
let mut bytes = BytesMut::new();
self.encode(&mut bytes);
bytes.freeze().to_vec()
}
pub fn encode(&self, dst: &mut BytesMut) {
self.outer_header.encode(dst);
dst.put_slice(&self.ciphertext)
}
pub fn ciphertext(&self) -> &[u8] {
&self.ciphertext
}
pub fn outer_header(&self) -> OuterHeader {
self.outer_header
}
}
#[derive(Clone, PartialEq)]
pub struct LpPacket {
pub(crate) header: LpHeader,
pub(crate) message: LpMessage,
}
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 }
}
pub fn message(&self) -> &LpMessage {
&self.message
}
pub fn into_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.dbg_encode(&mut bytes);
bytes.freeze().to_vec()
}
pub(crate) fn dbg_encode(&self, dst: &mut BytesMut) {
self.header.dbg_encode(dst);
self.message.encode(dst)
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::{LpError, packet::LpPacket, replay::ReceivingKeyCounterValidator};
pub trait LpPacketReplayExt {
/// 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.
fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError>;
/// Mark packet as received in the replay protection validator
///
/// This should be called after a packet has been successfully processed.
fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError>;
}
impl LpPacketReplayExt for LpPacket {
/// 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.
fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError> {
validator.will_accept_branchless(self.header().outer.counter)?;
Ok(())
}
/// Mark packet as received in the replay protection validator
///
/// This should be called after a packet has been successfully processed.
fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError> {
validator.mark_did_receive_branchless(self.header().outer.counter)?;
Ok(())
}
}
@@ -1,8 +1,4 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt;
use std::fmt::Write;
use std::fmt::{self, Write};
pub fn format_debug_bytes(bytes: &[u8]) -> Result<String, fmt::Error> {
let mut out = String::new();
+84 -93
View File
@@ -1,102 +1,93 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: GPL-3.0-only
use crate::{ClientHelloData, LpError};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{Ciphersuite, KEM, KEMKeyDigests, SignatureScheme, SigningKeyDigests};
use std::collections::HashMap;
use crate::LpError;
use nym_kkt::keys::EncapsulationKey;
use nym_kkt_ciphersuite::{Ciphersuite, KEM, KEMKeyDigests};
use std::collections::BTreeMap;
use std::fmt::Debug;
use std::sync::Arc;
pub use libcrux_psq::handshake::types::{DHKeyPair, DHPublicKey};
pub use nym_kkt::key_utils::{
generate_keypair_mceliece, generate_keypair_mlkem, generate_lp_keypair_x25519,
};
pub use nym_kkt::keys::KEMKeys;
/// Representation of a local Lewes Protocol peer
/// encapsulating all the known information and keys.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct LpLocalPeer {
/// Local Ed25519 keys for PSQ authentication
pub(crate) ed25519: Arc<ed25519::KeyPair>,
pub(crate) ciphersuite: Ciphersuite,
/// Local x25519 keys (Noise static key)
pub(crate) x25519: Arc<x25519::KeyPair>,
pub(crate) x25519: Arc<DHKeyPair>,
/// Local KEM key used for PSQ
pub(crate) kem_psq: Option<Arc<x25519::KeyPair>>,
/// Local KEM keys used for PSQ
pub(crate) kem_keypairs: Option<KEMKeys>,
}
impl LpLocalPeer {
pub fn new(ed25519: Arc<ed25519::KeyPair>, x25519: Arc<x25519::KeyPair>) -> Self {
pub fn new(ciphersuite: Ciphersuite, x25519: Arc<DHKeyPair>) -> Self {
LpLocalPeer {
ed25519,
ciphersuite,
x25519,
kem_psq: None,
kem_keypairs: Default::default(),
}
}
pub fn build_client_hello_data(&self, timestamp: u64) -> ClientHelloData {
ClientHelloData::new_with_fresh_salt(
*self.x25519().public_key(),
*self.ed25519().public_key(),
timestamp,
)
}
#[must_use]
pub fn with_kem_psq_key(mut self, key: Arc<x25519::KeyPair>) -> Self {
self.kem_psq = Some(key);
pub fn with_kem_keys(mut self, kem_keys: KEMKeys) -> Self {
self.kem_keypairs = Some(kem_keys);
self
}
pub fn ed25519(&self) -> &Arc<ed25519::KeyPair> {
&self.ed25519
}
pub fn x25519(&self) -> &Arc<x25519::KeyPair> {
&self.x25519
}
/// Returns the reference to the KEM Public key of the peer (if available).
pub fn get_kem_key_handle(&self) -> Result<&x25519::PublicKey, LpError> {
self.kem_psq
pub fn kem_key(&self, kem: KEM) -> Option<EncapsulationKey> {
self.kem_keypairs
.as_ref()
.map(|kp| kp.public_key())
.ok_or(LpError::ResponderWithMissingKEMKey)
.and_then(|k| k.encapsulation_key(kem))
}
pub fn encoded_kem_key(&self, kem: KEM) -> Option<&[u8]> {
self.kem_keypairs
.as_ref()
.and_then(|k| k.encoded_encapsulation_key(kem))
}
pub fn x25519(&self) -> &Arc<DHKeyPair> {
&self.x25519
}
/// Convert this `LpLocalPeer` into a valid `LpRemotePeer` that can be used within tests
#[doc(hidden)]
pub fn as_remote(&self) -> LpRemotePeer {
let expected_kem_key_digests = match &self.kem_psq {
None => HashMap::new(),
Some(kem_keys) => {
let mut digests = HashMap::new();
digests.insert(
KEM::X25519,
nym_kkt::key_utils::produce_key_digests(kem_keys.public_key().as_bytes()),
);
digests
}
};
let mut expected_signing_key_digests = HashMap::new();
expected_signing_key_digests.insert(
SignatureScheme::Ed25519,
nym_kkt::key_utils::produce_key_digests(self.ed25519.public_key().as_bytes()),
);
let expected_kem_key_digests = self
.kem_keypairs
.as_ref()
.map(|k| k.encapsulation_keys_digests())
.unwrap_or_default();
LpRemotePeer {
ed25519_public: *self.ed25519.public_key(),
x25519_public: *self.x25519.public_key(),
x25519_public: self.x25519.pk,
expected_kem_key_digests,
expected_signing_key_digests,
}
}
// this is only exposed in tests as ideally we should be storing the proper types to begin with
#[cfg(test)]
pub fn encapsulate_kem_key(&self) -> Option<nym_kkt::ciphersuite::EncapsulationKey<'_>> {
let pk_bytes = self.kem_psq.as_ref()?.public_key().to_bytes();
let libcrux_pk =
libcrux_kem::PublicKey::decode(libcrux_kem::Algorithm::X25519, &pk_bytes).ok()?;
pub fn ciphersuite(&self) -> Ciphersuite {
self.ciphersuite
}
}
Some(nym_kkt::ciphersuite::EncapsulationKey::X25519(libcrux_pk))
impl Debug for LpLocalPeer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LpLocalPeer")
.field("ciphersuite", &self.ciphersuite)
.field(
"x25519",
&bs58::encode(self.x25519.pk.as_ref()).into_string(),
)
.field("kem_keypairs", &self.kem_keypairs)
.finish()
}
}
@@ -104,45 +95,31 @@ impl LpLocalPeer {
/// encapsulating all the known information and keys.
#[derive(Debug, Clone)]
pub struct LpRemotePeer {
/// Remote Ed25519 public key for PSQ authentication
pub(crate) ed25519_public: ed25519::PublicKey,
/// Remote X25519 public key (Noise static key)
pub(crate) x25519_public: x25519::PublicKey,
pub(crate) x25519_public: DHPublicKey,
/// Expected digests of the remote's KEM key
pub(crate) expected_kem_key_digests: HashMap<KEM, KEMKeyDigests>,
/// Expected digests of the remote's signing key
pub(crate) expected_signing_key_digests: HashMap<SignatureScheme, SigningKeyDigests>,
pub(crate) expected_kem_key_digests: BTreeMap<KEM, KEMKeyDigests>,
}
impl LpRemotePeer {
pub fn new(ed25519_public: ed25519::PublicKey, x25519_public: x25519::PublicKey) -> Self {
pub fn new(x25519_public: DHPublicKey) -> Self {
LpRemotePeer {
ed25519_public,
x25519_public,
expected_kem_key_digests: Default::default(),
expected_signing_key_digests: Default::default(),
}
}
pub fn ed25519(&self) -> ed25519::PublicKey {
self.ed25519_public
}
pub fn x25519(&self) -> x25519::PublicKey {
self.x25519_public
pub fn x25519(&self) -> &DHPublicKey {
&self.x25519_public
}
#[must_use]
pub fn with_key_digests(
mut self,
expected_kem_key_digests: HashMap<KEM, KEMKeyDigests>,
expected_signing_key_digests: HashMap<SignatureScheme, SigningKeyDigests>,
expected_kem_key_digests: BTreeMap<KEM, KEMKeyDigests>,
) -> Self {
self.expected_kem_key_digests = expected_kem_key_digests;
self.expected_signing_key_digests = expected_signing_key_digests;
self
}
@@ -166,32 +143,46 @@ impl LpRemotePeer {
.ok_or(LpError::NoKnownKEMKeyDigests { kem, hash_function })
.cloned()
}
pub fn kem_key_digests(&self) -> &BTreeMap<KEM, KEMKeyDigests> {
&self.expected_kem_key_digests
}
}
impl From<DHPublicKey> for LpRemotePeer {
fn from(value: DHPublicKey) -> Self {
LpRemotePeer {
x25519_public: value,
expected_kem_key_digests: Default::default(),
}
}
}
#[cfg(any(feature = "mock", test))]
pub fn mock_peer() -> LpLocalPeer {
// use deterministic rng
let mut rng = nym_test_utils::helpers::deterministic_rng();
let mut rng = nym_test_utils::helpers::deterministic_rng_09();
random_peer(&mut rng)
}
#[cfg(any(feature = "mock", test))]
pub fn random_peer<R: rand::CryptoRng + rand::RngCore>(rng: &mut R) -> LpLocalPeer {
let ed25519 = Arc::new(ed25519::KeyPair::new(rng));
let x25519 = Arc::new(ed25519.to_x25519());
let kem_psq = Some(x25519.clone());
pub fn random_peer<R: rand09::CryptoRng + rand09::RngCore>(rng: &mut R) -> LpLocalPeer {
let x25519 = Arc::new(nym_kkt::key_utils::generate_lp_keypair_x25519(rng));
LpLocalPeer {
ed25519,
ciphersuite: Ciphersuite::default(),
x25519,
kem_psq,
kem_keypairs: Some(KEMKeys::new(
nym_kkt::key_utils::generate_keypair_mceliece(rng),
nym_kkt::key_utils::generate_keypair_mlkem(rng),
)),
}
}
#[cfg(any(feature = "mock", test))]
pub fn mock_peers() -> (LpLocalPeer, LpLocalPeer) {
// use deterministic rng
let mut rng = nym_test_utils::helpers::deterministic_rng();
let mut rng = nym_test_utils::helpers::deterministic_rng_09();
(random_peer(&mut rng), random_peer(&mut rng))
}
+486
View File
@@ -0,0 +1,486 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::LpError;
use libcrux_psq::handshake::types::Authenticator;
use nym_crypto::hkdf::blake3::derive_key_blake3_multi_input;
use nym_kkt::keys::EncapsulationKey;
use rand09::{self, CryptoRng, Rng};
use tls_codec::Serialize;
use zeroize::Zeroize;
pub type LpReceiverIndex = u32;
pub const MAX_HOPS: u8 = 16;
pub const LP_PEER_CONFIG_SIZE: usize = 20;
const SEED_LEN: usize = 16;
const CONFIG_LEN: usize = 1;
const FILLER_LEN: usize = LP_PEER_CONFIG_SIZE - SEED_LEN - CONFIG_LEN;
const RECEIVER_INDEX_DERIVATION_CONTEXT: &str = "LP_PEER_CONFIG_RECEIVER_INDEX_DERIVATION_V1";
// 20 bytes
#[derive(PartialEq)]
pub struct LpPeerConfig {
// The first 4 fields will be packed in one u8
// with 1 bit left at the end
// Determine the hop id.
// Should be 0 if node_initiator is true
// Should be > 1 && < 16 if is_exit is true
hop_id: u8,
// Determine if the recipient should be an exit node
is_exit: bool,
// Determine if we are establishing a node<>node connection
// Should be false if is_exit is true
node_initiator: bool,
// Enable censorship resistance countermeasures
censorship_resistance: bool,
// If we add more config params later, we can use this
filler: [u8; FILLER_LEN],
seed: [u8; SEED_LEN],
}
impl LpPeerConfig {
/// Creates a new client to entry config.
/// Sets `hop_id` to 0.
/// Input: censorship_resistance flag to enable censorship resistance features.
pub fn new_client_to_entry<R>(rng: &mut R, censorship_resistance: bool) -> Self
where
R: Rng + CryptoRng,
{
Self::build(
0,
false,
false,
censorship_resistance,
rng.random(),
rng.random(),
)
}
/// Creates a new client to exit config.
/// Inputs:
/// hop_id: this value must be in the range (1..=15). This function returns an error if this is not the case.
/// censorship_resistance flag to enable censorship resistance features.
pub fn new_client_to_exit<R>(
rng: &mut R,
hop_id: u8,
censorship_resistance: bool,
) -> Result<Self, LpError>
where
R: Rng + CryptoRng,
{
Self::new(rng, hop_id, true, false, censorship_resistance)
}
/// Creates a new client to an intermediate node config.
/// Inputs:
/// hop_id: this value must be in the range (1..=14). This function returns an error if this is not the case.
/// censorship_resistance flag to enable censorship resistance features.
pub fn new_client_to_intermediate<R>(
rng: &mut R,
hop_id: u8,
censorship_resistance: bool,
) -> Result<Self, LpError>
where
R: Rng + CryptoRng,
{
if hop_id == 0 || hop_id == 15 {
Err(LpError::Internal(format!(
"An intermediate hop cannot be the first or last hop. Requested hop id {hop_id}"
)))
} else {
Self::new(rng, hop_id, false, false, censorship_resistance)
}
}
/// Creates a new node to node config.
/// Censorship resistance features are disabled by default between nodes.
pub fn new_node_to_node<R>(rng: &mut R) -> Result<Self, LpError>
where
R: Rng + CryptoRng,
{
// no need for censorship resistance between nodes (for now)
// hop_id between nodes is 0
Self::new(rng, 0, false, true, false)
}
pub fn new<R>(
rng: &mut R,
hop_id: u8,
is_exit: bool,
node_initiator: bool,
censorship_resistance: bool,
) -> Result<Self, LpError>
where
R: Rng + CryptoRng,
{
Self::build_checked(
hop_id,
is_exit,
node_initiator,
censorship_resistance,
rng.random(),
rng.random(),
)
}
fn build(
hop_id: u8,
is_exit: bool,
node_initiator: bool,
censorship_resistance: bool,
seed: [u8; SEED_LEN],
filler: [u8; FILLER_LEN],
) -> Self {
Self {
hop_id,
is_exit,
node_initiator,
censorship_resistance,
filler,
seed,
}
}
fn build_checked(
hop_id: u8,
is_exit: bool,
node_initiator: bool,
censorship_resistance: bool,
seed: [u8; SEED_LEN],
filler: [u8; FILLER_LEN],
) -> Result<Self, LpError> {
if node_initiator && is_exit {
Err(LpError::Internal(
"A node cannot establish an exit node for itself.".into(),
))
} else if node_initiator && hop_id != 0 {
Err(LpError::Internal(
"Hop id in node to node connections must be zero.".into(),
))
} else if !node_initiator && hop_id >= MAX_HOPS {
Err(LpError::Internal(format!(
"Requested hop index ({}) is greater than the allowed maximum {}.",
hop_id,
MAX_HOPS - 1
)))
} else if !node_initiator && is_exit && hop_id == 0 {
Err(LpError::Internal(
"Hop id for exit node cannot be zero.".into(),
))
} else if !node_initiator && !is_exit && hop_id == 15 {
Err(LpError::Internal(
"The hop with id 15 must be an exit node.".into(),
))
} else {
Ok(Self::build(
hop_id,
is_exit,
node_initiator,
censorship_resistance,
seed,
filler,
))
}
}
pub fn hop_id(&self) -> u8 {
self.hop_id
}
pub fn seed(&self) -> &[u8; SEED_LEN] {
&self.seed
}
pub fn serialize(&self) -> [u8; LP_PEER_CONFIG_SIZE] {
let mut output_bytes = [0u8; LP_PEER_CONFIG_SIZE];
output_bytes[0..4].copy_from_slice(&self.pack_config());
output_bytes[4..].copy_from_slice(&self.seed);
output_bytes
}
pub fn deserialize(bytes: &[u8]) -> Result<Self, LpError> {
if bytes.len() != LP_PEER_CONFIG_SIZE {
return Err(LpError::DeserializationError(format!(
"Invalid Lp Config Length ({}), expected ({})",
bytes.len(),
LP_PEER_CONFIG_SIZE
)));
}
let (hop_id, is_exit, node_initiator, censorship_resistance) =
Self::unpack_first_byte(bytes[0]);
let mut filler = [0u8; FILLER_LEN];
filler.copy_from_slice(&bytes[CONFIG_LEN..CONFIG_LEN + FILLER_LEN]);
let mut seed = [0u8; SEED_LEN];
seed.copy_from_slice(&bytes[CONFIG_LEN + FILLER_LEN..LP_PEER_CONFIG_SIZE]);
Self::build_checked(
hop_id,
is_exit,
node_initiator,
censorship_resistance,
seed,
filler,
)
}
fn pack_config(&self) -> [u8; 4] {
[
self.pack_first_byte(),
self.filler[0],
self.filler[1],
self.filler[2],
]
}
fn pack_first_byte(&self) -> u8 {
let mut byte = self.hop_id;
// Set the 5th bit to determine if the node is an exit node
if self.is_exit {
byte |= 0b0001_0000;
}
// Set the 6th bit to determine if we're establishing a node to node connection
if self.node_initiator {
byte |= 0b0010_0000;
}
// Set the 7th bit to determine if we should use censorship resistance measures
if self.censorship_resistance {
byte |= 0b0100_0000;
}
// There will be 1 free bit at the end
byte
}
fn unpack_first_byte(byte: u8) -> (u8, bool, bool, bool) {
// extract 4 bits
let hop_id = byte & 0b0000_1111;
// extract 5th bit
let is_exit = (byte & 0b0001_0000) >> 4 == 1;
// extract 6th bit
let node_initiator = (byte & 0b0010_0000) >> 5 == 1;
// extract 7th bit
let censorship_resistance = (byte & 0b0100_0000) >> 6 == 1;
// If we need to use the last bit, we can add something here
(hop_id, is_exit, node_initiator, censorship_resistance)
}
pub fn is_client_entry(&self) -> bool {
self.hop_id == 0 && !self.is_exit && !self.node_initiator
}
pub fn is_client_intermediate_node(&self) -> bool {
self.hop_id > 0 && !self.is_exit && !self.node_initiator
}
pub fn is_client_exit(&self) -> bool {
self.hop_id > 0 && self.is_exit && !self.node_initiator
}
pub fn is_node_to_node(&self) -> bool {
self.hop_id == 0 && !self.is_exit && self.node_initiator
}
// This returns a LpReceiverIndex made out of the first 4 bytes from
// KDF(RECEIVER_INDEX_DERIVATION_CONTEXT, initiator_pub_key || responder_kem_key, seed)
pub fn derive_receiver_index(
&self,
initiator_public_key: &Authenticator,
responder_kem_pk: &EncapsulationKey,
) -> Result<LpReceiverIndex, LpError> {
let initiator_public_key = initiator_public_key.tls_serialize_detached().map_err(|_| {
LpError::Internal(
"Failed to serialize initiator public key when computing receiver index".into(),
)
})?;
let mut h = derive_key_blake3_multi_input(
RECEIVER_INDEX_DERIVATION_CONTEXT,
&[initiator_public_key.as_slice(), responder_kem_pk.as_bytes()],
self.seed(),
);
let index = LpReceiverIndex::from_le_bytes([h[0], h[1], h[2], h[3]]);
h.zeroize();
Ok(index)
}
}
#[cfg(test)]
mod test {
use crate::peer_config::LpPeerConfig;
#[test]
fn test_pack_config() {
let mut rng = rand09::rng();
// Node to node, no censorship resistance
{
let expected_conf = 0b0010_0000;
let conf = LpPeerConfig::new(&mut rng, 0, false, true, false).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert_eq!(
conf_bytes[0],
LpPeerConfig::new_node_to_node(&mut rng)
.unwrap()
.serialize()[0]
);
assert!(conf.is_node_to_node());
}
// Node to node, with censorship resistance
{
let expected_conf = 0b0110_0000;
let conf = LpPeerConfig::new(&mut rng, 0, false, true, true).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert!(conf.is_node_to_node());
}
// Client to Entry, no censorship resistance
{
let expected_conf = 0b0000_0000;
let conf = LpPeerConfig::new(&mut rng, 0, false, false, false).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
let conf_alt_first_byte =
LpPeerConfig::new_client_to_entry(&mut rng, false).serialize()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert_eq!(conf_bytes[0], conf_alt_first_byte);
assert!(conf.is_client_entry())
}
// Client to Entry, with censorship resistance
{
let expected_conf = 0b0100_0000;
let conf = LpPeerConfig::new(&mut rng, 0, false, false, true).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
let conf_alt_first_byte =
LpPeerConfig::new_client_to_entry(&mut rng, true).serialize()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert_eq!(conf_bytes[0], conf_alt_first_byte);
assert!(conf.is_client_entry());
}
// Client to Exit(exit hop = 1), with censorship resistance
{
let expected_conf = 0b0101_0001;
let conf = LpPeerConfig::new(&mut rng, 1, true, false, true).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
let conf_alt_first_byte = LpPeerConfig::new_client_to_exit(&mut rng, 1, true)
.unwrap()
.serialize()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert_eq!(conf_bytes[0], conf_alt_first_byte);
assert!(conf.is_client_exit());
}
// Client to Exit(exit hop = 2), without censorship resistance
{
let expected_conf = 0b0001_0010;
let conf = LpPeerConfig::new(&mut rng, 2, true, false, false).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
let conf_alt_first_byte = LpPeerConfig::new_client_to_exit(&mut rng, 2, false)
.unwrap()
.serialize()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert_eq!(conf_bytes[0], conf_alt_first_byte);
assert!(conf.is_client_exit());
}
// Client to Intermediate (hop_id = 14), without censorship resistance
{
let expected_conf = 0b0000_1110;
let conf = LpPeerConfig::new(&mut rng, 14, false, false, false).unwrap();
let conf_bytes = conf.serialize();
let deserialized_conf_first_byte = LpPeerConfig::deserialize(&conf_bytes)
.unwrap()
.pack_config()[0];
let conf_alt_first_byte = LpPeerConfig::new_client_to_intermediate(&mut rng, 14, false)
.unwrap()
.serialize()[0];
assert_eq!(expected_conf, conf_bytes[0]);
assert_eq!(expected_conf, deserialized_conf_first_byte);
assert_eq!(conf_bytes[0], conf_alt_first_byte);
assert!(conf.is_client_intermediate_node());
}
}
#[test]
fn test_failures() {
let mut rng = rand09::rng();
// Hop with id 15 must be an exit node
assert!(LpPeerConfig::new(&mut rng, 15, false, false, false).is_err());
// intermediate hop cannot be the first hop
assert!(LpPeerConfig::new_client_to_intermediate(&mut rng, 0, false).is_err());
// intermediate hop cannot be the last hop
assert!(LpPeerConfig::new_client_to_intermediate(&mut rng, 15, false).is_err());
// Hop with id 0 must be an entry node
assert!(LpPeerConfig::new_client_to_intermediate(&mut rng, 0, false).is_err());
assert!(LpPeerConfig::new_client_to_exit(&mut rng, 0, false).is_err());
assert!(LpPeerConfig::new(&mut rng, 0, true, false, false).is_err());
// cannot be node to node with hop_id > 0
assert!(LpPeerConfig::new(&mut rng, 1, false, true, false).is_err());
// cannot be node to node and exit at the same time
assert!(LpPeerConfig::new(&mut rng, 0, true, true, false).is_err());
// cannot have hop_id greater than 15
// this is a valid config
assert!(LpPeerConfig::new(&mut rng, 0, false, false, false).is_ok());
// this is a valid config
assert!(LpPeerConfig::new(&mut rng, 14, false, false, false).is_ok());
// this is a valid config
assert!(LpPeerConfig::new(&mut rng, 15, true, false, false).is_ok());
// these are not valid configs
assert!(LpPeerConfig::new(&mut rng, 16, false, false, false).is_err());
assert!(LpPeerConfig::new(&mut rng, 16, true, false, false).is_err());
assert!(LpPeerConfig::new(&mut rng, 240, false, false, false).is_err());
}
}
-792
View File
@@ -1,792 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! PSK (Pre-Shared Key) derivation for LP sessions using Blake3 KDF.
//!
//! This module implements identity-bound PSK derivation where both client and gateway
//! derive the same PSK from their LP keypairs.
//!
//! PSQ is embedded in Noise (not separate protocol) because:
//! 1. Single round-trip: PSQ ciphertext piggybacks on Noise handshake messages
//! 2. PSK binding: Noise XKpsk3 pattern authenticates both ECDH and PSQ-derived PSK
//! 3. Simpler state machine: No separate PSQ negotiation phase needed
//! 4. Atomic security: Session establishment either succeeds fully or fails completely
//!
//! Two approaches are supported:
//! - **Legacy ECDH-only** (`derive_psk`) - Simple but no post-quantum security
//! - **PSQ-enhanced** (`derive_psk_with_psq_*`) - Combines ECDH with post-quantum KEM
//!
//! ## Error Handling Strategy
//!
//! **PSQ failures always abort the handshake cleanly with no retry or fallback.**
//!
//! ### Rationale
//!
//! PSQ errors indicate:
//! - **Authentication failures** (CredError) - Potential attack or misconfiguration
//! - **Timing failures** (TimestampElapsed) - Replay attacks or clock skew
//! - **Crypto failures** (CryptoError) - Library bugs or hardware faults
//! - **Serialization failures** (Serialization) - Protocol violations or corruption
//!
//! None of these are transient errors that benefit from retry. Falling back to
//! ECDH-only PSK would silently degrade post-quantum security.
//!
//! ### Error Recovery Behavior
//!
//! On any PSQ error:
//! 1. Function returns `Err(LpError)` immediately
//! 2. Session state remains unchanged (dummy PSK, clean Noise state)
//! 3. Handshake aborts - caller must start fresh connection
//! 4. Error is logged with diagnostic context
//!
//! ### State Guarantees on Error
//!
//! - **`psq_state`**: Remains in `NotStarted` (initiator) or `ResponderWaiting` (responder)
//! - **Noise `HandshakeState`**: PSK slot 3 = dummy `[0u8; 32]` (not modified on error)
//! - **No partial data**: All allocations are stack-local to failed function
//! - **No cleanup needed**: No state was mutated
use crate::LpError;
use libcrux_psq::v1::cred::{Authenticator, Ed25519};
use libcrux_psq::v1::impls::X25519 as PsqX25519;
use libcrux_psq::v1::psk_registration::{Initiator, InitiatorMsg, Responder};
use libcrux_psq::v1::traits::{Ciphertext as PsqCiphertext, PSQ};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey};
use std::time::Duration;
use tls_codec::{Deserialize as TlsDeserializeTrait, Serialize as TlsSerializeTrait};
/// Context string for Blake3 KDF domain separation (PSQ-enhanced).
const PSK_PSQ_CONTEXT: &str = "nym-lp-psk-psq-v1";
/// Session context for PSQ protocol.
const PSQ_SESSION_CONTEXT: &[u8] = b"nym-lp-psq-session";
/// Context string for subsession PSK derivation.
const SUBSESSION_PSK_CONTEXT: &str = "lp-subsession-psk-v1";
/// Result from PSQ initiator message creation.
///
/// Contains all outputs needed for session establishment:
/// - `psk`: Final derived PSK for Noise handshake (ECDH || K_pq || salt → Blake3)
/// - `payload`: Serialized PSQ message to send to responder
/// - `pq_shared_secret`: Raw K_pq from KEM encapsulation (for subsession derivation)
#[derive(Debug)]
pub struct PsqInitiatorResult {
/// Final PSK for Noise XKpsk3 handshake
pub psk: [u8; 32],
/// Serialized PSQ payload to embed in handshake message
pub payload: Vec<u8>,
/// Raw PQ shared secret (K_pq) before KDF combination.
/// Used for deriving subsession PSKs to preserve PQ protection.
pub pq_shared_secret: [u8; 32],
}
/// Result from PSQ responder message processing.
///
/// Contains all outputs needed for session establishment:
/// - `psk`: Final derived PSK for Noise handshake (matches initiator's)
/// - `psk_handle`: Encrypted PSK handle (ctxt_B) to send back to initiator
/// - `pq_shared_secret`: Raw K_pq from KEM decapsulation (for subsession derivation)
#[derive(Debug)]
pub struct PsqResponderResult {
/// Final PSK for Noise XKpsk3 handshake
pub psk: [u8; 32],
/// Encrypted PSK handle (ctxt_B) from PSQ responder message
pub psk_handle: Vec<u8>,
/// Raw PQ shared secret (K_pq) before KDF combination.
/// Used for deriving subsession PSKs to preserve PQ protection.
pub pq_shared_secret: [u8; 32],
}
/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Initiator side.
///
/// This function combines classical ECDH with post-quantum KEM to provide forward secrecy
/// and HNDL (Harvest-Now, Decrypt-Later) resistance.
///
/// # Formula
/// ```text
/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public)
/// (psq_psk, ct) = PSQ_Encapsulate(remote_kem_public, session_context)
/// psk = Blake3_derive_key(
/// context="nym-lp-psk-psq-v1",
/// input=ecdh_secret || psq_psk || salt
/// )
/// ```
///
/// # Arguments
/// * `local_x25519_private` - Initiator's X25519 private key (for Noise)
/// * `remote_x25519_public` - Responder's X25519 public key (for Noise)
/// * `remote_kem_public` - Responder's KEM public key (obtained via KKT)
/// * `salt` - 32-byte salt for session binding
///
/// # Returns
/// * `Ok((psk, ciphertext))` - PSK and ciphertext to send to responder
/// * `Err(LpError)` - If PSQ encapsulation fails
///
/// # Example
/// ```ignore
/// // Client side (after KKT exchange)
/// let (psk, ciphertext) = derive_psk_with_psq_initiator(
/// client_x25519_private,
/// gateway_x25519_public,
/// &gateway_kem_key, // from KKT
/// &salt
/// )?;
/// // Send ciphertext to gateway
/// ```
pub fn derive_psk_with_psq_initiator(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
remote_kem_public: &EncapsulationKey,
salt: &[u8; 32],
) -> Result<([u8; 32], Vec<u8>), LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: PSQ encapsulation for post-quantum security
// KEM algorithm migration path:
// - X25519: Current default for testing/compatibility (no HNDL resistance)
// - MlKem768: Future production default (NIST PQ Level 3, HNDL resistant)
// - XWing: Maximum security option (hybrid X25519 + ML-KEM)
// Migration: Update LpConfig.kem_algorithm, no protocol changes needed.
// KKT protocol adapts automatically to different KEM key sizes.
let kem_pk = match remote_kem_public {
EncapsulationKey::X25519(pk) => pk,
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
let mut rng = rand09::rng();
let (psq_psk, ciphertext) =
PsqX25519::encapsulate_psq(kem_pk, PSQ_SESSION_CONTEXT, &mut rng)
.map_err(|e| LpError::Internal(format!("PSQ encapsulation failed: {:?}", e)))?;
// Step 3: Combine ECDH + PSQ via Blake3 KDF
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need &
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
// Serialize ciphertext using TLS encoding for transport
let ct_bytes = ciphertext
.tls_serialize_detached()
.map_err(|e| LpError::Internal(format!("Ciphertext serialization failed: {:?}", e)))?;
Ok((final_psk, ct_bytes))
}
/// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Responder side.
///
/// This function decapsulates the ciphertext from the initiator and combines it with
/// ECDH to derive the same PSK.
///
/// # Formula
/// ```text
/// ecdh_secret = ECDH(local_x25519_private, remote_x25519_public)
/// psq_psk = PSQ_Decapsulate(local_kem_keypair, ciphertext, session_context)
/// psk = Blake3_derive_key(
/// context="nym-lp-psk-psq-v1",
/// input=ecdh_secret || psq_psk || salt
/// )
/// ```
///
/// # Arguments
/// * `local_x25519_private` - Responder's X25519 private key (for Noise)
/// * `remote_x25519_public` - Initiator's X25519 public key (for Noise)
/// * `local_kem_keypair` - Responder's KEM keypair (decapsulation key, public key)
/// * `ciphertext` - PSQ ciphertext from initiator
/// * `salt` - 32-byte salt for session binding
///
/// # Returns
/// * `Ok(psk)` - Derived PSK
/// * `Err(LpError)` - If PSQ decapsulation fails
///
/// # Example
/// ```ignore
/// // Gateway side (after receiving ciphertext)
/// let psk = derive_psk_with_psq_responder(
/// gateway_x25519_private,
/// client_x25519_public,
/// (&gateway_kem_sk, &gateway_kem_pk),
/// &ciphertext, // from client
/// &salt
/// )?;
/// ```
pub fn derive_psk_with_psq_responder(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
local_kem_keypair: (&DecapsulationKey, &EncapsulationKey),
ciphertext: &[u8],
salt: &[u8; 32],
) -> Result<[u8; 32], LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey
let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) {
(DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk),
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
// Step 3: Deserialize ciphertext using TLS decoding
let ct = PsqCiphertext::<PsqX25519>::tls_deserialize(&mut &ciphertext[..])
.map_err(|e| LpError::Internal(format!("Ciphertext deserialization failed: {:?}", e)))?;
// Step 4: PSQ decapsulation for post-quantum security
let psq_psk = PsqX25519::decapsulate_psq(kem_sk, kem_pk, &ct, PSQ_SESSION_CONTEXT)
.map_err(|e| LpError::Internal(format!("PSQ decapsulation failed: {:?}", e)))?;
// Step 5: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator)
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need &
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
Ok(final_psk)
}
/// PSQ protocol wrapper for initiator (client) side.
///
/// Creates a PSQ initiator message with Ed25519 authentication, following the protocol:
/// 1. Encapsulate PSK using responder's KEM key
/// 2. Derive PSK and AEAD keys from K_pq
/// 3. Sign the encapsulation with Ed25519
/// 4. AEAD encrypt (timestamp || signature || public_key)
///
/// Returns (PSK, serialized_payload) where payload includes enc_pq and encrypted auth data.
///
/// # Arguments
/// * `local_x25519_private` - Client's X25519 private key (for hybrid ECDH)
/// * `remote_x25519_public` - Gateway's X25519 public key (for hybrid ECDH)
/// * `remote_kem_public` - Gateway's PQ KEM public key (from KKT)
/// * `client_ed25519_sk` - Client's Ed25519 signing key
/// * `client_ed25519_pk` - Client's Ed25519 public key (credential)
/// * `salt` - Session salt
/// * `session_context` - Context bytes for PSQ (e.g., b"nym-lp-psq-session")
///
/// # Returns
/// `PsqInitiatorResult` containing PSK, payload, and raw PQ shared secret
pub fn psq_initiator_create_message(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
remote_kem_public: &EncapsulationKey,
client_ed25519_sk: &ed25519::PrivateKey,
client_ed25519_pk: &ed25519::PublicKey,
salt: &[u8; 32],
session_context: &[u8],
) -> Result<PsqInitiatorResult, LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: PSQ v1 with Ed25519 authentication
// Extract X25519 KEM key from EncapsulationKey
let kem_pk = match remote_kem_public {
EncapsulationKey::X25519(pk) => pk,
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
// Convert nym Ed25519 keys to libcrux format
type Ed25519VerificationKey = <Ed25519 as Authenticator>::VerificationKey;
let ed25519_sk_bytes = client_ed25519_sk.to_bytes();
let ed25519_pk_bytes = client_ed25519_pk.to_bytes();
let ed25519_verification_key = Ed25519VerificationKey::from_bytes(ed25519_pk_bytes);
// Use PSQ v1 API with Ed25519 authentication
let mut rng = rand09::rng();
let (state, initiator_msg) = Initiator::send_initial_message::<Ed25519, PsqX25519>(
session_context,
Duration::from_secs(3600), // 1 hour expiry
kem_pk,
&ed25519_sk_bytes,
&ed25519_verification_key,
&mut rng,
)
.map_err(|e| {
tracing::error!(
"PSQ initiator failed - KEM encapsulation or signing error: {:?}",
e
);
LpError::Internal(format!("PSQ v1 send_initial_message failed: {:?}", e))
})?;
// Extract PSQ shared secret (unregistered PSK) - this is K_pq
let psq_psk = state.unregistered_psk();
// pq_shared_secret is the raw K_pq from KEM encapsulation.
// Store it for subsession derivation before it's combined with ECDH.
let pq_shared_secret: [u8; 32] = *psq_psk;
// Step 3: Combine ECDH + PSQ via Blake3 KDF
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(psq_psk); // psq_psk is already a &[u8; 32]
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
// Serialize InitiatorMsg with TLS encoding for transport
let msg_bytes = initiator_msg
.tls_serialize_detached()
.map_err(|e| LpError::Internal(format!("InitiatorMsg serialization failed: {:?}", e)))?;
Ok(PsqInitiatorResult {
psk: final_psk,
payload: msg_bytes,
pq_shared_secret,
})
}
/// PSQ protocol wrapper for responder (gateway) side.
///
/// Processes a PSQ initiator message, verifies authentication, and derives PSK.
/// Follows the protocol:
/// 1. Decapsulate to get K_pq
/// 2. Derive AEAD keys and verify encrypted auth data
/// 3. Verify Ed25519 signature
/// 4. Check timestamp validity
/// 5. Derive PSK
///
/// # Arguments
/// * `local_x25519_private` - Gateway's X25519 private key (for hybrid ECDH)
/// * `remote_x25519_public` - Client's X25519 public key (for hybrid ECDH)
/// * `local_kem_keypair` - Gateway's PQ KEM keypair
/// * `initiator_ed25519_pk` - Client's Ed25519 public key (for signature verification)
/// * `psq_payload` - Serialized PSQ payload from initiator
/// * `salt` - Session salt (must match initiator's)
/// * `session_context` - Context bytes for PSQ
///
/// # Returns
/// `PsqResponderResult` containing PSK, PSK handle, and raw PQ shared secret
pub fn psq_responder_process_message(
local_x25519_private: &x25519::PrivateKey,
remote_x25519_public: &x25519::PublicKey,
local_kem_keypair: (&DecapsulationKey, &EncapsulationKey),
initiator_ed25519_pk: &ed25519::PublicKey,
psq_payload: &[u8],
salt: &[u8; 32],
session_context: &[u8],
) -> Result<PsqResponderResult, LpError> {
// Step 1: Classical ECDH for baseline security
let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public);
// Step 2: Extract X25519 keypair from DecapsulationKey/EncapsulationKey
let (kem_sk, kem_pk) = match (local_kem_keypair.0, local_kem_keypair.1) {
(DecapsulationKey::X25519(sk), EncapsulationKey::X25519(pk)) => (sk, pk),
_ => {
return Err(LpError::KKTError(
"Only X25519 KEM is currently supported for PSQ".to_string(),
));
}
};
// Step 3: Deserialize InitiatorMsg using TLS decoding
let initiator_msg = InitiatorMsg::<PsqX25519>::tls_deserialize(&mut &psq_payload[..])
.map_err(|e| LpError::Internal(format!("InitiatorMsg deserialization failed: {:?}", e)))?;
// Step 4: Convert nym Ed25519 public key to libcrux VerificationKey format
type Ed25519VerificationKey = <Ed25519 as Authenticator>::VerificationKey;
let initiator_ed25519_pk_bytes = initiator_ed25519_pk.to_bytes();
let initiator_verification_key = Ed25519VerificationKey::from_bytes(initiator_ed25519_pk_bytes);
// Step 5: PSQ v1 responder processing with Ed25519 verification
let (registered_psk, responder_msg) = Responder::send::<Ed25519, PsqX25519>(
b"nym-lp-handle", // PSK storage handle
Duration::from_secs(3600), // 1 hour expiry (must match initiator)
session_context, // Must match initiator's session_context
kem_pk, // Responder's public key
kem_sk, // Responder's secret key
&initiator_verification_key, // Initiator's Ed25519 public key for verification
&initiator_msg, // InitiatorMsg to verify and process
)
.map_err(|e| {
use libcrux_psq::v1::Error as PsqError;
match e {
PsqError::CredError => {
tracing::warn!(
"PSQ responder auth failure - invalid Ed25519 signature (potential attack)"
);
}
PsqError::TimestampElapsed | PsqError::RegistrationError => {
tracing::warn!(
"PSQ responder timing failure - TTL expired (potential replay attack)"
);
}
_ => {
tracing::error!("PSQ responder failed - {:?}", e);
}
}
LpError::Internal(format!("PSQ v1 responder send failed: {:?}", e))
})?;
// Extract the PSQ PSK from the registered PSK - this is K_pq
let psq_psk = registered_psk.psk;
// pq_shared_secret is the raw K_pq from KEM decapsulation.
// Store it for subsession derivation before it's combined with ECDH.
let pq_shared_secret: [u8; 32] = psq_psk;
// Step 6: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator)
let mut combined = Vec::with_capacity(64 + psq_psk.len());
combined.extend_from_slice(&ecdh_secret);
combined.extend_from_slice(&psq_psk); // psq_psk is [u8; 32], need &
combined.extend_from_slice(salt);
let final_psk = nym_crypto::kdf::derive_key_blake3(PSK_PSQ_CONTEXT, &combined, &[]);
// Step 7: Serialize ResponderMsg (contains ctxt_B - encrypted PSK handle)
use tls_codec::Serialize;
let responder_msg_bytes = responder_msg
.tls_serialize_detached()
.map_err(|e| LpError::Internal(format!("ResponderMsg serialization failed: {:?}", e)))?;
Ok(PsqResponderResult {
psk: final_psk,
psk_handle: responder_msg_bytes,
pq_shared_secret,
})
}
/// Derive subsession PSK from parent's PQ shared secret.
///
/// Uses Blake3 KDF with domain separation to derive unique PSK for each subsession.
/// This preserves PQ protection: subsession keys inherit quantum resistance from
/// parent's KEM shared secret (K_pq).
///
/// # Security Model
///
/// Subsessions use Noise KKpsk0 pattern where:
/// - Both parties already know each other's static X25519 keys (from parent session)
/// - PSK provides PQ protection by deriving from parent's K_pq
/// - Each subsession gets unique PSK via index parameter (prevents key reuse)
///
/// # Arguments
/// * `pq_shared_secret` - Parent session's K_pq (32 bytes from KEM)
/// * `subsession_index` - Monotonic index for this subsession (prevents reuse)
///
/// # Returns
/// 32-byte PSK for Noise KKpsk0 handshake
pub fn derive_subsession_psk(pq_shared_secret: &[u8; 32], subsession_index: u64) -> [u8; 32] {
nym_crypto::kdf::derive_key_blake3(
SUBSESSION_PSK_CONTEXT,
pq_shared_secret,
&subsession_index.to_le_bytes(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::thread_rng;
fn generate_x25519_keypair() -> x25519::KeyPair {
x25519::KeyPair::new(&mut thread_rng())
}
#[test]
fn test_psk_derivation_is_symmetric() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let salt = [2u8; 32];
let mut rng = &mut rand09::rng();
let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let dec_key = DecapsulationKey::X25519(_kem_sk);
// Client derives PSK
let (client_psk, ciphertext) = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt,
)
.unwrap();
// Gateway derives PSK from their perspective
let gateway_psk = derive_psk_with_psq_responder(
keypair_2.private_key(),
keypair_1.public_key(),
(&dec_key, &enc_key),
&ciphertext,
&salt,
)
.unwrap();
assert_eq!(
client_psk, gateway_psk,
"Both sides should derive identical PSK"
);
}
#[test]
fn test_different_salts_produce_different_psks() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let salt1 = [1u8; 32];
let salt2 = [2u8; 32];
let mut rng = &mut rand09::rng();
let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let psk1 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt1,
)
.unwrap();
let psk2 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt2,
)
.unwrap();
assert_ne!(psk1, psk2, "Different salts should produce different PSKs");
}
#[test]
fn test_different_keys_produce_different_psks() {
let keypair_1 = generate_x25519_keypair();
let keypair_2 = generate_x25519_keypair();
let keypair_3 = generate_x25519_keypair();
let salt = [3u8; 32];
let mut rng = &mut rand09::rng();
let (_kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let psk1 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_2.public_key(),
&enc_key,
&salt,
)
.unwrap();
let psk2 = derive_psk_with_psq_initiator(
keypair_1.private_key(),
keypair_3.public_key(),
&enc_key,
&salt,
)
.unwrap();
assert_ne!(
psk1, psk2,
"Different remote keys should produce different PSKs"
);
}
// PSQ-enhanced PSK tests
use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey, KEM};
use nym_kkt::key_utils::generate_keypair_libcrux;
#[test]
fn test_psq_derivation_deterministic() {
let mut rng = rand09::rng();
// Generate X25519 keypairs for Noise
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// Generate KEM keypair for PSQ
let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let dec_key = DecapsulationKey::X25519(kem_sk);
let salt = [1u8; 32];
// Derive PSK twice with same inputs (initiator side)
let (_psk1, ct1) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
let (_psk2, _ct2) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
// PSKs will be different due to randomness in PSQ, but ciphertexts too
// This test verifies the function is deterministic given the SAME ciphertext
let psk_responder1 = derive_psk_with_psq_responder(
gateway_keypair.private_key(),
client_keypair.public_key(),
(&dec_key, &enc_key),
&ct1,
&salt,
)
.unwrap();
let psk_responder2 = derive_psk_with_psq_responder(
gateway_keypair.private_key(),
client_keypair.public_key(),
(&dec_key, &enc_key),
&ct1, // Same ciphertext
&salt,
)
.unwrap();
assert_eq!(
psk_responder1, psk_responder2,
"Same ciphertext should produce same PSK"
);
}
#[test]
fn test_psq_derivation_symmetric() {
let mut rng = rand09::rng();
// Generate X25519 keypairs for Noise
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// Generate KEM keypair for PSQ
let (kem_sk, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let dec_key = DecapsulationKey::X25519(kem_sk);
let salt = [2u8; 32];
// Client derives PSK (initiator)
let (client_psk, ciphertext) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
// Gateway derives PSK from ciphertext (responder)
let gateway_psk = derive_psk_with_psq_responder(
gateway_keypair.private_key(),
client_keypair.public_key(),
(&dec_key, &enc_key),
&ciphertext,
&salt,
)
.unwrap();
assert_eq!(
client_psk, gateway_psk,
"Both sides should derive identical PSK via PSQ"
);
}
#[test]
fn test_different_kem_keys_different_psk() {
let mut rng = rand09::rng();
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
// Two different KEM keypairs
let (_, kem_pk1) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let (_, kem_pk2) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key1 = EncapsulationKey::X25519(kem_pk1);
let enc_key2 = EncapsulationKey::X25519(kem_pk2);
let salt = [3u8; 32];
let (psk1, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key1,
&salt,
)
.unwrap();
let (psk2, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key2,
&salt,
)
.unwrap();
assert_ne!(
psk1, psk2,
"Different KEM keys should produce different PSKs"
);
}
#[test]
fn test_psq_psk_output_length() {
let mut rng = rand09::rng();
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let salt = [4u8; 32];
let (psk, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt,
)
.unwrap();
assert_eq!(psk.len(), 32, "PSQ PSK should be exactly 32 bytes");
}
#[test]
fn test_psq_different_salts_different_psks() {
let mut rng = rand09::rng();
let client_keypair = generate_x25519_keypair();
let gateway_keypair = generate_x25519_keypair();
let (_, kem_pk) = generate_keypair_libcrux(&mut rng, KEM::X25519).unwrap();
let enc_key = EncapsulationKey::X25519(kem_pk);
let salt1 = [1u8; 32];
let salt2 = [2u8; 32];
let (psk1, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt1,
)
.unwrap();
let (psk2, _) = derive_psk_with_psq_initiator(
client_keypair.private_key(),
gateway_keypair.public_key(),
&enc_key,
&salt2,
)
.unwrap();
assert_ne!(psk1, psk2, "Different salts should produce different PSKs");
}
}
+145
View File
@@ -0,0 +1,145 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::psq::{PSQ_MSG2_SIZE, psq_msg1_size};
use crate::transport::{LpTransportError, traits::HandshakeMessage};
use nym_kkt::context::KKTMode;
use nym_kkt_ciphersuite::KEM;
use std::ops::Deref;
pub struct KKTRequest(nym_kkt::message::KKTRequest);
impl From<nym_kkt::message::KKTRequest> for KKTRequest {
fn from(request: nym_kkt::message::KKTRequest) -> Self {
KKTRequest(request)
}
}
impl From<KKTRequest> for nym_kkt::message::KKTRequest {
fn from(request: KKTRequest) -> Self {
request.0
}
}
impl HandshakeMessage for KKTRequest {
fn into_bytes(self) -> Vec<u8> {
self.0.into_bytes()
}
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, LpTransportError> {
Ok(KKTRequest(
nym_kkt::message::KKTRequest::try_from_bytes(&bytes)
.map_err(|err| LpTransportError::MalformedPacket(err.to_string()))?,
))
}
fn expected_size(mode: KKTMode, expected_kem: KEM, payload_size: usize) -> usize {
nym_kkt::message::KKTRequest::size_excluding_payload(mode, expected_kem) + payload_size
}
fn response_size(&self, expected_kem: KEM) -> Option<usize> {
Some(nym_kkt::message::KKTResponse::size_excluding_payload(
expected_kem,
))
}
}
pub struct KKTResponse(nym_kkt::message::KKTResponse);
impl From<nym_kkt::message::KKTResponse> for KKTResponse {
fn from(request: nym_kkt::message::KKTResponse) -> Self {
KKTResponse(request)
}
}
impl From<KKTResponse> for nym_kkt::message::KKTResponse {
fn from(request: KKTResponse) -> Self {
request.0
}
}
impl HandshakeMessage for KKTResponse {
fn into_bytes(self) -> Vec<u8> {
self.0.into_bytes()
}
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, LpTransportError> {
Ok(KKTResponse(nym_kkt::message::KKTResponse::from_bytes(
bytes,
)))
}
fn expected_size(_: KKTMode, expected_kem: KEM, payload_size: usize) -> usize {
nym_kkt::message::KKTResponse::size_excluding_payload(expected_kem) + payload_size
}
fn response_size(&self, expected_kem: KEM) -> Option<usize> {
Some(psq_msg1_size(expected_kem))
}
}
pub struct PSQMsg1(Vec<u8>);
impl Deref for PSQMsg1 {
type Target = Vec<u8>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PSQMsg1 {
pub fn new(bytes: Vec<u8>) -> Self {
PSQMsg1(bytes)
}
}
impl HandshakeMessage for PSQMsg1 {
fn into_bytes(self) -> Vec<u8> {
self.0
}
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, LpTransportError> {
Ok(PSQMsg1(bytes))
}
fn expected_size(_: KKTMode, expected_kem: KEM, payload_size: usize) -> usize {
psq_msg1_size(expected_kem) + payload_size
}
fn response_size(&self, _: KEM) -> Option<usize> {
Some(PSQ_MSG2_SIZE)
}
}
pub struct PSQMsg2(Vec<u8>);
impl Deref for PSQMsg2 {
type Target = Vec<u8>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PSQMsg2 {
pub fn new(bytes: Vec<u8>) -> Self {
PSQMsg2(bytes)
}
}
impl HandshakeMessage for PSQMsg2 {
fn into_bytes(self) -> Vec<u8> {
self.0
}
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, LpTransportError> {
Ok(PSQMsg2(bytes))
}
fn expected_size(_: KKTMode, _: KEM, payload_size: usize) -> usize {
PSQ_MSG2_SIZE + payload_size
}
fn response_size(&self, _: KEM) -> Option<usize> {
None
}
}
+6 -46
View File
@@ -1,52 +1,12 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::codec::{OuterAeadKey, parse_lp_packet, serialize_lp_packet};
use crate::{LpError, LpPacket};
use bytes::BytesMut;
use nym_lp_transport::traits::LpTransport;
use libcrux_psq::handshake::ciphersuites::CiphersuiteName;
use nym_kkt_ciphersuite::KEM;
#[cfg(test)]
use mock_instant::thread_local::{SystemTime, UNIX_EPOCH};
#[cfg(not(test))]
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) fn current_timestamp() -> Result<u64, LpError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| LpError::Internal("System time before UNIX epoch".into()))
.map(|d| d.as_secs())
}
// only used in internal code (and tests)
#[allow(async_fn_in_trait)]
pub trait LpTransportHandshakeExt: LpTransport {
// the outer key is temporary until the algorithm is changed with psqv2
async fn receive_packet(
&mut self,
outer_key: Option<&OuterAeadKey>,
) -> Result<LpPacket, LpError>
where
Self: Unpin,
{
let raw = self.receive_raw_packet().await?;
parse_lp_packet(&raw, outer_key)
}
async fn send_packet(
&mut self,
packet: LpPacket,
outer_key: Option<&OuterAeadKey>,
) -> Result<(), LpError>
where
Self: Unpin,
{
let mut packet_buf = BytesMut::new();
serialize_lp_packet(&packet, &mut packet_buf, outer_key)?;
self.send_serialised_packet(&packet_buf).await?;
Ok(())
pub(crate) fn kem_to_ciphersuite(kem: KEM) -> CiphersuiteName {
match kem {
KEM::MlKem768 => CiphersuiteName::X25519_MLKEM768_X25519_AESGCM128_HKDFSHA256,
KEM::McEliece => CiphersuiteName::X25519_CLASSICMCELIECE_X25519_AESGCM128_HKDFSHA256,
}
}
impl<T> LpTransportHandshakeExt for T where T: LpTransport {}
+486 -348
View File
@@ -1,391 +1,529 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::codec::OuterAeadKey;
use crate::message::{HandshakeData, KKTRequestData, MessageType};
use crate::noise_protocol::NoiseProtocol;
use crate::peer::LpRemotePeer;
use crate::psk::psq_initiator_create_message;
use crate::psq::helpers::{LpTransportHandshakeExt, current_timestamp};
use crate::psq::{IntermediateHandshakeFailure, PSQHandshakeState};
use crate::session::PqSharedSecret;
use crate::{ClientHelloData, LpError, LpMessage, LpSession};
use nym_kkt::KKT_RESPONSE_AAD;
use nym_kkt::ciphersuite::EncapsulationKey;
use nym_kkt::context::KKTContext;
use nym_kkt::encryption::{KKTSessionSecret, decrypt_kkt_frame, encrypt_initial_kkt_frame};
use nym_kkt::session::{anonymous_initiator_process, initiator_ingest_response};
use nym_lp_transport::traits::LpTransport;
use rand09::rng;
use crate::peer::{LpLocalPeer, LpRemotePeer};
use crate::peer_config::LpPeerConfig;
use crate::psq::handshake_message::{PSQMsg1, PSQMsg2};
use crate::psq::helpers::kem_to_ciphersuite;
use crate::psq::{
AAD_INITIATOR_INNER_V1, AAD_INITIATOR_OUTER_V1, InitiatorData, PSQ_MSG2_SIZE,
PSQHandshakeState, SESSION_CONTEXT_V1, handshake_message, psq_msg1_size,
};
use crate::session::PersistentSessionBinding;
use crate::transport::traits::LpHandshakeChannel;
use crate::{LpError, LpTransportSession};
use libcrux_psq::handshake::RegistrationInitiator;
use libcrux_psq::handshake::builders::{
CiphersuiteBuilder, InitiatorCiphersuite, PrincipalBuilder,
};
use libcrux_psq::handshake::types::Authenticator;
use libcrux_psq::{Channel, IntoSession};
use nym_kkt::initiator::KKTInitiator;
use nym_kkt::keys::EncapsulationKey;
use nym_kkt::message::{KKTRequest, KKTResponse};
use rand09::SeedableRng;
use tracing::debug;
impl<'a, S> PSQHandshakeState<'a, S>
where
S: LpTransport + Unpin,
{
/// Generate and send client hello to the responder
pub(crate) async fn send_client_hello(&mut self) -> Result<ClientHelloData, LpError> {
let protocol = self.protocol_version()?;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandshakeMode {
// Client <> Entry
OneWayEntry,
// 1. Generate and send ClientHelloData with fresh salt and both public keys
let timestamp = current_timestamp()?;
// Client <> Exit
OneWayExit,
let client_hello_data = self.local_peer.build_client_hello_data(timestamp);
self.connection
.send_packet(client_hello_data.into_lp_packet(protocol), None)
.await?;
Ok(client_hello_data)
// Entry <> Exit
MutualInternode,
// in the future more variants will be supported (such as individual mix hops)
}
impl HandshakeMode {
pub fn is_mutual(&self) -> bool {
matches!(self, HandshakeMode::MutualInternode)
}
}
/// Attempt to receive an ack to sent client hello. returns a boolean indicating
/// whether the request has been successful or whether there has been a collision in receiver
/// index requiring a retry
pub(crate) async fn receive_client_hello_ack(&mut self) -> Result<bool, LpError> {
match self.receive_non_error(None).await?.message {
LpMessage::Ack => Ok(true),
LpMessage::Collision => Ok(false),
other => {
// TODO: retry on collision
Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::Ack,
))
pub struct PSQHandshakeStateInitiator<'a, S> {
pub(super) inner_state: PSQHandshakeState<'a, S>,
pub(super) initiator_data: InitiatorData,
/// The mode of the handshake (mutual node-node, client-entry, entry-exit)
pub(super) mode: HandshakeMode,
}
pub(crate) fn build_psq_principal<R>(
rng: R,
version: u8,
ciphersuite: InitiatorCiphersuite,
) -> Result<RegistrationInitiator<R>, LpError>
where
R: rand09::CryptoRng,
{
let (ctx, inner_aad, outer_aad) = match version {
1 => (
SESSION_CONTEXT_V1,
AAD_INITIATOR_INNER_V1,
AAD_INITIATOR_OUTER_V1,
),
other => return Err(LpError::UnsupportedVersion { version: other }),
};
PrincipalBuilder::new(rng)
.outer_aad(outer_aad)
.inner_aad(inner_aad)
.context(ctx)
.build_registration_initiator(ciphersuite)
.map_err(|inner| LpError::PSQInitiatorBuilderFailure { inner })
}
pub(crate) fn build_psq_ciphersuite<'a>(
init: &'a LpLocalPeer,
responder: &'a LpRemotePeer,
kem_key: &'a EncapsulationKey,
) -> Result<InitiatorCiphersuite<'a>, LpError> {
let psq_ciphersuite = kem_to_ciphersuite(kem_key.kem());
let builder = CiphersuiteBuilder::new(psq_ciphersuite)
.longterm_x25519_keys(init.x25519())
.peer_longterm_x25519_pk(responder.x25519());
match kem_key {
EncapsulationKey::McEliece(kem_key) => builder.peer_longterm_cmc_pk(kem_key),
EncapsulationKey::MlKem768(kem_key) => builder.peer_longterm_mlkem_pk(kem_key),
}
.build_initiator_ciphersuite()
.map_err(|inner| LpError::PSQInitiatorBuilderFailure { inner })
}
impl<'a, S> PSQHandshakeStateInitiator<'a, S>
where
S: LpHandshakeChannel + Unpin,
{
fn lp_peer_config<R>(&self, rng: &mut R) -> Result<LpPeerConfig, LpError>
where
R: rand09::CryptoRng,
{
// for now we don't support censorship resistance flag
let censorship_resistance = false;
match self.mode {
HandshakeMode::OneWayEntry => Ok(LpPeerConfig::new_client_to_entry(
rng,
censorship_resistance,
)),
HandshakeMode::OneWayExit => {
LpPeerConfig::new_client_to_exit(rng, 1, censorship_resistance)
}
HandshakeMode::MutualInternode => LpPeerConfig::new_node_to_node(rng),
}
}
/// Attempt to send KKT request to begin the handshake
pub(crate) async fn send_kkt_request(
&mut self,
session_id: u32,
remote_peer: &LpRemotePeer,
) -> Result<(KKTContext, KKTSessionSecret), LpError> {
let protocol = self.protocol_version()?;
async fn send_kkt_request(&mut self, request: KKTRequest) -> Result<(), LpError> {
let kem = self.inner_state.local_peer.ciphersuite.kem();
let (kkt_context, kkt_frame) = anonymous_initiator_process(&mut rng(), self.ciphersuite)?;
let (session_secret, encrypted_frame) =
encrypt_initial_kkt_frame(&mut rng(), &remote_peer.x25519_public, &kkt_frame)?;
let lp_message = KKTRequestData::new(encrypted_frame).into();
let lp_packet = self.next_packet(session_id, protocol, lp_message);
self.connection.send_packet(lp_packet, None).await?;
Ok((kkt_context, session_secret))
}
/// Attempt to receive a KKT response to the previously sent request and extract (and validate)
/// the received encapsulation key
pub(crate) async fn receive_kkt_response(
&mut self,
(kkt_context, session_secret): (KKTContext, KKTSessionSecret),
remote_peer: &LpRemotePeer,
) -> Result<EncapsulationKey<'static>, LpError> {
let kkt_response = match self.receive_non_error(None).await?.message {
LpMessage::KKTResponse(response) => response,
other => {
return Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::KKTResponse,
));
}
};
debug!("received KKT response");
let expected_kem_key_digest = remote_peer.expected_kem_key_hash(self.ciphersuite)?;
let (response_frame, remote_context) =
decrypt_kkt_frame(&session_secret, &kkt_response.0, KKT_RESPONSE_AAD)?;
let encapsulation_key = initiator_ingest_response(
&kkt_context,
&response_frame,
&remote_context,
&remote_peer.ed25519_public,
&expected_kem_key_digest,
)?;
Ok(encapsulation_key)
}
/// Attempt to prepare and send initial PSQ msg1
pub(crate) async fn send_psq_initiator_message(
&mut self,
remote_peer: &LpRemotePeer,
encapsulation_key: &EncapsulationKey<'_>,
salt: &[u8; 32],
session_id_bytes: &[u8; 4],
) -> Result<(OuterAeadKey, NoiseProtocol, PqSharedSecret), LpError> {
let protocol = self.protocol_version()?;
let session_id = u32::from_le_bytes(*session_id_bytes);
let psq_initiator = psq_initiator_create_message(
self.local_peer.x25519.private_key(),
&remote_peer.x25519_public,
encapsulation_key,
self.local_peer.ed25519.private_key(),
self.local_peer.ed25519.public_key(),
salt,
session_id_bytes,
)?;
let psk = psq_initiator.psk;
let psq_payload = psq_initiator.payload;
// TEMP \/
let outer_aead_key = OuterAeadKey::from_psk(&psk);
// TEMP /\
// prepare noise state and msg1
let mut noise_protocol = NoiseProtocol::build_new_initiator(
self.local_peer.x25519().private_key().as_bytes(),
remote_peer.x25519_public.as_bytes(),
&psk,
)?;
// prepare noise msg1
let noise_msg1 = noise_protocol
.get_bytes_to_send()
.ok_or_else(|| LpError::kkt_psq_handshake("failed to generate noise msg1"))??;
let psq_len = psq_payload.len() as u16;
let mut combined = Vec::with_capacity(2 + psq_payload.len() + noise_msg1.len());
combined.extend_from_slice(&psq_len.to_le_bytes());
combined.extend_from_slice(&psq_payload);
combined.extend_from_slice(&noise_msg1);
let lp_message = HandshakeData::new(combined).into();
let lp_packet = self.next_packet(session_id, protocol, lp_message);
self.connection.send_packet(lp_packet, None).await?;
Ok((
outer_aead_key,
noise_protocol,
PqSharedSecret::new(psq_initiator.pq_shared_secret),
))
}
/// Attempt to receive and validate received PSQ msg2
pub(crate) async fn receive_psq_responder_message(
&mut self,
outer_aead_key: &OuterAeadKey,
noise_protocol: &mut NoiseProtocol,
) -> Result<(), LpError> {
let psq_msg2 = match self
self.inner_state
.connection
.receive_packet(Some(outer_aead_key))
.await?
.message
{
LpMessage::Handshake(response) => response.0,
other => {
return Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::Handshake,
));
}
};
// Extract PSK handle: [u16 handle_len][handle_bytes][noise_msg]
if psq_msg2.len() < 2 {
return Err(LpError::kkt_psq_handshake("too short msg2 received"));
}
let handle_len = u16::from_le_bytes([psq_msg2[0], psq_msg2[1]]) as usize;
if psq_msg2.len() < 2 + handle_len {
return Err(LpError::kkt_psq_handshake("too short msg2 received"));
}
// Extract and "store" the PSK handle
let _psq_handle_bytes = &psq_msg2[2..2 + handle_len];
let noise_payload = &psq_msg2[2 + handle_len..];
// *sigh* ignore the message
let _noise_msg2 = noise_protocol.read_message(noise_payload)?;
.send_handshake_message::<handshake_message::KKTRequest>(request.into(), kem)
.await?;
Ok(())
}
/// Attempt to prepare and send final PSQ msg3
pub(crate) async fn send_final_psq_message(
&mut self,
session_id: u32,
outer_aead_key: &OuterAeadKey,
noise_protocol: &mut NoiseProtocol,
) -> Result<(), LpError> {
let protocol = self.protocol_version()?;
/// Attempt to receive a KKT response to the previously sent request
async fn receive_kkt_response(&mut self) -> Result<KKTResponse, LpError> {
// no response payload
let packet_len =
KKTResponse::size_excluding_payload(self.inner_state.local_peer.ciphersuite.kem());
let noise_msg3 = noise_protocol
.get_bytes_to_send()
.ok_or_else(|| LpError::kkt_psq_handshake("failed to generate noise msg3"))??;
let lp_message = HandshakeData::new(noise_msg3).into();
let lp_packet = self.next_packet(session_id, protocol, lp_message);
self.connection
.send_packet(lp_packet, Some(outer_aead_key))
let resp = self
.inner_state
.connection
.receive_handshake_message::<handshake_message::KKTResponse>(packet_len)
.await?;
if !noise_protocol.is_handshake_finished() {
Ok(resp.into())
}
pub async fn complete_handshake(self) -> Result<LpTransportSession, LpError>
where
S: LpHandshakeChannel + Unpin,
{
let mut rng = rand09::rngs::StdRng::from_os_rng();
self.complete_handshake_with_rng(&mut rng).await
}
pub async fn complete_handshake_with_rng<R>(
mut self,
rng: &mut R,
) -> Result<LpTransportSession, LpError>
where
S: LpHandshakeChannel + Unpin,
R: rand09::CryptoRng,
{
let ciphersuite = self.inner_state.local_peer.ciphersuite();
let kem = ciphersuite.kem();
let lp_peer_config = self.lp_peer_config(rng)?;
// 1. retrieve the expected kem key hash. if we don't know it,
let dir_hash = self
.initiator_data
.remote_peer
.expected_kem_key_hash(ciphersuite)?;
// 2. prepare and send KKT request
let (mut initiator, kkt_request) = if self.mode.is_mutual() {
// this has been verified when setting the mutual flag
let Some(local_encapsulation_key) = self.inner_state.local_peer.encoded_kem_key(kem)
else {
return Err(LpError::PSQMutualInitiatorMissingKemKey);
};
KKTInitiator::generate_mutual_request(
rng,
ciphersuite,
local_encapsulation_key,
self.initiator_data.remote_peer.x25519(),
&dir_hash,
self.initiator_data.protocol_version,
Some(Vec::from(lp_peer_config.serialize())),
)?
} else {
KKTInitiator::generate_one_way_request(
rng,
ciphersuite,
self.initiator_data.remote_peer.x25519(),
&dir_hash,
self.initiator_data.protocol_version,
Some(Vec::from(lp_peer_config.serialize())),
)?
};
let init_kem_key = self.inner_state.local_peer.kem_key(kem);
debug!("sending KKT request");
self.send_kkt_request(kkt_request).await?;
// 3. receive and process KKT response
let raw_response = self.receive_kkt_response().await?;
debug!("received KKT response");
// the responder does not send a payload
let response = initiator.process_response(raw_response, 0)?;
// 4. generate and send PSQ request
let protocol = self.initiator_data.protocol_version;
let conn = self.inner_state.connection;
// note: the clone is cheap due to internal Arcs
let resp_encapsulation_key = response.encapsulation_key.clone();
// build the PSQ initiator
let initiator_ciphersuite = build_psq_ciphersuite(
&self.inner_state.local_peer,
&self.initiator_data.remote_peer,
&response.encapsulation_key,
)?;
let mut psq_initiator = build_psq_principal(rng, protocol, initiator_ciphersuite)?;
// PSQ msg 1 send
let mut buf = vec![0u8; psq_msg1_size(kem)];
// annoyingly `RegistrationInitiator` has to write into unresizable `&mut [u8]`...
let n = psq_initiator.write_message(&[], &mut buf)?;
debug!("sending PSQ handshake msg");
if n != buf.len() {
return Err(LpError::Internal(
"unexpected changes in PSQ msg1 size".to_string(),
));
}
let msg = PSQMsg1::new(buf);
conn.send_handshake_message(msg, kem).await?;
// 5. receive and process PSQ response
let psq_msg: PSQMsg2 = conn.receive_handshake_message(PSQ_MSG2_SIZE).await?;
debug!("received PSQ handshake msg");
psq_initiator.read_message(&psq_msg, &mut [])?;
if !psq_initiator.is_handshake_finished() {
return Err(LpError::kkt_psq_handshake(
"noise handshake not finished after msg3",
"handshake not finished after receiving psq response",
));
}
let initiator_authenticator = Authenticator::Dh(self.inner_state.local_peer.x25519().pk);
let receiver_index = lp_peer_config
.derive_receiver_index(&initiator_authenticator, &resp_encapsulation_key)?;
let binding = PersistentSessionBinding {
initiator_authenticator,
responder_ecdh_pk: self.initiator_data.remote_peer.x25519_public,
responder_pq_pk: Some(resp_encapsulation_key),
initiator_pq_pk: init_kem_key,
};
let psq_session = psq_initiator.into_session()?;
LpTransportSession::new(psq_session, binding, receiver_index, protocol)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::{decrypt_data, encrypt_data};
use crate::peer::mock_peers;
use crate::peer_config::LP_PEER_CONFIG_SIZE;
use crate::psq::{PSQ_MSG2_SIZE, psq_msg1_size, responder};
use nym_kkt::context::KKTMode;
use nym_kkt::responder::KKTResponder;
use nym_kkt_ciphersuite::{Ciphersuite, HashFunction, IntoEnumIterator, KEM, SignatureScheme};
use nym_test_utils::helpers::{DeterministicRng09Send, u64_seeded_rng_09};
use nym_test_utils::mocks::async_read_write::MockIOStream;
use nym_test_utils::traits::{Leak, Timeboxed};
use std::collections::BTreeMap;
#[tokio::test]
async fn initiator_test_plain() -> anyhow::Result<()> {
for kem in KEM::iter() {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
let dir_hash_init = BTreeMap::new();
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
let (mut init, mut resp) = mock_peers();
let resp_remote = resp.as_remote();
let ciphersuite = Ciphersuite::default().with_kem(kem);
init.ciphersuite = ciphersuite;
resp.ciphersuite = ciphersuite;
let initiator_data = InitiatorData::new(1, resp_remote);
let handshake_init = PSQHandshakeState::new(conn_init, init)
.as_initiator(initiator_data, HandshakeMode::OneWayEntry)?;
let mut init_rng = DeterministicRng09Send::new(u64_seeded_rng_09(1));
let init_fut = tokio::spawn(async move {
handshake_init
.complete_handshake_with_rng(&mut init_rng)
.timeboxed()
.await
});
// responder:
let supported_sigs = [SignatureScheme::Ed25519];
let supported_hash = [
HashFunction::Blake3,
HashFunction::Shake256,
HashFunction::Shake128,
HashFunction::SHA256,
];
let resp_keys = resp.kem_keypairs.as_ref().unwrap();
let responder_x25519_keypair = resp.x25519();
let kkt_responder = KKTResponder::new(
responder_x25519_keypair,
resp_keys,
&dir_hash_init,
&supported_hash,
&supported_sigs,
&[1],
)?;
// 1. read KKT request
let raw_kkt_req: handshake_message::KKTRequest = conn_resp
.receive_handshake_message(
KKTRequest::size_excluding_payload(KKTMode::OneWay, kem) + LP_PEER_CONFIG_SIZE,
)
.timeboxed()
.await??;
let req = raw_kkt_req.into();
// 2. process
let processed_req = kkt_responder.process_request(req, LP_PEER_CONFIG_SIZE)?;
conn_resp
.send_handshake_message::<handshake_message::KKTResponse>(
processed_req.response.into(),
kem,
)
.timeboxed()
.await??;
// 3. read PSQ req
let responder_ciphersuite = responder::build_psq_ciphersuite(&resp, kem)?;
let mut responder =
responder::build_psq_principal(rand09::rng(), 1, responder_ciphersuite)?;
let response_len = psq_msg1_size(kem);
let msg: PSQMsg1 = conn_resp
.receive_handshake_message(response_len)
.timeboxed()
.await??;
responder.read_message(&msg, &mut []).unwrap();
// 4 send PSQ response
let mut buf = vec![0u8; PSQ_MSG2_SIZE];
let n = responder.write_message(&[], &mut buf).unwrap();
assert_eq!(n, buf.len());
let msg = PSQMsg2::new(buf);
conn_resp
.send_handshake_message(msg, kem)
.timeboxed()
.await??;
assert!(responder.is_handshake_finished());
let mut session_init = init_fut.await???;
let mut r_transport = responder.into_session().unwrap();
// test serialization, deserialization
let channel_i = session_init.active_transport();
let mut channel_r = r_transport.transport_channel().unwrap();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, channel_i)?;
let pt_r = decrypt_data(&ct_i, &mut channel_r)?;
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, &mut channel_r)?;
let pt_i = decrypt_data(&ct_r, channel_i)?;
assert_eq!(app_data_r, pt_i);
}
Ok(())
}
/// Receive final ACK that indicates finalisation of the handshake
pub(crate) async fn receive_final_ack(
&mut self,
outer_aead_key: &OuterAeadKey,
) -> Result<(), LpError> {
match self
.connection
.receive_packet(Some(outer_aead_key))
.await?
.message
{
LpMessage::Ack => Ok(()),
other => Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::Ack,
)),
}
}
#[tokio::test]
async fn initiator_test_plain_mutual() -> anyhow::Result<()> {
for kem in KEM::iter() {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
async fn complete_as_initiator_inner(
&mut self,
) -> Result<LpSession, IntermediateHandshakeFailure>
where
S: LpTransport + Unpin,
{
// 0. retrieve the expected kem key hash. if we don't know it,
// there's no point in even trying to start the handshake
let Some(remote_peer) = self.remote_peer.take() else {
return Err(IntermediateHandshakeFailure::plain(
LpError::kkt_psq_handshake("initiator can't proceed without remote information"),
));
};
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
// 1. Generate and send ClientHelloData with fresh salt and both public keys
// and keep retrying until we manage to establish a receiver index without collisions
let mut attempt = 0;
let client_hello_data = loop {
attempt += 1;
let (mut init, mut resp) = mock_peers();
let resp_remote = resp.as_remote();
let init_remote = init.as_remote();
let dir_hash_init = init_remote.expected_kem_key_digests;
debug!("sending client hello");
let client_hello = self
.send_client_hello()
.await
.map_err(IntermediateHandshakeFailure::plain)?;
if self
.receive_client_hello_ack()
.await
.map_err(IntermediateHandshakeFailure::plain)?
{
debug!("received client hello ACK");
break client_hello;
}
debug!("received client hello collision");
let ciphersuite = Ciphersuite::default().with_kem(kem);
init.ciphersuite = ciphersuite;
resp.ciphersuite = ciphersuite;
let initiator_data = InitiatorData::new(1, resp_remote);
// TODO: make it configurable
if attempt > 3 {
return Err(IntermediateHandshakeFailure::plain(
LpError::kkt_psq_handshake(
"failed to establish receiver index without collision",
),
));
}
};
let session_id = client_hello_data.receiver_index;
let session_id_bytes = session_id.to_le_bytes();
let salt = client_hello_data.salt;
let handshake_init = PSQHandshakeState::new(conn_init, init)
.as_initiator(initiator_data, HandshakeMode::MutualInternode)?;
// 3. prepare and send KKT request
debug!("sending KKT request");
let kkt_data = self
.send_kkt_request(session_id, &remote_peer)
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
let mut init_rng = DeterministicRng09Send::new(u64_seeded_rng_09(1));
// 4. receive and process KKT response
let encapsulation_key = self
.receive_kkt_response(kkt_data, &remote_peer)
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
debug!("received KKT response");
// 5. prepare and send PSQ msg1
debug!("sending PSQ msg1");
let (outer_aead_key, mut noise_protocol, pq_shared_secret) = self
.send_psq_initiator_message(&remote_peer, &encapsulation_key, &salt, &session_id_bytes)
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
// 6. receive and process PSQ msg2
debug!("received PSQ msg2");
if let Err(source) = self
.receive_psq_responder_message(&outer_aead_key, &mut noise_protocol)
.await
{
return Err(IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: Some(outer_aead_key),
source,
let init_fut = tokio::spawn(async move {
handshake_init
.complete_handshake_with_rng(&mut init_rng)
.timeboxed()
.await
});
}
// 7. prepare and send PSQ msg3
debug!("sending PSQ msg3");
if let Err(source) = self
.send_final_psq_message(session_id, &outer_aead_key, &mut noise_protocol)
.await
{
return Err(IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: Some(outer_aead_key),
source,
});
}
// responder:
let supported_sigs = [SignatureScheme::Ed25519];
let supported_hash = [
HashFunction::Blake3,
HashFunction::Shake256,
HashFunction::Shake128,
HashFunction::SHA256,
];
let resp_keys = resp.kem_keypairs.as_ref().unwrap();
let responder_x25519_keypair = resp.x25519();
// 8. receive final ACK and finalise
debug!("received final ACK");
if let Err(source) = self.receive_final_ack(&outer_aead_key).await {
return Err(IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: Some(outer_aead_key),
source,
});
}
let kkt_responder = KKTResponder::new(
responder_x25519_keypair,
resp_keys,
&dir_hash_init,
&supported_hash,
&supported_sigs,
&[1],
)?;
#[allow(clippy::expect_used)]
Ok(LpSession::new(
session_id,
self.protocol_version()
.expect("protocol version is known at this point"),
outer_aead_key,
self.local_peer.clone(),
remote_peer,
pq_shared_secret,
noise_protocol,
))
}
// 1. read KKT request
let raw_kkt_req: handshake_message::KKTRequest = conn_resp
.receive_handshake_message(
KKTRequest::size_excluding_payload(KKTMode::Mutual, kem) + LP_PEER_CONFIG_SIZE,
)
.timeboxed()
.await??;
let req = raw_kkt_req.into();
// TODO: missing: receive counter check
pub async fn complete_as_initiator(mut self) -> Result<LpSession, LpError>
where
S: LpTransport + Unpin,
{
match self.complete_as_initiator_inner().await {
Ok(res) => Ok(res),
Err(err) => Err(self.try_send_error_packet(err).await),
// 2. process
let processed_req = kkt_responder.process_request(req, LP_PEER_CONFIG_SIZE)?;
conn_resp
.send_handshake_message::<handshake_message::KKTResponse>(
processed_req.response.into(),
kem,
)
.timeboxed()
.await??;
// 3. read PSQ req
let responder_ciphersuite = responder::build_psq_ciphersuite(&resp, kem)?;
let mut responder =
responder::build_psq_principal(rand09::rng(), 1, responder_ciphersuite)?;
let response_len = psq_msg1_size(kem);
let msg: PSQMsg1 = conn_resp
.receive_handshake_message(response_len)
.timeboxed()
.await??;
responder.read_message(&msg, &mut []).unwrap();
// 4 send PSQ response
let mut buf = vec![0u8; PSQ_MSG2_SIZE];
let n = responder.write_message(&[], &mut buf).unwrap();
assert_eq!(n, buf.len());
let msg = PSQMsg2::new(buf);
conn_resp
.send_handshake_message(msg, kem)
.timeboxed()
.await??;
assert!(responder.is_handshake_finished());
let mut session_init = init_fut.await???;
let mut r_transport = responder.into_session().unwrap();
// test serialization, deserialization
let channel_i = session_init.active_transport();
let mut channel_r = r_transport.transport_channel().unwrap();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, channel_i)?;
let pt_r = decrypt_data(&ct_i, &mut channel_r)?;
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, &mut channel_r)?;
let pt_i = decrypt_data(&ct_r, channel_i)?;
assert_eq!(app_data_r, pt_i);
}
Ok(())
}
}
+577 -254
View File
@@ -1,172 +1,134 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::codec::OuterAeadKey;
use crate::message::ErrorPacketData;
use crate::packet::LpHeader;
use crate::packet::version;
use crate::peer::{LpLocalPeer, LpRemotePeer};
use crate::psq::helpers::LpTransportHandshakeExt;
use crate::{LpError, LpMessage, LpPacket};
use nym_kkt::ciphersuite::Ciphersuite;
use nym_lp_transport::traits::LpTransport;
use tracing::debug;
use crate::transport::traits::LpHandshakeChannel;
use nym_kkt_ciphersuite::{HashFunction, IntoEnumIterator, KEM, KEMKeyDigests, SignatureScheme};
use std::collections::BTreeMap;
pub(crate) mod handshake_message;
mod helpers;
mod initiator;
mod responder;
pub mod initiator;
pub mod responder;
pub(crate) struct IntermediateHandshakeFailure {
/// Session id established during exchange if we managed to derive it
session_id: Option<u32>,
use crate::LpError;
use crate::psq::initiator::HandshakeMode;
pub use initiator::PSQHandshakeStateInitiator;
pub use responder::PSQHandshakeStateResponder;
/// Protocol version established during the exchange
protocol_version: Option<u8>,
pub(crate) const AAD_INITIATOR_OUTER_V1: &[u8] = b"NYM-PQ-AAD-INIT-OUTER-V1";
pub(crate) const AAD_INITIATOR_INNER_V1: &[u8] = b"NYM-PQ-AAD-INIT-INNER-V1";
pub(crate) const AAD_RESPONDER_V1: &[u8] = b"NYM-PQ-AAD-RESP-V1";
pub(crate) const SESSION_CONTEXT_V1: &[u8] = b"NYM-PQ-SESSION-CONTEXT-V1";
/// Outer aead key established during exchange if we managed to derive it
outer_aead_key: Option<OuterAeadKey>,
/// The error source
source: LpError,
}
impl IntermediateHandshakeFailure {
fn plain(source: LpError) -> IntermediateHandshakeFailure {
IntermediateHandshakeFailure {
session_id: None,
protocol_version: None,
outer_aead_key: None,
source,
}
/// Size of the first (initiator) PSQ message including all serialisation overheads if no additional payload has been attached
pub(crate) fn psq_msg1_size(kem: KEM) -> usize {
match kem {
KEM::MlKem768 => 1247,
KEM::McEliece => 315,
}
}
/// Size of the second (responder) PSQ message including all serialisation overheads if no additional payload has been attached
pub(crate) const PSQ_MSG2_SIZE: usize = 70;
pub struct PSQHandshakeState<'a, S> {
/// The underlying connection established for the handshake
connection: &'a mut S,
/// Protocol version used for the exchange.
/// either known implicitly through the directory (initiator)
/// or established through client hello (responder)
protocol_version: Option<u8>,
/// Ciphersuite selected for the KKT/PSQ exchange
ciphersuite: Ciphersuite,
/// Representation of a local Lewes Protocol peer
/// encapsulating all the known information and keys.
local_peer: LpLocalPeer,
}
#[derive(Debug)]
pub struct InitiatorData {
/// Protocol version used for the exchange known implicitly through the directory
pub protocol_version: u8,
/// Representation of a remote Lewes Protocol peer
/// encapsulating all the known information and keys.
remote_peer: Option<LpRemotePeer>,
pub remote_peer: LpRemotePeer,
}
/// Counter for outgoing packets
sending_counter: u64,
impl InitiatorData {
pub fn new(protocol_version: u8, remote_peer: LpRemotePeer) -> Self {
InitiatorData {
protocol_version,
remote_peer,
}
}
}
#[derive(Debug, Clone)]
pub struct ResponderData {
/// List of supported Hash Functions by this Responder
pub supported_hash_functions: Vec<HashFunction>,
/// List of supported Signature Schemes by this Responder
pub supported_signature_schemes: Vec<SignatureScheme>,
/// List of supported outer (LP) protocol version by this Responder
pub supported_outer_protocol_versions: Vec<u8>,
/// Expected KEM hashes of the initiator.
/// It is only expected to be populated for the mutual mode of the KKT.
/// Otherwise the map is empty.
pub initiator_kem_hashes: BTreeMap<KEM, KEMKeyDigests>,
}
impl ResponderData {
#[must_use]
pub fn with_initiator_kem_hashes(mut self, kem_hashes: BTreeMap<KEM, KEMKeyDigests>) -> Self {
self.initiator_kem_hashes = kem_hashes;
self
}
}
impl Default for ResponderData {
fn default() -> Self {
// by default all schemes are supported
ResponderData {
supported_hash_functions: HashFunction::iter().collect(),
supported_signature_schemes: SignatureScheme::iter().collect(),
supported_outer_protocol_versions: vec![version::CURRENT],
initiator_kem_hashes: Default::default(),
}
}
}
impl<'a, S> PSQHandshakeState<'a, S>
where
S: LpTransport + Unpin,
S: LpHandshakeChannel + Unpin,
{
pub fn new(connection: &'a mut S, ciphersuite: Ciphersuite, local_peer: LpLocalPeer) -> Self {
pub fn new(connection: &'a mut S, local_peer: LpLocalPeer) -> Self {
PSQHandshakeState {
connection,
protocol_version: None,
ciphersuite,
local_peer,
remote_peer: None,
sending_counter: 0,
}
}
#[must_use]
pub fn with_protocol_version(mut self, protocol_version: u8) -> Self {
self.protocol_version = Some(protocol_version);
self
}
#[must_use]
pub fn with_remote_peer(mut self, remote_peer: LpRemotePeer) -> Self {
self.remote_peer = Some(remote_peer);
self
}
fn protocol_version(&self) -> Result<u8, LpError> {
self.protocol_version
.ok_or_else(|| LpError::kkt_psq_handshake("unknown protocol version"))
}
/// Generates the next counter value for outgoing packets.
pub fn next_counter(&mut self) -> u64 {
let counter = self.sending_counter;
self.sending_counter += 1;
counter
}
pub fn next_packet(
&mut self,
session_id: u32,
protocol_version: u8,
message: LpMessage,
) -> LpPacket {
let counter = self.next_counter();
let header = LpHeader::new(session_id, counter, protocol_version);
LpPacket::new(header, message)
}
pub(crate) async fn try_send_error_packet(
&mut self,
err: IntermediateHandshakeFailure,
) -> LpError {
// if session_id is not known, we can't send the packet back (with the current design)
let (Some(session_id), Some(protocol)) = (err.session_id, err.protocol_version) else {
return err.source;
};
if let Err(err) = self
.send_error_packet(
session_id,
protocol,
err.source.to_string(),
err.outer_aead_key.as_ref(),
)
.await
{
debug!("failed to send back error response: {err}")
pub fn as_initiator(
self,
initiator_data: InitiatorData,
mode: HandshakeMode,
) -> Result<PSQHandshakeStateInitiator<'a, S>, LpError> {
if mode.is_mutual() && self.local_peer.kem_keypairs.is_none() {
return Err(LpError::PSQMutualInitiatorMissingKemKey);
}
err.source
Ok(PSQHandshakeStateInitiator {
initiator_data,
inner_state: self,
mode,
})
}
/// Attempt to send an error packet
pub(crate) async fn send_error_packet(
&mut self,
session_id: u32,
protocol_version: u8,
msg: impl Into<String>,
outer_aead_key: Option<&OuterAeadKey>,
) -> Result<(), LpError> {
let packet = self.next_packet(
session_id,
protocol_version,
LpMessage::Error(ErrorPacketData::new(msg)),
);
self.connection.send_packet(packet, outer_aead_key).await?;
Ok(())
}
/// Attempt to receive a packet from connection, explicitly checking for an error response
/// and returning corresponding message if received
pub(crate) async fn receive_non_error(
&mut self,
outer_aead_key: Option<&OuterAeadKey>,
) -> Result<LpPacket, LpError> {
let packet = self.connection.receive_packet(outer_aead_key).await?;
match &packet.message {
LpMessage::Error(error_packet) => Err(LpError::kkt_psq_handshake(format!(
"remote error: {}",
error_packet.message
))),
_ => Ok(packet),
pub fn as_responder(self, responder_data: ResponderData) -> PSQHandshakeStateResponder<'a, S> {
PSQHandshakeStateResponder {
responder_data,
inner_state: self,
}
}
}
@@ -174,167 +136,528 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::{decrypt_data, encrypt_data};
use crate::peer::mock_peers;
use crate::psq::helpers::LpTransportHandshakeExt;
use crate::psq::responder::DEFAULT_TIMESTAMP_TOLERANCE;
use mock_instant::thread_local::MockClock;
use nym_kkt::ciphersuite::{HashFunction, HashLength, KEM, SignatureScheme};
use crate::peer_config::{LP_PEER_CONFIG_SIZE, LpPeerConfig};
use libcrux_psq::handshake::types::Authenticator;
use libcrux_psq::session::{Session, SessionBinding};
use libcrux_psq::{Channel, IntoSession};
use nym_kkt::initiator::KKTInitiator;
use nym_kkt::responder::KKTResponder;
use nym_kkt_ciphersuite::{Ciphersuite, HashFunction, KEM, SignatureScheme};
use nym_test_utils::helpers::{
DeterministicRng09Send, deterministic_rng_09, u64_seeded_rng_09,
};
use nym_test_utils::mocks::async_read_write::MockIOStream;
use nym_test_utils::traits::{Leak, TimeboxedSpawnable};
use std::time::Duration;
use std::collections::BTreeMap;
use tokio::join;
#[allow(dead_code)]
async fn extract_error(conn: &mut MockIOStream) -> String {
let packet = conn.receive_packet(None).await.unwrap();
match packet.message {
LpMessage::Error(error) => error.message,
_ => panic!("non error packet"),
}
}
#[tokio::test]
async fn e2e_psq_handshake() -> anyhow::Result<()> {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
for kem in KEM::iter() {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
let ciphersuite = Ciphersuite::default().with_kem(kem);
let ciphersuite = Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
);
let (mut init, mut resp) = mock_peers();
init.ciphersuite = ciphersuite;
resp.ciphersuite = ciphersuite;
let resp_remote = resp.as_remote();
let (init, resp) = mock_peers();
let resp_remote = resp.as_remote();
let handshake_init = PSQHandshakeState::new(conn_init, init).as_initiator(
InitiatorData::new(1, resp_remote),
HandshakeMode::OneWayEntry,
)?;
let handshake_resp =
PSQHandshakeState::new(conn_resp, resp).as_responder(ResponderData::default());
let handshake_init = PSQHandshakeState::new(conn_init, ciphersuite, init)
.with_protocol_version(1)
.with_remote_peer(resp_remote);
let handshake_resp = PSQHandshakeState::new(conn_resp, ciphersuite, resp);
let init_rng = DeterministicRng09Send::new(u64_seeded_rng_09(1));
let resp_rng = DeterministicRng09Send::new(u64_seeded_rng_09(2));
let resp_fut = handshake_resp.complete_as_responder().spawn_timeboxed();
let init_fut = handshake_init.complete_as_initiator().spawn_timeboxed();
// similarly leak the rngs to get the static lifetimes
let init_rng = init_rng.leak();
let resp_rng = resp_rng.leak();
let (session_init, session_resp) = join!(init_fut, resp_fut);
let init_fut = handshake_init
.complete_handshake_with_rng(init_rng)
.spawn_timeboxed();
let resp_fut = handshake_resp
.complete_handshake_with_rng(resp_rng)
.spawn_timeboxed();
let session_init = session_init???;
let session_resp = session_resp???;
let (session_init, session_resp) = join!(init_fut, resp_fut);
assert_eq!(session_init.id(), session_resp.id());
assert_eq!(
session_init.outer_aead_key().as_bytes(),
session_resp.outer_aead_key().as_bytes()
);
assert_eq!(
session_init.pq_shared_secret().as_bytes(),
session_resp.pq_shared_secret().as_bytes()
);
let mut session_init = session_init???;
let mut session_resp = session_resp???;
assert_eq!(session_init.receiver_index(), session_resp.receiver_index());
assert_eq!(
session_init.session_identifier(),
session_resp.session_identifier()
);
// test serialization, deserialization
let channel_i = session_init.active_transport();
let channel_r = session_resp.active_transport();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, channel_i)?;
let pt_r = decrypt_data(&ct_i, channel_r)?;
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, channel_r)?;
let pt_i = decrypt_data(&ct_r, channel_i)?;
assert_eq!(app_data_r, pt_i);
}
Ok(())
}
#[tokio::test]
async fn preparing_client_hello_initiator() -> anyhow::Result<()> {
let mut conn_init = MockIOStream::default();
let mut conn_resp = conn_init.try_get_remote_handle();
async fn e2e_psq_mutual_handshake() -> anyhow::Result<()> {
for kem in KEM::iter() {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
let ciphersuite = Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
);
let (init, resp) = mock_peers();
let resp_remote = resp.as_remote();
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
let ciphersuite = Ciphersuite::default().with_kem(kem);
// as initiator
let mut handshake_init = PSQHandshakeState::new(&mut conn_init, ciphersuite, init)
.with_protocol_version(1)
.with_remote_peer(resp_remote);
let (mut init, mut resp) = mock_peers();
init.ciphersuite = ciphersuite;
resp.ciphersuite = ciphersuite;
let resp_remote = resp.as_remote();
let init_remote = init.as_remote();
let handshake_init = PSQHandshakeState::new(conn_init, init).as_initiator(
InitiatorData::new(1, resp_remote),
HandshakeMode::MutualInternode,
)?;
let handshake_resp = PSQHandshakeState::new(conn_resp, resp).as_responder(
ResponderData::default()
.with_initiator_kem_hashes(init_remote.expected_kem_key_digests),
);
let init_rng = DeterministicRng09Send::new(u64_seeded_rng_09(1));
let resp_rng = DeterministicRng09Send::new(u64_seeded_rng_09(2));
// similarly leak the rngs to get the static lifetimes
let init_rng = init_rng.leak();
let resp_rng = resp_rng.leak();
let init_fut = handshake_init
.complete_handshake_with_rng(init_rng)
.spawn_timeboxed();
let resp_fut = handshake_resp
.complete_handshake_with_rng(resp_rng)
.spawn_timeboxed();
let (session_init, session_resp) = join!(init_fut, resp_fut);
let mut session_init = session_init???;
let mut session_resp = session_resp???;
assert_eq!(session_init.receiver_index(), session_resp.receiver_index());
assert_eq!(
session_init.session_identifier(),
session_resp.session_identifier()
);
// test serialization, deserialization
let channel_i = session_init.active_transport();
let channel_r = session_resp.active_transport();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, channel_i)?;
let pt_r = decrypt_data(&ct_i, channel_r)?;
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, channel_r)?;
let pt_i = decrypt_data(&ct_r, channel_i)?;
assert_eq!(app_data_r, pt_i);
}
// you can generate and send (valid) client hello as initiator
let client_hello = handshake_init.send_client_hello().await?;
let LpMessage::ClientHello(received_client_hello) =
conn_resp.receive_packet(None).await?.message
else {
panic!("wrong message type");
};
assert_eq!(client_hello, received_client_hello);
Ok(())
}
// essentially make sure you can't accidentally trigger the handshake as the responder
#[tokio::test]
async fn preparing_client_hello_responder() -> anyhow::Result<()> {
let conn_init = MockIOStream::default();
let mut conn_resp = conn_init.try_get_remote_handle();
// plain test without any wrappers
#[test]
fn e2e_test_plain() {
let mut rng = deterministic_rng_09();
let ciphersuite = Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
);
let (_, resp) = mock_peers();
for kem in KEM::iter() {
// SETUP START:
let protocol_version = 1;
let (mut init, resp) = mock_peers();
init.ciphersuite = Ciphersuite::default().with_kem(kem);
let resp_remote = resp.as_remote();
let dir_hash = resp_remote.expected_kem_key_hash(init.ciphersuite).unwrap();
let dir_hash_init = BTreeMap::new();
// as initiator
let mut handshake_resp = PSQHandshakeState::new(&mut conn_resp, ciphersuite, resp);
let resp_keys = resp.kem_keypairs.as_ref().unwrap();
let responder_x25519_keypair = resp.x25519();
// you can generate and send (valid) client hello as initiator
let sending_res = handshake_resp.send_client_hello().await;
assert!(sending_res.is_err());
Ok(())
let supported_sigs = [SignatureScheme::Ed25519];
let supported_hash = [
HashFunction::Blake3,
HashFunction::Shake256,
HashFunction::Shake128,
HashFunction::SHA256,
];
let kkt_responder = KKTResponder::new(
responder_x25519_keypair,
resp_keys,
&dir_hash_init,
&supported_hash,
&supported_sigs,
&[protocol_version],
)
.unwrap();
// SETUP END
let lp_peer_config = LpPeerConfig::new_client_to_entry(&mut rng, false);
// OneWay - MlKem
let (mut initiator, request) = KKTInitiator::generate_one_way_request(
&mut rng,
init.ciphersuite,
&responder_x25519_keypair.pk,
&dir_hash,
protocol_version,
Some(Vec::from(lp_peer_config.serialize())),
)
.unwrap();
let processed_req = kkt_responder
.process_request(request, LP_PEER_CONFIG_SIZE)
.unwrap();
let response = initiator
.process_response(processed_req.response, 0)
.unwrap();
let encapsulation_key = response.encapsulation_key;
let mut payload_buf_responder = vec![0u8; 4096];
let mut payload_buf_initiator = vec![0u8; 4096];
let initiator_ciphersuite =
initiator::build_psq_ciphersuite(&init, &resp_remote, &encapsulation_key).unwrap();
let mut initiator = initiator::build_psq_principal(
rand09::rng(),
protocol_version,
initiator_ciphersuite,
)
.unwrap();
let responder_ciphersuite = responder::build_psq_ciphersuite(&resp, kem).unwrap();
let mut responder = responder::build_psq_principal(
rand09::rng(),
protocol_version,
responder_ciphersuite,
)
.unwrap();
// Send first message
let mut buf = vec![0u8; psq_msg1_size(kem)];
let len_i = initiator.write_message(&[], &mut buf).unwrap();
assert_eq!(len_i, buf.len());
// Read first message
let (_, _) = responder
.read_message(&buf, &mut payload_buf_responder)
.unwrap();
// Get the authenticator out here, so we can deserialize the session later.
let Some(initiator_authenticator) = responder.initiator_authenticator() else {
panic!("No initiator authenticator found")
};
// Respond
let mut buf = [0u8; PSQ_MSG2_SIZE];
let len_r = responder.write_message(&[], &mut buf).unwrap();
assert_eq!(len_r, buf.len());
// Finalize on registration initiator
let (len_i_deserialized, _) = initiator
.read_message(&buf, &mut payload_buf_initiator)
.unwrap();
// We read the same amount of data.
assert_eq!(len_r, len_i_deserialized);
// Ready for transport mode
assert!(initiator.is_handshake_finished());
assert!(responder.is_handshake_finished());
let i_transport = initiator.into_session().unwrap();
let r_transport = responder.into_session().unwrap();
// test serialization, deserialization
let mut session_storage = vec![0u8; 4096];
i_transport
.serialize(
&mut session_storage,
SessionBinding {
initiator_authenticator: &Authenticator::Dh(init.x25519().pk),
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
let mut i_transport = Session::deserialize(
&session_storage,
SessionBinding {
initiator_authenticator: &Authenticator::Dh(init.x25519().pk),
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
r_transport
.serialize(
&mut session_storage,
SessionBinding {
initiator_authenticator: &initiator_authenticator,
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
let mut r_transport = Session::deserialize(
&session_storage,
SessionBinding {
initiator_authenticator: &initiator_authenticator,
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
let mut channel_i = i_transport.transport_channel().unwrap();
let mut channel_r = r_transport.transport_channel().unwrap();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, &mut channel_i).unwrap();
let pt_r = decrypt_data(&ct_i, &mut channel_r).unwrap();
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, &mut channel_r).unwrap();
let pt_i = decrypt_data(&ct_r, &mut channel_i).unwrap();
assert_eq!(app_data_r, pt_i);
}
}
#[tokio::test]
async fn test_receive_client_hello_timestamp_too_skewed() -> anyhow::Result<()> {
let current_time = Duration::from_secs(10000);
MockClock::set_system_time(current_time);
#[test]
fn e2e_test_plain_mutual() {
let mut rng = deterministic_rng_09();
let too_old = current_time - DEFAULT_TIMESTAMP_TOLERANCE - Duration::from_secs(1);
let too_recent = current_time + DEFAULT_TIMESTAMP_TOLERANCE + Duration::from_secs(1);
for kem in KEM::iter() {
// SETUP START:
let protocol_version = 1;
let (mut init, resp) = mock_peers();
init.ciphersuite = Ciphersuite::default().with_kem(kem);
let ciphersuite = Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
);
let init_remote = init.as_remote();
let resp_remote = resp.as_remote();
// TOO OLD
let mut conn_init = MockIOStream::default();
let mut conn_resp = conn_init.try_get_remote_handle();
let (init, resp) = mock_peers();
let dir_hash_init = init_remote.expected_kem_key_digests.clone();
let dir_hash_resp = resp_remote.expected_kem_key_hash(init.ciphersuite).unwrap();
let mut handshake_resp = PSQHandshakeState::new(&mut conn_resp, ciphersuite, resp);
let client_hello_too_old = init.build_client_hello_data(too_old.as_secs());
let resp_keys = resp.kem_keypairs.as_ref().unwrap();
let responder_x25519_keypair = resp.x25519();
conn_init
.send_packet(client_hello_too_old.into_lp_packet(1), None)
.await?;
let err = handshake_resp.receive_client_hello().await.unwrap_err();
assert!(err.to_string().contains("too old"));
let init_keys = init.kem_keypairs.as_ref().unwrap();
let init_kem = init_keys.encoded_encapsulation_key(kem).unwrap();
// TOO RECENT
let mut conn_init = MockIOStream::default();
let mut conn_resp = conn_init.try_get_remote_handle();
let (init, resp) = mock_peers();
let supported_sigs = [SignatureScheme::Ed25519];
let supported_hash = [
HashFunction::Blake3,
HashFunction::Shake256,
HashFunction::Shake128,
HashFunction::SHA256,
];
let kkt_responder = KKTResponder::new(
responder_x25519_keypair,
resp_keys,
&dir_hash_init,
&supported_hash,
&supported_sigs,
&[protocol_version],
)
.unwrap();
let mut handshake_resp = PSQHandshakeState::new(&mut conn_resp, ciphersuite, resp);
let client_hello_too_recent = init.build_client_hello_data(too_recent.as_secs());
// SETUP END
conn_init
.send_packet(client_hello_too_recent.into_lp_packet(1), None)
.await?;
let err = handshake_resp.receive_client_hello().await.unwrap_err();
let lp_peer_config = LpPeerConfig::new_client_to_entry(&mut rng, false);
assert!(err.to_string().contains("too future"));
Ok(())
// OneWay - MlKem
let (mut initiator, request) = KKTInitiator::generate_mutual_request(
&mut rng,
init.ciphersuite,
init_kem,
&responder_x25519_keypair.pk,
&dir_hash_resp,
protocol_version,
Some(Vec::from(lp_peer_config.serialize())),
)
.unwrap();
let processed_req = kkt_responder
.process_request(request, LP_PEER_CONFIG_SIZE)
.unwrap();
let init_key = processed_req.remote_encapsulation_key.unwrap();
assert_eq!(init_key.as_bytes(), init_kem);
let response = initiator
.process_response(processed_req.response, 0)
.unwrap();
let encapsulation_key = response.encapsulation_key;
let mut payload_buf_responder = vec![0u8; 4096];
let mut payload_buf_initiator = vec![0u8; 4096];
let initiator_ciphersuite =
initiator::build_psq_ciphersuite(&init, &resp_remote, &encapsulation_key).unwrap();
let mut initiator = initiator::build_psq_principal(
rand09::rng(),
protocol_version,
initiator_ciphersuite,
)
.unwrap();
let responder_ciphersuite = responder::build_psq_ciphersuite(&resp, kem).unwrap();
let mut responder = responder::build_psq_principal(
rand09::rng(),
protocol_version,
responder_ciphersuite,
)
.unwrap();
// Send first message
let mut buf = vec![0u8; psq_msg1_size(kem)];
let len_i = initiator.write_message(&[], &mut buf).unwrap();
assert_eq!(len_i, buf.len());
// Read first message
let (_, _) = responder
.read_message(&buf, &mut payload_buf_responder)
.unwrap();
// Get the authenticator out here, so we can deserialize the session later.
let Some(initiator_authenticator) = responder.initiator_authenticator() else {
panic!("No initiator authenticator found")
};
// Respond
let mut buf = [0u8; PSQ_MSG2_SIZE];
let len_r = responder.write_message(&[], &mut buf).unwrap();
assert_eq!(len_r, buf.len());
// Finalize on registration initiator
let (len_i_deserialized, _) = initiator
.read_message(&buf, &mut payload_buf_initiator)
.unwrap();
// We read the same amount of data.
assert_eq!(len_r, len_i_deserialized);
// Ready for transport mode
assert!(initiator.is_handshake_finished());
assert!(responder.is_handshake_finished());
let i_transport = initiator.into_session().unwrap();
let r_transport = responder.into_session().unwrap();
// test serialization, deserialization
let mut session_storage = vec![0u8; 4096];
i_transport
.serialize(
&mut session_storage,
SessionBinding {
initiator_authenticator: &Authenticator::Dh(init.x25519().pk),
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
let mut i_transport = Session::deserialize(
&session_storage,
SessionBinding {
initiator_authenticator: &Authenticator::Dh(init.x25519().pk),
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
r_transport
.serialize(
&mut session_storage,
SessionBinding {
initiator_authenticator: &initiator_authenticator,
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
let mut r_transport = Session::deserialize(
&session_storage,
SessionBinding {
initiator_authenticator: &initiator_authenticator,
responder_ecdh_pk: &responder_x25519_keypair.pk,
responder_pq_pk: Some(encapsulation_key.as_pq_encapsulation_key()),
},
)
.unwrap();
let mut channel_i = i_transport.transport_channel().unwrap();
let mut channel_r = r_transport.transport_channel().unwrap();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, &mut channel_i).unwrap();
let pt_r = decrypt_data(&ct_i, &mut channel_r).unwrap();
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, &mut channel_r).unwrap();
let pt_i = decrypt_data(&ct_r, &mut channel_i).unwrap();
assert_eq!(app_data_r, pt_i);
}
}
}
+456 -422
View File
@@ -1,461 +1,495 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::codec::OuterAeadKey;
use crate::message::{HandshakeData, KKTResponseData, MessageType};
use crate::noise_protocol::NoiseProtocol;
use crate::peer::LpRemotePeer;
use crate::psk::psq_responder_process_message;
use crate::psq::helpers::{LpTransportHandshakeExt, current_timestamp};
use crate::psq::{IntermediateHandshakeFailure, PSQHandshakeState};
use crate::session::PqSharedSecret;
use crate::{ClientHelloData, LpError, LpMessage, LpSession};
use nym_kkt::KKT_RESPONSE_AAD;
use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey};
use nym_kkt::context::KKTContext;
use nym_kkt::encryption::{KKTSessionSecret, decrypt_initial_kkt_frame, encrypt_kkt_frame};
use nym_kkt::frame::KKTSessionId;
use nym_kkt::session::{responder_ingest_message, responder_process};
use nym_lp_transport::traits::LpTransport;
use rand09::rng;
use std::time::Duration;
use crate::peer::LpLocalPeer;
use crate::peer_config::{LP_PEER_CONFIG_SIZE, LpPeerConfig};
use crate::psq::handshake_message::{PSQMsg1, PSQMsg2};
use crate::psq::helpers::kem_to_ciphersuite;
use crate::psq::{
AAD_RESPONDER_V1, PSQ_MSG2_SIZE, PSQHandshakeState, ResponderData, SESSION_CONTEXT_V1,
handshake_message, psq_msg1_size,
};
use crate::session::PersistentSessionBinding;
use crate::transport::traits::{HandshakeMessage, LpHandshakeChannel};
use crate::{LpError, LpTransportSession};
use libcrux_psq::handshake::Responder;
use libcrux_psq::handshake::builders::{
CiphersuiteBuilder, PrincipalBuilder, ResponderCiphersuite,
};
use libcrux_psq::{Channel, IntoSession};
use nym_kkt::context::KKTMode;
use nym_kkt::message::{KKTRequest, KKTResponse, ProcessedKKTRequest};
use nym_kkt::responder::KKTResponder;
use nym_kkt_ciphersuite::KEM;
use rand09::SeedableRng;
use tracing::debug;
pub const DEFAULT_TIMESTAMP_TOLERANCE: Duration = Duration::from_secs(30);
// this will be removed anyway, so no point in doing anything more than a hardcoded placeholder
fn validate_client_hello_timestamp(
client_timestamp: u64,
tolerance: Duration,
) -> Result<(), LpError> {
let now = current_timestamp()?;
let age = now.abs_diff(client_timestamp);
if age > tolerance.as_secs() {
let direction = if now >= client_timestamp {
"old"
} else {
"future"
};
return Err(LpError::kkt_psq_handshake(format!(
"ClientHello timestamp is too {direction} (age: {age}s, tolerance: {}s)",
tolerance.as_secs()
)));
}
Ok(())
pub struct PSQHandshakeStateResponder<'a, S> {
pub(super) inner_state: PSQHandshakeState<'a, S>,
pub(super) responder_data: ResponderData,
}
impl<'a, S> PSQHandshakeState<'a, S>
pub(crate) fn build_psq_principal<R>(
rng: R,
version: u8,
ciphersuite: ResponderCiphersuite,
) -> Result<Responder<R>, LpError>
where
S: LpTransport + Unpin,
R: rand09::CryptoRng,
{
pub(crate) fn encapsulated_kem_keys(
&self,
) -> Result<(DecapsulationKey<'static>, EncapsulationKey<'static>), LpError> {
let kem_keys = self
let (ctx, aad) = match version {
1 => (SESSION_CONTEXT_V1, AAD_RESPONDER_V1),
other => return Err(LpError::UnsupportedVersion { version: other }),
};
PrincipalBuilder::new(rng)
.context(ctx)
.outer_aad(aad)
.recent_keys_upper_bound(30)
.build_responder(ciphersuite)
.map_err(|inner| LpError::PSQResponderBuilderFailure { inner })
}
pub(crate) fn build_psq_ciphersuite(
peer: &LpLocalPeer,
kem: KEM,
) -> Result<ResponderCiphersuite<'_>, LpError> {
let Some(kem_keys) = peer.kem_keypairs.as_ref() else {
return Err(LpError::ResponderWithMissingKEMKeys);
};
let psq_ciphersuite = kem_to_ciphersuite(kem);
let builder = CiphersuiteBuilder::new(psq_ciphersuite).longterm_x25519_keys(peer.x25519());
match kem {
KEM::MlKem768 => builder
.longterm_mlkem_encapsulation_key(kem_keys.ml_kem768_encapsulation_key())
.longterm_mlkem_decapsulation_key(kem_keys.ml_kem768_decapsulation_key()),
KEM::McEliece => builder
.longterm_cmc_encapsulation_key(kem_keys.mc_eliece_encapsulation_key())
.longterm_cmc_decapsulation_key(kem_keys.mc_eliece_decapsulation_key()),
}
.build_responder_ciphersuite()
.map_err(|inner| LpError::PSQResponderBuilderFailure { inner })
}
impl<'a, S> PSQHandshakeStateResponder<'a, S>
where
S: LpHandshakeChannel + Unpin,
{
async fn receive_kkt_request(&mut self, mode: KKTMode) -> Result<KKTRequest, LpError> {
let packet_len =
KKTRequest::size_excluding_payload(mode, self.inner_state.local_peer.ciphersuite.kem())
+ LP_PEER_CONFIG_SIZE;
// TODO: we have an issue here: if initiator sends us a KEM key of different type
// than our ciphersuite, we will fail to receive it.
// Surely this won't blow up in our faces later... right?
let req = self
.inner_state
.connection
.receive_handshake_message::<handshake_message::KKTRequest>(packet_len)
.await?;
Ok(req.into())
}
/// Attempt to receive a KKT request from a one-way client
async fn receive_one_way_kkt_request(&mut self) -> Result<KKTRequest, LpError> {
Self::receive_kkt_request(self, KKTMode::OneWay).await
}
/// Attempt to receive a KKT request from a mutual client
async fn receive_mutual_kkt_request(&mut self) -> Result<KKTRequest, LpError> {
Self::receive_kkt_request(self, KKTMode::Mutual).await
}
/// Attempt to process the received KKT request
fn process_kkt_request(&self, kkt_request: KKTRequest) -> Result<ProcessedKKTRequest, LpError> {
let kem_keys = &self
.inner_state
.local_peer
.kem_psq
.kem_keypairs
.as_ref()
.ok_or(LpError::ResponderWithMissingKEMKey)?;
.ok_or(LpError::ResponderWithMissingKEMKeys)?;
let libcrux_private_key = libcrux_kem::PrivateKey::decode(
libcrux_kem::Algorithm::X25519,
kem_keys.private_key().as_bytes(),
)
.map_err(|e| {
LpError::KKTError(format!(
"Failed to convert X25519 private key to libcrux PrivateKey: {e:?}",
))
})?;
let dec_key = DecapsulationKey::X25519(libcrux_private_key);
let libcrux_public_key = libcrux_kem::PublicKey::decode(
libcrux_kem::Algorithm::X25519,
kem_keys.public_key().as_bytes(),
)
.map_err(|e| {
LpError::KKTError(format!(
"Failed to convert X25519 public key to libcrux PublicKey: {e:?}",
))
})?;
let enc_key = EncapsulationKey::X25519(libcrux_public_key);
Ok((dec_key, enc_key))
}
/// Attempt to receive and validate ClientHello
pub(crate) async fn receive_client_hello(
&mut self,
) -> Result<(ClientHelloData, LpRemotePeer), LpError> {
let client_hello_packet = self.receive_non_error(None).await?;
let client_hello = match client_hello_packet.message {
LpMessage::ClientHello(client_hello) => client_hello,
other => {
return Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::ClientHello,
));
}
};
validate_client_hello_timestamp(
client_hello.extract_timestamp(),
DEFAULT_TIMESTAMP_TOLERANCE,
)?;
// TODO: somehow check for collision
// set version and remote peer information
self.protocol_version = Some(client_hello_packet.header.protocol_version);
let remote_peer = LpRemotePeer::new(
client_hello.client_ed25519_public_key,
client_hello.client_lp_public_key,
);
Ok((client_hello, remote_peer))
}
/// Send client hello ACK
pub(crate) async fn send_client_hello_ack(&mut self, session_id: u32) -> Result<(), LpError> {
let protocol = self.protocol_version()?;
let ack = self.next_packet(session_id, protocol, LpMessage::Ack);
self.connection.send_packet(ack, None).await?;
Ok(())
}
/// Attempt to receive and process a KKT request
pub(crate) async fn receive_kkt_request(
&mut self,
) -> Result<(KKTContext, KKTSessionSecret, KKTSessionId), LpError> {
let kkt_request = match self.receive_non_error(None).await?.message {
LpMessage::KKTRequest(request) => request.0,
other => {
return Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::KKTRequest,
));
}
};
let (session_secret, request_frame, remote_context) =
decrypt_initial_kkt_frame(self.local_peer.x25519.private_key(), &kkt_request)?;
let (context, _) = responder_ingest_message(&remote_context, None, None, &request_frame)?;
Ok((context, session_secret, request_frame.session_id()))
let processed_req = KKTResponder::new(
&self.inner_state.local_peer.x25519,
kem_keys,
&self.responder_data.initiator_kem_hashes,
&self.responder_data.supported_hash_functions,
&self.responder_data.supported_signature_schemes,
&self.responder_data.supported_outer_protocol_versions,
)?
.process_request(kkt_request, LP_PEER_CONFIG_SIZE)?;
Ok(processed_req)
}
/// Attempt to send KKT response to the previously received request
pub(crate) async fn send_kkt_response(
&mut self,
session_id: u32,
(kkt_context, session_secret, kkt_session_id): (KKTContext, KKTSessionSecret, KKTSessionId),
encapsulation_key: &EncapsulationKey<'_>,
) -> Result<(), LpError> {
let protocol = self.protocol_version()?;
let response_frame = responder_process(
&kkt_context,
kkt_session_id,
self.local_peer.ed25519().private_key(),
encapsulation_key,
)?;
let encrypted_frame = encrypt_kkt_frame(
&mut rng(),
&session_secret,
&response_frame,
KKT_RESPONSE_AAD,
)?;
let lp_message = KKTResponseData::new(encrypted_frame).into();
let lp_packet = self.next_packet(session_id, protocol, lp_message);
self.connection.send_packet(lp_packet, None).await?;
async fn send_kkt_response(&mut self, response: KKTResponse, kem: KEM) -> Result<(), LpError> {
self.inner_state
.connection
.send_handshake_message::<handshake_message::KKTResponse>(response.into(), kem)
.await?;
Ok(())
}
/// Attempt to receive and process a PSQ msg1 request
pub(crate) async fn receive_psq_initiator_message(
&mut self,
remote_peer: &LpRemotePeer,
local_kem_keypair: (&DecapsulationKey<'_>, &EncapsulationKey<'_>),
salt: &[u8; 32],
session_id_bytes: &[u8; 4],
) -> Result<(OuterAeadKey, NoiseProtocol, PqSharedSecret, Vec<u8>), LpError> {
let psq_msg1 = match self.receive_non_error(None).await?.message {
LpMessage::Handshake(response) => response.0,
other => {
return Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::Handshake,
));
}
};
// Extract PSQ payload: [u16 psq_len][psq_payload][noise_msg]
if psq_msg1.len() < 2 {
return Err(LpError::kkt_psq_handshake("too short msg1 received"));
}
let handle_len = u16::from_le_bytes([psq_msg1[0], psq_msg1[1]]) as usize;
if psq_msg1.len() < 2 + handle_len {
return Err(LpError::kkt_psq_handshake("too short msg1 received"));
}
let psq_payload = &psq_msg1[2..2 + handle_len];
let noise_payload = &psq_msg1[2 + handle_len..];
// Decapsulate PSK from PSQ payload using X25519 as DHKEM
let psq_responder = psq_responder_process_message(
self.local_peer.x25519.private_key(),
&remote_peer.x25519_public,
local_kem_keypair,
&remote_peer.ed25519_public,
psq_payload,
salt,
session_id_bytes,
)?;
let psk = psq_responder.psk;
let psk_handle = psq_responder.psk_handle;
// TEMP \/
let outer_aead_key = OuterAeadKey::from_psk(&psk);
// TEMP /\
let mut noise_protocol = NoiseProtocol::build_new_responder(
self.local_peer.x25519().private_key().as_bytes(),
remote_peer.x25519_public.as_bytes(),
&psk,
)?;
noise_protocol.read_message(noise_payload)?;
Ok((
outer_aead_key,
noise_protocol,
PqSharedSecret::new(psq_responder.pq_shared_secret),
psk_handle,
))
}
/// Attempt to prepare and generate a responder PSQ msg2
pub(crate) async fn send_psq_responder_message(
&mut self,
session_id: u32,
psk_handle: &[u8],
outer_aead_key: &OuterAeadKey,
noise_protocol: &mut NoiseProtocol,
) -> Result<(), LpError> {
let protocol = self.protocol_version()?;
let msg2 = noise_protocol
.get_bytes_to_send()
.ok_or_else(|| LpError::kkt_psq_handshake("failed to generate noise msg2"))??;
// Embed PSK handle in message: [u16 handle_len][handle_bytes][noise_msg]
let handle_len = psk_handle.len() as u16;
let mut combined = Vec::with_capacity(2 + psk_handle.len() + msg2.len());
combined.extend_from_slice(&handle_len.to_le_bytes());
combined.extend_from_slice(psk_handle);
combined.extend_from_slice(&msg2);
let lp_message = HandshakeData::new(combined).into();
let lp_packet = self.next_packet(session_id, protocol, lp_message);
self.connection
.send_packet(lp_packet, Some(outer_aead_key))
.await?;
Ok(())
}
/// Attempt to receive and process final PSQ msg3
pub(crate) async fn receive_final_psq_message(
&mut self,
outer_aead_key: &OuterAeadKey,
noise_protocol: &mut NoiseProtocol,
) -> Result<(), LpError> {
let psq_msg3 = match self
async fn receive_psq_initiator_message(&mut self, kem: KEM) -> Result<Vec<u8>, LpError> {
let packet_len = psq_msg1_size(kem);
let msg: PSQMsg1 = self
.inner_state
.connection
.receive_packet(Some(outer_aead_key))
.await?
.message
{
LpMessage::Handshake(response) => response.0,
other => {
return Err(LpError::unexpected_handshake_response(
other.typ(),
MessageType::Handshake,
));
}
};
noise_protocol.read_message(&psq_msg3)?;
if !noise_protocol.is_handshake_finished() {
return Err(LpError::kkt_psq_handshake(
"noise handshake not finished after msg3",
));
}
Ok(())
}
/// Send final ACK to indicate finalisation of the handshake
pub(crate) async fn send_final_ack(
&mut self,
session_id: u32,
outer_aead_key: &OuterAeadKey,
) -> Result<(), LpError> {
let protocol = self.protocol_version()?;
let ack = self.next_packet(session_id, protocol, LpMessage::Ack);
self.connection
.send_packet(ack, Some(outer_aead_key))
.receive_handshake_message(packet_len)
.await?;
Ok(())
Ok(msg.into_bytes())
}
async fn complete_as_responder_inner(
&mut self,
) -> Result<LpSession, IntermediateHandshakeFailure>
pub async fn complete_handshake(self) -> Result<LpTransportSession, LpError>
where
S: LpTransport + Unpin,
S: LpHandshakeChannel + Unpin,
{
// 1. receive and validate ClientHello
let (client_hello_data, remote_peer) =
self.receive_client_hello()
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: None,
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
debug!("received client hello");
let mut rng = rand09::rngs::StdRng::from_os_rng();
self.complete_handshake_with_rng(&mut rng).await
}
let session_id = client_hello_data.receiver_index;
let session_id_bytes = session_id.to_le_bytes();
let salt = client_hello_data.salt;
// 2. send ack
debug!("sending client hello ACK");
self.send_client_hello_ack(session_id)
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
// 3. receive and process KKT request
let kkt_data =
self.receive_kkt_request()
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
pub async fn complete_handshake_with_rng<R>(
mut self,
rng: &mut R,
) -> Result<LpTransportSession, LpError>
where
S: LpHandshakeChannel + Unpin,
R: rand09::CryptoRng,
{
// 1. receive and process KKTRequest
let kkt_request = if self.responder_data.initiator_kem_hashes.is_empty() {
debug!("expecting one way KKT request");
self.receive_one_way_kkt_request().await?
} else {
debug!("expecting mutual KKT request");
self.receive_mutual_kkt_request().await?
};
debug!("received KKT request");
// TEMP: 'derive' KEM keys
let (dec_key, enc_key) =
self.encapsulated_kem_keys()
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
let processed_req = self.process_kkt_request(kkt_request)?;
let kem = processed_req.requested_kem;
let init_kem = processed_req.remote_encapsulation_key;
// 4. prepare and send KKT response
let lp_peer_config = LpPeerConfig::deserialize(&processed_req.request_payload)?;
// 2. send back the KKTResponse
debug!("sending KKT response");
self.send_kkt_response(session_id, kkt_data, &enc_key)
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
self.send_kkt_response(processed_req.response, kem).await?;
// 5. receive and process PSQ msg1
debug!("received PSQ msg1");
let (outer_aead_key, mut noise_protocol, pq_shared_secret, psk_handle) = self
.receive_psq_initiator_message(
&remote_peer,
(&dec_key, &enc_key),
&salt,
&session_id_bytes,
)
.await
.map_err(|source| IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: None,
source,
})?;
// 3. receive and process PSQ request
let raw_psq1 = self.receive_psq_initiator_message(kem).await?;
debug!("received PSQ handshake msg");
// 6. prepare and send PSQ msg2
debug!("sending PSQ msg2");
if let Err(source) = self
.send_psq_responder_message(
session_id,
&psk_handle,
&outer_aead_key,
&mut noise_protocol,
)
.await
{
return Err(IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: Some(outer_aead_key),
source,
});
// construct the responder and process the message
let responder_ciphersuite = build_psq_ciphersuite(&self.inner_state.local_peer, kem)?;
let version = processed_req.outer_protocol_version;
let mut psq_responder = build_psq_principal(rng, version, responder_ciphersuite)?;
psq_responder.read_message(&raw_psq1, &mut [])?;
let initiator_authenticator = psq_responder
.initiator_authenticator()
.ok_or(LpError::MissingInitiatorAuthenticator)?;
// 4. send PSQ response
let conn = self.inner_state.connection;
let mut buf = vec![0u8; PSQ_MSG2_SIZE];
psq_responder.write_message(&[], &mut buf)?;
debug!("sending PSQ handshake msg");
conn.send_handshake_message(PSQMsg2::new(buf), kem).await?;
if !psq_responder.is_handshake_finished() {
return Err(LpError::kkt_psq_handshake(
"handshake not finished after receiving psq response",
));
}
// 7. receive and process PSQ msg3
debug!("received PSQ msg3");
if let Err(source) = self
.receive_final_psq_message(&outer_aead_key, &mut noise_protocol)
.await
{
return Err(IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: Some(outer_aead_key),
source,
});
}
// SAFETY: we have completed the exchange so this key MUST HAVE been present
#[allow(clippy::unwrap_used)]
let kem_key = self
.inner_state
.local_peer
.kem_keypairs
.as_ref()
.unwrap()
.encapsulation_key(kem)
.unwrap();
// 8. [optionally] send ACK to finalise
debug!("sending final ACK");
if let Err(source) = self.send_final_ack(session_id, &outer_aead_key).await {
return Err(IntermediateHandshakeFailure {
session_id: Some(session_id),
protocol_version: self.protocol_version,
outer_aead_key: Some(outer_aead_key),
source,
});
}
let receiver_index =
lp_peer_config.derive_receiver_index(&initiator_authenticator, &kem_key)?;
#[allow(clippy::expect_used)]
Ok(LpSession::new(
session_id,
self.protocol_version()
.expect("protocol version is known at this point"),
outer_aead_key,
self.local_peer.clone(),
remote_peer,
pq_shared_secret,
noise_protocol,
))
}
let binding = PersistentSessionBinding {
initiator_authenticator,
responder_ecdh_pk: self.inner_state.local_peer.x25519().pk,
responder_pq_pk: Some(kem_key),
initiator_pq_pk: init_kem,
};
pub async fn complete_as_responder(mut self) -> Result<LpSession, LpError>
where
S: LpTransport + Unpin,
{
match self.complete_as_responder_inner().await {
Ok(res) => Ok(res),
Err(err) => Err(self.try_send_error_packet(err).await),
}
let psq_session = psq_responder.into_session()?;
LpTransportSession::new(
psq_session,
binding,
receiver_index,
processed_req.outer_protocol_version,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::{decrypt_data, encrypt_data};
use crate::peer::mock_peers;
use crate::peer_config::LpPeerConfig;
use crate::psq::initiator;
use nym_kkt::initiator::KKTInitiator;
use nym_kkt_ciphersuite::{Ciphersuite, IntoEnumIterator};
use nym_test_utils::helpers::{
DeterministicRng09Send, deterministic_rng_09, u64_seeded_rng_09,
};
use nym_test_utils::mocks::async_read_write::MockIOStream;
use nym_test_utils::traits::{Leak, Timeboxed};
#[tokio::test]
async fn responder_test_plain() -> anyhow::Result<()> {
for kem in KEM::iter() {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
// SETUP START:
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
let (mut init, mut resp) = mock_peers();
let resp_remote = resp.as_remote();
let ciphersuite = Ciphersuite::default().with_kem(kem);
init.ciphersuite = ciphersuite;
resp.ciphersuite = ciphersuite;
let responder_data = ResponderData::default();
let handshake_resp =
PSQHandshakeState::new(conn_resp, resp).as_responder(responder_data);
let mut resp_rng = DeterministicRng09Send::new(u64_seeded_rng_09(2));
let resp_fut = tokio::spawn(async move {
handshake_resp
.complete_handshake_with_rng(&mut resp_rng)
.timeboxed()
.await
});
// initiator:
let mut rng = deterministic_rng_09();
let dir_hash = resp_remote.expected_kem_key_hash(init.ciphersuite)?;
let lp_peer_config = LpPeerConfig::new_client_to_entry(&mut rng, false);
// OneWay - MlKem
let (mut initiator, request) = KKTInitiator::generate_one_way_request(
&mut rng,
init.ciphersuite,
&resp_remote.x25519_public,
&dir_hash,
1,
Some(Vec::from(lp_peer_config.serialize())),
)?;
// 1. send kkt request
conn_init
.send_handshake_message::<handshake_message::KKTRequest>(request.into(), kem)
.timeboxed()
.await??;
// 2. receive KKT response
let response_len = KKTResponse::size_excluding_payload(kem);
let resp: handshake_message::KKTResponse = conn_init
.receive_handshake_message(response_len)
.timeboxed()
.await??;
let kkt_response = resp.into();
let response = initiator.process_response(kkt_response, 0)?;
let encapsulation_key = response.encapsulation_key;
let initiator_ciphersuite =
initiator::build_psq_ciphersuite(&init, &resp_remote, &encapsulation_key)?;
let mut initiator =
initiator::build_psq_principal(rand09::rng(), 1, initiator_ciphersuite)?;
// 3. send PSQ msg1
// Send first message
let mut buf = vec![0u8; psq_msg1_size(kem)];
let n = initiator.write_message(&[], &mut buf).unwrap();
assert_eq!(n, buf.len());
let msg = PSQMsg1::new(buf);
conn_init
.send_handshake_message(msg, kem)
.timeboxed()
.await??;
// 4. receive PSQ msg2
let msg: PSQMsg2 = conn_init
.receive_handshake_message(PSQ_MSG2_SIZE)
.timeboxed()
.await??;
initiator.read_message(&msg, &mut []).unwrap();
assert!(initiator.is_handshake_finished());
let mut session_resp = resp_fut.await???;
let mut i_transport = initiator.into_session().unwrap();
// test serialization, deserialization
let mut channel_i = i_transport.transport_channel().unwrap();
let channel_r = session_resp.active_transport();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, &mut channel_i)?;
let pt_r = decrypt_data(&ct_i, channel_r)?;
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, channel_r)?;
let pt_i = decrypt_data(&ct_r, &mut channel_i)?;
assert_eq!(app_data_r, pt_i);
}
Ok(())
}
#[tokio::test]
async fn responder_test_plain_mutual() -> anyhow::Result<()> {
for kem in KEM::iter() {
let conn_init = MockIOStream::default();
let conn_resp = conn_init.try_get_remote_handle();
// SETUP START:
// leak the connections (JUST FOR THE PURPOSE OF THIS TEST!)
// so they'd get 'static lifetime
let conn_init = conn_init.leak();
let conn_resp = conn_resp.leak();
let (mut init, mut resp) = mock_peers();
let resp_remote = resp.as_remote();
let init_remote = init.as_remote();
let ciphersuite = Ciphersuite::default().with_kem(kem);
init.ciphersuite = ciphersuite;
resp.ciphersuite = ciphersuite;
let responder_data = ResponderData::default()
.with_initiator_kem_hashes(init_remote.expected_kem_key_digests);
let handshake_resp =
PSQHandshakeState::new(conn_resp, resp).as_responder(responder_data);
let mut resp_rng = DeterministicRng09Send::new(u64_seeded_rng_09(2));
let resp_fut = tokio::spawn(async move {
handshake_resp
.complete_handshake_with_rng(&mut resp_rng)
.timeboxed()
.await
});
// initiator:
let mut rng = deterministic_rng_09();
let dir_hash = resp_remote.expected_kem_key_hash(init.ciphersuite)?;
let lp_peer_config = LpPeerConfig::new_client_to_entry(&mut rng, false);
// Mutual - MlKem
let (mut initiator, request) = KKTInitiator::generate_mutual_request(
&mut rng,
init.ciphersuite,
init.encoded_kem_key(kem).unwrap(),
&resp_remote.x25519_public,
&dir_hash,
1,
Some(Vec::from(lp_peer_config.serialize())),
)?;
// 1. send kkt request
conn_init
.send_handshake_message::<handshake_message::KKTRequest>(request.into(), kem)
.timeboxed()
.await??;
// 2. receive KKT response
let response_len = KKTResponse::size_excluding_payload(kem);
let resp: handshake_message::KKTResponse = conn_init
.receive_handshake_message(response_len)
.timeboxed()
.await??;
let kkt_response = resp.into();
let response = initiator.process_response(kkt_response, 0)?;
let encapsulation_key = response.encapsulation_key;
let initiator_ciphersuite =
initiator::build_psq_ciphersuite(&init, &resp_remote, &encapsulation_key)?;
let mut initiator =
initiator::build_psq_principal(rand09::rng(), 1, initiator_ciphersuite)?;
// 3. send PSQ msg1
// Send first message
let mut buf = vec![0u8; psq_msg1_size(kem)];
let n = initiator.write_message(&[], &mut buf).unwrap();
assert_eq!(n, buf.len());
let msg = PSQMsg1::new(buf);
conn_init
.send_handshake_message(msg, kem)
.timeboxed()
.await??;
// 4. receive PSQ msg2
let msg: PSQMsg2 = conn_init
.receive_handshake_message(PSQ_MSG2_SIZE)
.timeboxed()
.await??;
initiator.read_message(&msg, &mut []).unwrap();
assert!(initiator.is_handshake_finished());
let mut session_resp = resp_fut.await???;
let mut i_transport = initiator.into_session().unwrap();
// test serialization, deserialization
let mut channel_i = i_transport.transport_channel().unwrap();
let channel_r = session_resp.active_transport();
assert_eq!(channel_i.identifier(), channel_r.identifier());
let app_data_i = b"Derived session hey".as_slice();
let app_data_r = b"Derived session ho".as_slice();
let ct_i = encrypt_data(app_data_i, &mut channel_i)?;
let pt_r = decrypt_data(&ct_i, channel_r)?;
assert_eq!(app_data_i, pt_r);
let ct_r = encrypt_data(app_data_r, channel_r)?;
let pt_i = decrypt_data(&ct_r, &mut channel_i)?;
assert_eq!(app_data_r, pt_i);
}
Ok(())
}
}
+36 -11
View File
@@ -38,6 +38,16 @@ const N_WORDS: usize = 16;
/// Total number of bits in the bitmap
const N_BITS: usize = WORD_SIZE * N_WORDS;
/// Current packet count statistics
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PacketCount {
/// the next expected counter value
pub next: u64,
/// the total number of received packets
pub received: u64,
}
/// Validator for receiving key counters to prevent replay attacks.
///
/// This structure maintains a bitmap of received packets and validates
@@ -205,11 +215,14 @@ impl ReceivingKeyCounterValidator {
/// Returns the current packet count statistics.
///
/// Returns a tuple of `(next, receive_cnt)` where:
/// Returns a struct consisting 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)
pub fn current_packet_cnt(&self) -> PacketCount {
PacketCount {
next: self.next,
received: self.receive_cnt,
}
}
#[inline(always)]
@@ -481,7 +494,10 @@ mod tests {
let mut validator = ReceivingKeyCounterValidator::default();
// Initial state
let (next, count) = validator.current_packet_cnt();
let PacketCount {
next,
received: count,
} = validator.current_packet_cnt();
assert_eq!(next, 0);
assert_eq!(count, 0);
@@ -490,21 +506,30 @@ mod tests {
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();
let PacketCount {
next,
received: 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();
let PacketCount {
next,
received: 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();
let PacketCount {
next,
received: count,
} = validator.current_packet_cnt();
assert_eq!(next, 11); // Next doesn't change
assert_eq!(count, 5); // Count increases
}
@@ -553,7 +578,7 @@ mod tests {
assert!(validator.mark_did_receive_branchless(first_jump).is_ok());
// Verify next counter is updated
let (next, _) = validator.current_packet_cnt();
let PacketCount { next, .. } = validator.current_packet_cnt();
assert_eq!(next, first_jump + 1);
// Second large jump, even further ahead
@@ -561,7 +586,7 @@ mod tests {
assert!(validator.mark_did_receive_branchless(second_jump).is_ok());
// Verify next counter is updated again
let (next, _) = validator.current_packet_cnt();
let PacketCount { next, .. } = validator.current_packet_cnt();
assert_eq!(next, second_jump + 1);
// Test packets within the new window
@@ -726,10 +751,10 @@ mod tests {
// Check final state of the validator
let final_state = validator.lock().unwrap();
let (_next, receive_cnt) = final_state.current_packet_cnt();
let count = final_state.current_packet_cnt();
// Verify that the received count matches our successful operations
assert_eq!(receive_cnt, total_successes as u64);
assert_eq!(count.received, total_successes as u64);
}
#[test]
File diff suppressed because it is too large Load Diff
+258 -663
View File
@@ -1,731 +1,326 @@
#[cfg(test)]
mod tests {
use crate::codec::{parse_lp_packet, serialize_lp_packet};
use crate::{
LpError, SessionsMock,
message::LpMessage,
packet::{LpHeader, LpPacket, TRAILER_LEN},
session_manager::SessionManager,
};
use bytes::BytesMut;
use crate::packet::{EncryptedLpPacket, LpMessage};
use crate::session::{LpAction, LpInput};
use crate::{LpError, SessionManager, SessionsMock};
use nym_kkt_ciphersuite::{IntoEnumIterator, KEM};
// Function to create a test packet - similar to how it's done in codec.rs tests
fn create_test_packet(
protocol_version: u8,
receiver_idx: u32,
counter: u64,
message: LpMessage,
) -> LpPacket {
// Create the header
let header = LpHeader {
protocol_version,
reserved: [0u8; 3], // reserved
receiver_idx,
counter,
};
// helpers to make tests smaller
trait ActionExtract {
fn ciphertext(self) -> EncryptedLpPacket;
// Create the trailer (zeros for now, in a real implementation this might be a MAC)
let trailer = [0u8; TRAILER_LEN];
// Create and return the packet directly
LpPacket {
header,
message,
trailer,
}
fn data(self) -> LpMessage;
}
/// Tests the complete session flow including:
/// - Creation of sessions through session manager
/// - Packet encoding/decoding with the session
/// - Replay protection across the session
/// - Multiple sessions with unique indices
/// - Session removal and cleanup
#[test]
fn test_full_session_flow() {
// 1. Initialize session manager
let mut session_manager_1 = SessionManager::new();
let mut session_manager_2 = SessionManager::new();
impl ActionExtract for LpAction {
fn ciphertext(self) -> EncryptedLpPacket {
if let LpAction::SendPacket(packet) = self {
packet
} else {
panic!("invalid action");
}
}
let receiver_index = 12345;
let sessions = SessionsMock::mock_post_handshake(receiver_index);
// 2. Create sessions using the pre-built Noise states
let peer_a_sm = session_manager_1.create_session_state_machine(sessions.initiator);
let peer_b_sm = session_manager_2.create_session_state_machine(sessions.responder);
// Verify session count
assert_eq!(session_manager_1.session_count(), 1);
assert_eq!(session_manager_2.session_count(), 1);
// 3. Simulate Data Transfer (Post-Handshake)
println!("Starting data transfer simulation...");
let plaintext_a_to_b = b"Hello from A!";
// A encrypts data
let ciphertext_a_to_b = session_manager_1
.encrypt_data(peer_a_sm, plaintext_a_to_b)
.expect("A encrypt failed");
// A prepares packet
let counter_a = session_manager_1.next_counter(peer_a_sm).unwrap();
let message_a_to_b = create_test_packet(1, receiver_index, counter_a, ciphertext_a_to_b);
let mut encoded_data_a_to_b = BytesMut::new();
serialize_lp_packet(&message_a_to_b, &mut encoded_data_a_to_b, None)
.expect("A serialize data failed");
// B parses packet and checks replay
let decoded_packet_b =
parse_lp_packet(&encoded_data_a_to_b, None).expect("B parse data failed");
assert_eq!(decoded_packet_b.header.counter, counter_a);
// Check replay before decrypting
session_manager_2
.receiving_counter_quick_check(peer_b_sm, decoded_packet_b.header.counter)
.expect("B data replay check failed (A->B)");
// B decrypts data
let decrypted_payload = session_manager_2
.decrypt_data(peer_b_sm, &decoded_packet_b.message)
.expect("B decrypt failed");
assert_eq!(decrypted_payload, plaintext_a_to_b);
// Mark counter only after successful decryption
session_manager_2
.receiving_counter_mark(peer_b_sm, decoded_packet_b.header.counter)
.expect("B mark data counter failed");
println!(
" A->B: Decrypted successfully: {:?}",
String::from_utf8_lossy(&decrypted_payload)
);
// B sends data to A
let plaintext_b_to_a = b"Hello from B!";
let ciphertext_b_to_a = session_manager_2
.encrypt_data(peer_b_sm, plaintext_b_to_a)
.expect("B encrypt failed");
let counter_b = session_manager_2.next_counter(peer_b_sm).unwrap();
let message_b_to_a = create_test_packet(1, receiver_index, counter_b, ciphertext_b_to_a);
let mut encoded_data_b_to_a = BytesMut::new();
serialize_lp_packet(&message_b_to_a, &mut encoded_data_b_to_a, None)
.expect("B serialize data failed");
// A parses packet and checks replay
let decoded_packet_a =
parse_lp_packet(&encoded_data_b_to_a, None).expect("A parse data failed");
assert_eq!(decoded_packet_a.header.counter, counter_b);
// Check replay before decrypting
session_manager_1
.receiving_counter_quick_check(peer_a_sm, decoded_packet_a.header.counter)
.expect("A data replay check failed (B->A)");
// A decrypts data
let decrypted_payload = session_manager_1
.decrypt_data(peer_a_sm, &decoded_packet_a.message)
.expect("A decrypt failed");
assert_eq!(decrypted_payload, plaintext_b_to_a);
// Mark counter only after successful decryption
session_manager_1
.receiving_counter_mark(peer_a_sm, decoded_packet_a.header.counter)
.expect("A mark data counter failed");
println!(
" B->A: Decrypted successfully: {:?}",
String::from_utf8_lossy(&decrypted_payload)
);
println!("Data transfer simulation completed.");
// 4. Replay Protection Test (Data Packet)
println!("Testing data packet replay protection...");
// Try to replay the last message from B to A
// Need to re-encode because decode consumes the buffer
let message_b_to_a_replay = create_test_packet(
1,
receiver_index,
counter_b,
LpMessage::EncryptedData(crate::message::EncryptedDataPayload(
plaintext_b_to_a.to_vec(),
)), // Using plaintext here, but content doesn't matter for replay check
);
let mut encoded_data_b_to_a_replay = BytesMut::new();
serialize_lp_packet(
&message_b_to_a_replay,
&mut encoded_data_b_to_a_replay,
None,
)
.expect("B serialize replay failed");
let parsed_replay_packet =
parse_lp_packet(&encoded_data_b_to_a_replay, None).expect("A parse replay failed");
let replay_result = session_manager_1
.receiving_counter_quick_check(peer_a_sm, parsed_replay_packet.header.counter);
assert!(replay_result.is_err(), "Data replay should be prevented");
assert!(
matches!(replay_result.unwrap_err(), LpError::Replay(_)),
"Should be a replay protection error for data packet"
);
println!("Data packet replay protection test passed.");
// 5. Test out-of-order packet reception (send counter N+1 before counter N)
println!("Testing out-of-order data packet reception...");
let counter_a_next = session_manager_1.next_counter(peer_a_sm).unwrap(); // Should be counter_a + 1
let counter_a_skip = session_manager_1.next_counter(peer_a_sm).unwrap(); // Should be counter_a + 2
// Prepare data for counter_a_skip (N+1)
let plaintext_skip = b"Out of order message";
let ciphertext_skip = session_manager_1
.encrypt_data(peer_a_sm, plaintext_skip)
.expect("A encrypt skip failed");
let message_a_to_b_skip = create_test_packet(
1, // protocol version
receiver_index,
counter_a_skip, // Send N+1 first
ciphertext_skip,
);
// Encode the skip message
let mut encoded_skip = BytesMut::new();
serialize_lp_packet(&message_a_to_b_skip, &mut encoded_skip, None)
.expect("Failed to serialize skip message");
// B parses skip message and checks replay
let decoded_packet_skip =
parse_lp_packet(&encoded_skip, None).expect("B parse skip failed");
session_manager_2
.receiving_counter_quick_check(peer_b_sm, decoded_packet_skip.header.counter)
.expect("B replay check skip failed");
assert_eq!(decoded_packet_skip.header.counter, counter_a_skip);
// B decrypts skip message
let decrypted_payload = session_manager_2
.decrypt_data(peer_b_sm, &decoded_packet_skip.message)
.expect("B decrypt skip failed");
assert_eq!(decrypted_payload, plaintext_skip);
// Mark counter N+1
session_manager_2
.receiving_counter_mark(peer_b_sm, decoded_packet_skip.header.counter)
.expect("B mark skip counter failed");
println!(
" A->B (Counter {}): Decrypted successfully: {:?}",
counter_a_skip,
String::from_utf8_lossy(&decrypted_payload)
);
// 6. Now send the skipped counter N message (should still work)
println!("Testing delayed data packet reception...");
// Prepare data for counter_a_next (N)
let plaintext_delayed = b"Delayed message";
let ciphertext_delayed = session_manager_1
.encrypt_data(peer_a_sm, plaintext_delayed)
.expect("A encrypt delayed failed");
let message_a_to_b_delayed = create_test_packet(
1, // protocol version
receiver_index,
counter_a_next, // counter N (delayed packet)
ciphertext_delayed,
);
// Encode the delayed message
let mut encoded_delayed = BytesMut::new();
serialize_lp_packet(&message_a_to_b_delayed, &mut encoded_delayed, None)
.expect("Failed to serialize delayed message");
// Make a copy for replay test later
let encoded_delayed_copy = encoded_delayed.clone();
// B parses delayed message and checks replay
let decoded_packet_delayed =
parse_lp_packet(&encoded_delayed, None).expect("B parse delayed failed");
session_manager_2
.receiving_counter_quick_check(peer_b_sm, decoded_packet_delayed.header.counter)
.expect("B replay check delayed failed");
assert_eq!(decoded_packet_delayed.header.counter, counter_a_next);
// B decrypts delayed message
let decrypted_payload = session_manager_2
.decrypt_data(peer_b_sm, &decoded_packet_delayed.message)
.expect("B decrypt delayed failed");
assert_eq!(decrypted_payload, plaintext_delayed);
// Mark counter N
session_manager_2
.receiving_counter_mark(peer_b_sm, decoded_packet_delayed.header.counter)
.expect("B mark delayed counter failed");
println!(
" A->B (Counter {}): Decrypted successfully: {:?}",
counter_a_next,
String::from_utf8_lossy(&decrypted_payload)
);
println!("Delayed data packet reception test passed.");
// 7. Try to replay message with counter N (should fail)
println!("Testing replay of delayed packet...");
let parsed_delayed_replay =
parse_lp_packet(&encoded_delayed_copy, None).expect("Parse delayed replay failed");
let result = session_manager_2
.receiving_counter_quick_check(peer_b_sm, parsed_delayed_replay.header.counter);
assert!(result.is_err(), "Replay attack should be prevented");
assert!(
matches!(result, Err(LpError::Replay(_))),
"Should be a replay protection error"
);
// 8. Session removal
assert!(session_manager_1.remove_state_machine(receiver_index));
assert_eq!(session_manager_1.session_count(), 0);
// Verify the session is gone
let session = session_manager_1.state_machine_exists(receiver_index);
assert!(!session, "Session should be removed");
// But the other session still exists
let session = session_manager_2.state_machine_exists(receiver_index);
assert!(session, "Session still exists in the other manager");
fn data(self) -> LpMessage {
if let LpAction::DeliverData(data) = self {
data
} else {
panic!("invalid action");
}
}
}
/// Tests simultaneous bidirectional communication between sessions
#[test]
fn test_bidirectional_communication() {
// 1. Initialize session manager
let mut session_manager_1 = SessionManager::new();
let mut session_manager_2 = SessionManager::new();
for kem in KEM::iter() {
// 1. Initialize session manager
let mut session_manager_1 = SessionManager::new();
let mut session_manager_2 = SessionManager::new();
let sessions = SessionsMock::mock_post_handshake(kem);
let receiver_index = 12345;
let sessions = SessionsMock::mock_post_handshake(receiver_index);
// 2. Create sessions using the pre-built Noise states
let peer_a_sm = session_manager_1
.insert_session(sessions.initiator)
.unwrap();
let peer_b_sm = session_manager_2
.insert_session(sessions.responder)
.unwrap();
// 2. Create sessions using the pre-built Noise states
let peer_a_sm = session_manager_1.create_session_state_machine(sessions.initiator);
let peer_b_sm = session_manager_2.create_session_state_machine(sessions.responder);
// 3. Send multiple encrypted messages both ways
const NUM_MESSAGES: u64 = 5;
for i in 0..NUM_MESSAGES {
println!("Bidirectional test: Round {i}");
// --- A sends to B ---
let plaintext_a = format!("A->B Message {i}").into_bytes();
let ciphertext_a = session_manager_1
.send_data(peer_a_sm, LpMessage::new_opaque(plaintext_a.clone()))
.unwrap()
.ciphertext();
// Counters after handshake
let mut counter_a = 0; // Next counter for A to send
let mut counter_b = 0; // Next counter for B to send
// B parses and checks replay
let decrypted_payload = session_manager_2
.receive_packet(peer_b_sm, ciphertext_a)
.unwrap()
.data();
assert_eq!(decrypted_payload.content, plaintext_a);
// 3. Send multiple encrypted messages both ways
const NUM_MESSAGES: u64 = 5;
for i in 0..NUM_MESSAGES {
println!("Bidirectional test: Round {}", i);
// --- A sends to B ---
let plaintext_a = format!("A->B Message {}", i).into_bytes();
let ciphertext_a = session_manager_1
.encrypt_data(peer_a_sm, &plaintext_a)
.expect("A encrypt failed");
let current_counter_a = counter_a;
counter_a += 1;
// --- B sends to A ---
let plaintext_b = format!("B->A Message {i}").into_bytes();
let ciphertext_b = session_manager_2
.send_data(peer_b_sm, LpMessage::new_opaque(plaintext_b.clone()))
.unwrap()
.ciphertext();
let message_a = create_test_packet(1, receiver_index, current_counter_a, ciphertext_a);
let mut encoded_a = BytesMut::new();
serialize_lp_packet(&message_a, &mut encoded_a, None).expect("A serialize failed");
// B parses and checks replay
let decrypted_payload = session_manager_1
.receive_packet(peer_a_sm, ciphertext_b)
.unwrap()
.data();
assert_eq!(decrypted_payload.content, plaintext_b);
}
// B parses and checks replay
let decoded_packet_b = parse_lp_packet(&encoded_a, None).expect("B parse failed");
session_manager_2
.receiving_counter_quick_check(peer_b_sm, decoded_packet_b.header.counter)
.expect("B replay check failed (A->B)");
assert_eq!(decoded_packet_b.header.counter, current_counter_a);
let decrypted_payload = session_manager_2
.decrypt_data(peer_b_sm, &decoded_packet_b.message)
.expect("B decrypt failed");
assert_eq!(decrypted_payload, plaintext_a);
session_manager_2
.receiving_counter_mark(peer_b_sm, current_counter_a)
.expect("B mark counter failed");
// 5. Verify counter stats
// Note: current_packet_cnt() returns (next_expected_receive_counter, total_received)
let count_a = session_manager_1.current_packet_cnt(peer_a_sm).unwrap();
let count_b = session_manager_2.current_packet_cnt(peer_b_sm).unwrap();
// --- B sends to A ---
let plaintext_b = format!("B->A Message {}", i).into_bytes();
let ciphertext_b = session_manager_2
.encrypt_data(peer_b_sm, &plaintext_b)
.expect("B encrypt failed");
let current_counter_b = counter_b;
counter_b += 1;
// Peer A sent handshake(0), handshake(1) + 5 data packets = 7 total. Next send counter = 7.
// Peer A received handshake(0) + 5 data packets = 6 total. Next expected recv counter = 6.
assert_eq!(
count_a.received, NUM_MESSAGES,
"Peer A total received count mismatch"
); // Received 5 data
assert_eq!(
count_a.next, NUM_MESSAGES,
"Peer A next expected receive counter mismatch"
); // Expected counter for msg from B
let message_b = create_test_packet(1, receiver_index, current_counter_b, ciphertext_b);
let mut encoded_b = BytesMut::new();
serialize_lp_packet(&message_b, &mut encoded_b, None).expect("B serialize failed");
// Peer B sent handshake(0) + 5 data packets = 6 total. Next send counter = 6.
// Peer B received handshake(0), handshake(1) + 5 data packets = 7 total. Next expected recv counter = 7.
assert_eq!(
count_b.received, NUM_MESSAGES,
"Peer B total received count mismatch"
); // Received 5 data
assert_eq!(
count_b.next, NUM_MESSAGES,
"Peer B next expected receive counter mismatch"
); // Expected counter for msg from A
// A parses and checks replay
let decoded_packet_a = parse_lp_packet(&encoded_b, None).expect("A parse failed");
session_manager_1
.receiving_counter_quick_check(peer_a_sm, decoded_packet_a.header.counter)
.expect("A replay check failed (B->A)");
assert_eq!(decoded_packet_a.header.counter, current_counter_b);
let decrypted_payload = session_manager_1
.decrypt_data(peer_a_sm, &decoded_packet_a.message)
.expect("A decrypt failed");
assert_eq!(decrypted_payload, plaintext_b);
session_manager_1
.receiving_counter_mark(peer_a_sm, current_counter_b)
.expect("A mark counter failed");
println!("Bidirectional test completed.");
}
// 5. Verify counter stats
// Note: current_packet_cnt() returns (next_expected_receive_counter, total_received)
let (next_recv_a, total_recv_a) = session_manager_1.current_packet_cnt(peer_a_sm).unwrap();
let (next_recv_b, total_recv_b) = session_manager_2.current_packet_cnt(peer_b_sm).unwrap();
// Peer A sent handshake(0), handshake(1) + 5 data packets = 7 total. Next send counter = 7.
// Peer A received handshake(0) + 5 data packets = 6 total. Next expected recv counter = 6.
assert_eq!(
counter_a, NUM_MESSAGES,
"Peer A final send counter mismatch"
);
assert_eq!(
total_recv_a, NUM_MESSAGES,
"Peer A total received count mismatch"
); // Received 5 data
assert_eq!(
next_recv_a, NUM_MESSAGES,
"Peer A next expected receive counter mismatch"
); // Expected counter for msg from B
// Peer B sent handshake(0) + 5 data packets = 6 total. Next send counter = 6.
// Peer B received handshake(0), handshake(1) + 5 data packets = 7 total. Next expected recv counter = 7.
assert_eq!(
counter_b, NUM_MESSAGES,
"Peer B final send counter mismatch"
);
assert_eq!(
total_recv_b, NUM_MESSAGES,
"Peer B total received count mismatch"
); // Received 5 data
assert_eq!(
next_recv_b, NUM_MESSAGES,
"Peer B next expected receive counter mismatch"
); // Expected counter for msg from A
println!("Bidirectional test completed.");
}
/// Tests error handling in session flow
#[test]
fn test_session_error_handling() {
// 1. Initialize session manager
let mut session_manager = SessionManager::new();
for kem in KEM::iter() {
// 1. Initialize session manager
let mut session_manager = SessionManager::new();
let receiver_index = 123;
let session1 = SessionsMock::mock_post_handshake(receiver_index).initiator;
let session2 = SessionsMock::mock_post_handshake(124).initiator;
let sessions = SessionsMock::mock_post_handshake(kem);
let session_id = sessions.initiator.receiver_index();
// 2. Create a session (using real noise state)
let _session = session_manager.create_session_state_machine(session1);
let non_existent = 123;
// sanity check in case of the 1 in 2^256
assert_ne!(session_id, non_existent);
// 3. Try to get a non-existent session
let result = session_manager.state_machine_exists(999);
assert!(!result, "Non-existent session should return None");
let session1 = sessions.initiator;
let session2 = sessions.responder;
// 4. Try to remove a non-existent session
let result = session_manager.remove_state_machine(999);
assert!(
!result,
"Remove session should not remove a non-existent session"
);
// 2. Create a session (using real noise state)
let _session = session_manager.insert_session(session1);
// 5. Create and immediately remove a session
let _temp_session = session_manager.create_session_state_machine(session2);
// 3. Try to get a non-existent session
let result = session_manager.session_exists(non_existent);
assert!(!result, "Non-existent session should return None");
assert!(
session_manager.remove_state_machine(124),
"Should remove the session"
);
// 4. Try to remove a non-existent session
let result = session_manager.remove_session(non_existent);
assert!(
!result,
"Remove session should not remove a non-existent session"
);
// 6. Create a codec and test error cases
// let mut codec = LPCodec::new(session);
// 5. Create and immediately remove a session
let _temp_session = session_manager.insert_session(session2);
// 7. Create an invalid message type packet
let mut buf = BytesMut::new();
// Add header
buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved
buf.extend_from_slice(&receiver_index.to_le_bytes()); // Sender index
buf.extend_from_slice(&0u64.to_le_bytes()); // Counter
// Add invalid message type
buf.extend_from_slice(&0xFFFFu16.to_le_bytes());
// Add some dummy data
buf.extend_from_slice(&[0u8; 80]);
// Add trailer
buf.extend_from_slice(&[0u8; TRAILER_LEN]);
// Try to parse the invalid message type
let result = parse_lp_packet(&buf, None);
assert!(result.is_err(), "Decoding invalid message type should fail");
// Add assertion for the specific error type
assert!(matches!(
result.unwrap_err(),
LpError::InvalidMessageType(0xFFFF)
));
// 8. Test partial packet decoding
let partial_packet = &buf[0..10]; // Too short to be a valid packet
let partial_bytes = BytesMut::from(partial_packet);
let result = parse_lp_packet(&partial_bytes, None);
assert!(result.is_err(), "Parsing partial packet should fail");
assert!(matches!(
result.unwrap_err(),
LpError::InsufficientBufferSize
));
assert!(
session_manager.remove_session(session_id),
"Should remove the session"
);
}
}
// Remove unused imports if SessionManager methods are no longer direct dependencies
// use crate::noise_protocol::{create_noise_state, create_noise_state_responder};
use crate::state_machine::LpData;
use crate::state_machine::{LpAction, LpInput, LpStateBare};
// Use Bytes for SendData input
// Keep helper function for creating test packets if needed,
// but LpAction::SendPacket should provide the packets now.
// fn create_test_packet(...) -> LpPacket { ... }
/// Tests the complete session flow using ONLY the process_input interface:
/// - Creation of sessions through session manager
/// - Handshake driven by StartHandshake, ReceivePacket inputs
/// - Data transfer driven by SendData, ReceivePacket inputs
/// - Actions like SendPacket, DeliverData handled from output
/// - Implicit replay protection via state machine logic
/// - Closing driven by Close input
#[test]
fn test_full_session_flow_with_process_input() {
fn test_full_session_flow() {
// 1. Initialize session managers
let mut session_manager_1 = SessionManager::new();
let mut session_manager_2 = SessionManager::new();
let receiver_index = 12345;
let sessions = SessionsMock::mock_post_handshake(receiver_index);
for kem in KEM::iter() {
let sessions = SessionsMock::mock_post_handshake(kem);
let session_id = sessions.responder.receiver_index();
// 2. Create sessions state machines
session_manager_1.create_session_state_machine(sessions.initiator);
session_manager_2.create_session_state_machine(sessions.responder);
// 2. Create sessions state machines
session_manager_1
.insert_session(sessions.initiator)
.unwrap();
session_manager_2
.insert_session(sessions.responder)
.unwrap();
assert_eq!(session_manager_1.session_count(), 1);
assert_eq!(session_manager_2.session_count(), 1);
assert!(session_manager_1.state_machine_exists(receiver_index));
assert!(session_manager_2.state_machine_exists(receiver_index));
assert_eq!(session_manager_1.session_count(), 1);
assert_eq!(session_manager_2.session_count(), 1);
assert!(session_manager_1.session_exists(session_id));
assert!(session_manager_2.session_exists(session_id));
// Verify initial states are Transport
assert_eq!(
session_manager_1.get_state(receiver_index).unwrap(),
LpStateBare::Transport
);
assert_eq!(
session_manager_2.get_state(receiver_index).unwrap(),
LpStateBare::Transport
);
// --- 3. Simulate Data Transfer via process_input ---
println!("Starting data transfer simulation via process_input...");
let plaintext_a_to_b =
LpMessage::new_opaque(b"Hello from A via process_input!".to_vec());
let plaintext_b_to_a =
LpMessage::new_opaque(b"Hello from B via process_input!".to_vec());
// --- 3. Simulate Data Transfer via process_input ---
println!("Starting data transfer simulation via process_input...");
let plaintext_a_to_b = LpData::new_opaque(b"Hello from A via process_input!".to_vec());
let plaintext_b_to_a = LpData::new_opaque(b"Hello from B via process_input!".to_vec());
// --- A sends to B ---
println!(" A sends to B");
let action_a_send = session_manager_1
.process_input(session_id, LpInput::SendData(plaintext_a_to_b.clone()))
.expect("A SendData failed");
// --- A sends to B ---
println!(" A sends to B");
let action_a_send = session_manager_1
.process_input(receiver_index, LpInput::SendData(plaintext_a_to_b.clone()))
.expect("A SendData should produce action")
.expect("A SendData failed");
let data_packet_a = action_a_send.ciphertext();
let data_packet_a = if let LpAction::SendPacket(packet) = action_a_send {
packet
} else {
panic!("A SendData did not produce SendPacket");
};
// B receives
println!(" B receives from A");
let action_b_recv = session_manager_2
.process_input(session_id, LpInput::ReceivePacket(data_packet_a))
.expect("B ReceivePacket (data) failed");
// Simulate network
let mut buf_data_a = BytesMut::new();
serialize_lp_packet(&data_packet_a, &mut buf_data_a, None).unwrap();
let parsed_data_a = parse_lp_packet(&buf_data_a, None).unwrap();
if let LpAction::DeliverData(data) = action_b_recv {
assert_eq!(data, plaintext_a_to_b, "Decrypted data mismatch A->B");
println!(
" B successfully decrypted: {:?}",
String::from_utf8_lossy(&data.content)
);
} else {
panic!("B ReceivePacket did not produce DeliverData");
}
// B receives
println!(" B receives from A");
let action_b_recv = session_manager_2
.process_input(receiver_index, LpInput::ReceivePacket(parsed_data_a))
.expect("B ReceivePacket (data) should produce action")
.expect("B ReceivePacket (data) failed");
// --- B sends to A ---
println!(" B sends to A");
let action_b_send = session_manager_2
.process_input(session_id, LpInput::SendData(plaintext_b_to_a.clone()))
.expect("B SendData failed");
if let LpAction::DeliverData(data) = action_b_recv {
assert_eq!(data, plaintext_a_to_b, "Decrypted data mismatch A->B");
println!(
" B successfully decrypted: {:?}",
String::from_utf8_lossy(&data.content)
let data_packet_b = action_b_send.ciphertext();
// Keep a copy for replay test
let data_packet_b_replay = data_packet_b.clone();
// A receives
println!(" A receives from B");
let action_a_recv = session_manager_1
.process_input(session_id, LpInput::ReceivePacket(data_packet_b))
.expect("A ReceivePacket (data) failed");
if let LpAction::DeliverData(data) = action_a_recv {
assert_eq!(data, plaintext_b_to_a, "Decrypted data mismatch B->A");
println!(
" A successfully decrypted: {:?}",
String::from_utf8_lossy(&data.content)
);
} else {
panic!("A ReceivePacket did not produce DeliverData");
}
println!("Data transfer simulation completed.");
// --- 4. Replay Protection Test ---
println!("Testing data packet replay protection via process_input...");
let replay_result = session_manager_1
.process_input(session_id, LpInput::ReceivePacket(data_packet_b_replay)); // Use cloned packet
assert!(replay_result.is_err(), "Replay should produce Err(...)");
let error = replay_result.err().unwrap();
assert!(
matches!(error, LpError::Replay(_)),
"Expected Replay error, got {:?}",
error
);
} else {
panic!("B ReceivePacket did not produce DeliverData");
}
println!("Data packet replay protection test passed.");
// --- B sends to A ---
println!(" B sends to A");
let action_b_send = session_manager_2
.process_input(receiver_index, LpInput::SendData(plaintext_b_to_a.clone()))
.expect("B SendData should produce action")
.expect("B SendData failed");
// --- 5. Out-of-Order Test ---
println!("Testing out-of-order reception via process_input...");
let data_packet_b = if let LpAction::SendPacket(packet) = action_b_send {
packet
} else {
panic!("B SendData did not produce SendPacket");
};
// Keep a copy for replay test
let data_packet_b_replay = data_packet_b.clone();
// A prepares N+1 then N
let data_n_plus_1 = LpMessage::new_opaque(b"Message N+1".to_vec());
let data_n = LpMessage::new_opaque(b"Message N".to_vec());
// Simulate network
let mut buf_data_b = BytesMut::new();
serialize_lp_packet(&data_packet_b, &mut buf_data_b, None).unwrap();
let parsed_data_b = parse_lp_packet(&buf_data_b, None).unwrap();
let action_send_n1 = session_manager_1
.process_input(session_id, LpInput::SendData(data_n_plus_1.clone()))
.unwrap();
let packet_n1 = match action_send_n1 {
LpAction::SendPacket(p) => p,
_ => panic!("Expected SendPacket"),
};
// A receives
println!(" A receives from B");
let action_a_recv = session_manager_1
.process_input(receiver_index, LpInput::ReceivePacket(parsed_data_b))
.expect("A ReceivePacket (data) should produce action")
.expect("A ReceivePacket (data) failed");
let action_send_n = session_manager_1
.process_input(session_id, LpInput::SendData(data_n.clone()))
.unwrap();
let packet_n = match action_send_n {
LpAction::SendPacket(p) => p,
_ => panic!("Expected SendPacket"),
};
let packet_n_replay = packet_n.clone(); // For replay test
if let LpAction::DeliverData(data) = action_a_recv {
assert_eq!(data, plaintext_b_to_a, "Decrypted data mismatch B->A");
println!(
" A successfully decrypted: {:?}",
String::from_utf8_lossy(&data.content)
// B receives N+1 first
println!(" B receives N+1");
let action_recv_n1 = session_manager_2
.process_input(session_id, LpInput::ReceivePacket(packet_n1))
.unwrap();
match action_recv_n1 {
LpAction::DeliverData(d) => assert_eq!(d, data_n_plus_1, "Data N+1 mismatch"),
_ => panic!("Expected DeliverData for N+1"),
}
// B receives N second (should work)
println!(" B receives N");
let action_recv_n = session_manager_2
.process_input(session_id, LpInput::ReceivePacket(packet_n))
.unwrap();
match action_recv_n {
LpAction::DeliverData(d) => assert_eq!(d, data_n, "Data N mismatch"),
_ => panic!("Expected DeliverData for N"),
}
// B tries to replay N (should fail)
println!(" B tries to replay N");
let replay_n_result = session_manager_2
.process_input(session_id, LpInput::ReceivePacket(packet_n_replay));
assert!(replay_n_result.is_err(), "Replay N should produce Err");
assert!(
matches!(replay_n_result.err().unwrap(), LpError::Replay(_)),
"Expected Replay error for N"
);
} else {
panic!("A ReceivePacket did not produce DeliverData");
println!("Out-of-order test passed.");
// --- 6. Session Removal ---
assert!(session_manager_1.remove_session(session_id));
assert_eq!(session_manager_1.session_count(), 0);
assert!(!session_manager_1.session_exists(session_id));
// B's session manager still has it until removed
assert!(session_manager_2.session_exists(session_id));
assert!(session_manager_2.remove_session(session_id));
assert_eq!(session_manager_2.session_count(), 0);
assert!(!session_manager_2.session_exists(session_id));
println!("Session removal test passed.");
}
println!("Data transfer simulation completed.");
// --- 4. Replay Protection Test ---
println!("Testing data packet replay protection via process_input...");
let replay_result = session_manager_1
.process_input(receiver_index, LpInput::ReceivePacket(data_packet_b_replay)); // Use cloned packet
assert!(replay_result.is_err(), "Replay should produce Err(...)");
let error = replay_result.err().unwrap();
assert!(
matches!(error, LpError::Replay(_)),
"Expected Replay error, got {:?}",
error
);
println!("Data packet replay protection test passed.");
// --- 5. Out-of-Order Test ---
println!("Testing out-of-order reception via process_input...");
// A prepares N+1 then N
let data_n_plus_1 = LpData::new_opaque(b"Message N+1".to_vec());
let data_n = LpData::new_opaque(b"Message N".to_vec());
let action_send_n1 = session_manager_1
.process_input(receiver_index, LpInput::SendData(data_n_plus_1.clone()))
.unwrap()
.unwrap();
let packet_n1 = match action_send_n1 {
LpAction::SendPacket(p) => p,
_ => panic!("Expected SendPacket"),
};
let action_send_n = session_manager_1
.process_input(receiver_index, LpInput::SendData(data_n.clone()))
.unwrap()
.unwrap();
let packet_n = match action_send_n {
LpAction::SendPacket(p) => p,
_ => panic!("Expected SendPacket"),
};
let packet_n_replay = packet_n.clone(); // For replay test
// B receives N+1 first
println!(" B receives N+1");
let action_recv_n1 = session_manager_2
.process_input(receiver_index, LpInput::ReceivePacket(packet_n1))
.unwrap()
.unwrap();
match action_recv_n1 {
LpAction::DeliverData(d) => assert_eq!(d, data_n_plus_1, "Data N+1 mismatch"),
_ => panic!("Expected DeliverData for N+1"),
}
// B receives N second (should work)
println!(" B receives N");
let action_recv_n = session_manager_2
.process_input(receiver_index, LpInput::ReceivePacket(packet_n))
.unwrap()
.unwrap();
match action_recv_n {
LpAction::DeliverData(d) => assert_eq!(d, data_n, "Data N mismatch"),
_ => panic!("Expected DeliverData for N"),
}
// B tries to replay N (should fail)
println!(" B tries to replay N");
let replay_n_result = session_manager_2
.process_input(receiver_index, LpInput::ReceivePacket(packet_n_replay));
assert!(replay_n_result.is_err(), "Replay N should produce Err");
assert!(
matches!(replay_n_result.err().unwrap(), LpError::Replay(_)),
"Expected Replay error for N"
);
println!("Out-of-order test passed.");
// --- 6. Close Test ---
println!("Testing close via process_input...");
// A closes
let action_a_close = session_manager_1
.process_input(receiver_index, LpInput::Close)
.expect("A Close should produce action")
.expect("A Close failed");
assert!(matches!(action_a_close, LpAction::ConnectionClosed));
assert_eq!(
session_manager_1.get_state(receiver_index).unwrap(),
LpStateBare::Closed
);
// Further actions on A fail
let send_after_close_a = session_manager_1.process_input(
receiver_index,
LpInput::SendData(LpData::new_opaque(b"fail".to_vec())),
);
assert!(send_after_close_a.is_err());
assert!(matches!(
send_after_close_a.err().unwrap(),
LpError::LpSessionClosed
));
// B closes
let action_b_close = session_manager_2
.process_input(receiver_index, LpInput::Close)
.expect("B Close should produce action")
.expect("B Close failed");
assert!(matches!(action_b_close, LpAction::ConnectionClosed));
assert_eq!(
session_manager_2.get_state(receiver_index).unwrap(),
LpStateBare::Closed
);
// Further actions on B fail
let send_after_close_b = session_manager_2.process_input(
receiver_index,
LpInput::SendData(LpData::new_opaque(b"fail".to_vec())),
);
assert!(send_after_close_b.is_err());
assert!(matches!(
send_after_close_b.err().unwrap(),
LpError::LpSessionClosed
));
println!("Close test passed.");
// --- 7. Session Removal ---
assert!(session_manager_1.remove_state_machine(receiver_index));
assert_eq!(session_manager_1.session_count(), 0);
assert!(!session_manager_1.state_machine_exists(receiver_index));
// B's session manager still has it until removed
assert!(session_manager_2.state_machine_exists(receiver_index));
assert!(session_manager_2.remove_state_machine(receiver_index));
assert_eq!(session_manager_2.session_count(), 0);
assert!(!session_manager_2.state_machine_exists(receiver_index));
println!("Session removal test passed.");
}
// ... other tests ...
}
+80 -103
View File
@@ -6,136 +6,112 @@
//! This module implements session lifecycle management functionality, handling
//! creation, retrieval, and storage of sessions.
use crate::state_machine::{LpAction, LpInput, LpStateBare};
use crate::{LpError, LpMessage, LpSession, LpStateMachine};
use crate::packet::{EncryptedLpPacket, LpMessage};
use crate::peer_config::LpReceiverIndex;
use crate::{LpError, LpTransportSession};
use std::collections::HashMap;
pub use crate::replay::validator::PacketCount;
use crate::session::{LpAction, LpInput};
/// Manages the lifecycle of Lewes Protocol sessions.
///
/// The SessionManager is responsible for creating, storing, and retrieving sessions,
/// ensuring proper thread-safety for concurrent access.
/// The SessionManager is responsible for creating, storing, and retrieving sessions
#[derive(Default)]
pub struct SessionManager {
/// Manages state machines directly, keyed by lp_id
state_machines: HashMap<u32, LpStateMachine>,
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
sessions: HashMap<LpReceiverIndex, LpTransportSession>,
}
impl SessionManager {
/// Creates a new session manager with empty session storage.
pub fn new() -> Self {
Self {
state_machines: HashMap::new(),
sessions: HashMap::new(),
}
}
pub fn process_input(
&mut self,
lp_id: u32,
lp_id: LpReceiverIndex,
input: LpInput,
) -> Result<Option<LpAction>, LpError> {
self.with_state_machine_mut(lp_id, |sm| sm.process_input(input).transpose())?
) -> Result<LpAction, LpError> {
self.with_session_mut(lp_id, |sm| sm.process_input(input))?
}
pub fn closed(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Closed)
pub fn send_data(
&mut self,
lp_id: LpReceiverIndex,
data: LpMessage,
) -> Result<LpAction, LpError> {
self.process_input(lp_id, LpInput::SendData(data))
}
pub fn transport(&self, lp_id: u32) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Transport)
pub fn receive_packet(
&mut self,
lp_id: LpReceiverIndex,
packet: EncryptedLpPacket,
) -> Result<LpAction, LpError> {
self.process_input(lp_id, LpInput::ReceivePacket(packet))
}
#[cfg(test)]
fn get_state_machine_id(&self, lp_id: u32) -> Result<u32, LpError> {
self.with_state_machine(lp_id, |sm| sm.id())?
fn get_session_id(&self, lp_id: LpReceiverIndex) -> Result<LpReceiverIndex, LpError> {
self.with_session(lp_id, |sm| sm.receiver_index())
}
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(&mut self, lp_id: u32, counter: u64) -> Result<(), LpError> {
self.with_state_machine_mut(lp_id, |sm| {
sm.session_mut()?.receiving_counter_mark(counter)
})?
}
pub fn next_counter(&mut self, lp_id: u32) -> Result<u64, LpError> {
self.with_state_machine_mut(lp_id, |sm| Ok(sm.session_mut()?.next_counter()))?
}
pub fn decrypt_data(&mut self, lp_id: u32, message: &LpMessage) -> Result<Vec<u8>, LpError> {
self.with_state_machine_mut(lp_id, |sm| {
sm.session_mut()?
.decrypt_data(message)
.map_err(LpError::NoiseError)
})?
}
pub fn encrypt_data(&mut self, lp_id: u32, message: &[u8]) -> Result<LpMessage, LpError> {
self.with_state_machine_mut(lp_id, |sm| {
sm.session_mut()?
.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 current_packet_cnt(&self, lp_id: LpReceiverIndex) -> Result<PacketCount, LpError> {
self.with_session(lp_id, |sm| Ok(sm.current_packet_cnt()))?
}
pub fn session_count(&self) -> usize {
self.state_machines.len()
self.sessions.len()
}
pub fn state_machine_exists(&self, lp_id: u32) -> bool {
self.state_machines.contains_key(&lp_id)
pub fn session_exists(&self, lp_id: LpReceiverIndex) -> bool {
self.sessions.contains_key(&lp_id)
}
pub fn with_state_machine<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
pub fn with_session<F, R>(&self, lp_id: LpReceiverIndex, f: F) -> Result<R, LpError>
where
F: FnOnce(&LpStateMachine) -> R,
F: FnOnce(&LpTransportSession) -> R,
{
if let Some(sm) = self.state_machines.get(&lp_id) {
if let Some(sm) = self.sessions.get(&lp_id) {
Ok(f(sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
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>(&mut self, lp_id: u32, f: F) -> Result<R, LpError>
pub fn with_session_mut<F, R>(&mut self, lp_id: LpReceiverIndex, f: F) -> Result<R, LpError>
where
F: FnOnce(&mut LpStateMachine) -> R, // Closure takes mutable ref
F: FnOnce(&mut LpTransportSession) -> R, // Closure takes mutable ref
{
if let Some(sm) = self.state_machines.get_mut(&lp_id) {
if let Some(sm) = self.sessions.get_mut(&lp_id) {
Ok(f(sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
Err(LpError::StateMachineNotFound(lp_id))
}
}
pub fn create_session_state_machine(&mut self, lp_session: LpSession) -> u32 {
let receiver_index = lp_session.id();
let sm = LpStateMachine::new(lp_session);
self.state_machines.insert(receiver_index, sm);
receiver_index
pub fn insert_session(
&mut self,
lp_session: LpTransportSession,
) -> Result<LpReceiverIndex, LpError> {
let session_id = lp_session.receiver_index();
if self.sessions.contains_key(&session_id) {
return Err(LpError::DuplicateSessionId(session_id));
}
self.sessions.insert(session_id, lp_session);
Ok(session_id)
}
/// Method to remove a state machine
pub fn remove_state_machine(&mut self, lp_id: u32) -> bool {
let removed = self.state_machines.remove(&lp_id);
pub fn remove_session(&mut self, lp_id: LpReceiverIndex) -> bool {
let removed = self.sessions.remove(&lp_id);
removed.is_some()
}
@@ -145,21 +121,21 @@ impl SessionManager {
mod tests {
use super::*;
use crate::{SessionsMock, mock_session_for_test};
use nym_kkt_ciphersuite::{IntoEnumIterator, KEM};
#[test]
fn test_session_manager_get() {
let mut manager = SessionManager::new();
let local_session = mock_session_for_test();
let id = local_session.id();
let id = local_session.receiver_index();
let sm_1_id = manager.create_session_state_machine(local_session);
let sm_1_id = manager.insert_session(local_session).unwrap();
assert_eq!(sm_1_id, id);
let retrieved = manager.state_machine_exists(id);
let retrieved = manager.session_exists(id);
assert!(retrieved);
let not_found = manager.state_machine_exists(99);
let not_found = manager.session_exists(123);
assert!(!not_found);
}
@@ -167,37 +143,38 @@ mod tests {
fn test_session_manager_remove() {
let mut manager = SessionManager::new();
let local_session = mock_session_for_test();
let sm_1_id = manager.insert_session(local_session).unwrap();
let sm_1_id = manager.create_session_state_machine(local_session);
let removed = manager.remove_state_machine(sm_1_id);
let removed = manager.remove_session(sm_1_id);
assert!(removed);
assert_eq!(manager.session_count(), 0);
let removed_again = manager.remove_state_machine(sm_1_id);
let removed_again = manager.remove_session(sm_1_id);
assert!(!removed_again);
}
#[test]
fn test_multiple_sessions() {
let mut manager = SessionManager::new();
let session1 = SessionsMock::mock_post_handshake(123).initiator;
let session2 = SessionsMock::mock_post_handshake(124).initiator;
let session3 = SessionsMock::mock_post_handshake(125).initiator;
for kem in KEM::iter() {
let mut manager = SessionManager::new();
let session1 = SessionsMock::mock_seeded_post_handshake(123, kem).initiator;
let session2 = SessionsMock::mock_seeded_post_handshake(124, kem).initiator;
let session3 = SessionsMock::mock_seeded_post_handshake(125, kem).initiator;
let sm_1 = manager.create_session_state_machine(session1);
let sm_2 = manager.create_session_state_machine(session2);
let sm_3 = manager.create_session_state_machine(session3);
let sm_1 = manager.insert_session(session1).unwrap();
let sm_2 = manager.insert_session(session2).unwrap();
let sm_3 = manager.insert_session(session3).unwrap();
assert_eq!(manager.session_count(), 3);
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();
let retrieved1 = manager.get_session_id(sm_1).unwrap();
let retrieved2 = manager.get_session_id(sm_2).unwrap();
let retrieved3 = manager.get_session_id(sm_3).unwrap();
assert_eq!(retrieved1, sm_1);
assert_eq!(retrieved2, sm_2);
assert_eq!(retrieved3, sm_3);
assert_eq!(retrieved1, sm_1);
assert_eq!(retrieved2, sm_2);
assert_eq!(retrieved3, sm_3);
}
}
#[test]
@@ -206,10 +183,10 @@ mod tests {
let sesion = mock_session_for_test();
let sm = manager.create_session_state_machine(sesion);
let sm = manager.insert_session(sesion).unwrap();
assert_eq!(manager.session_count(), 1);
let retrieved = manager.get_state_machine_id(sm);
let retrieved = manager.get_session_id(sm);
assert!(retrieved.is_ok());
assert_eq!(retrieved.unwrap(), sm);
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LpTransportError {
#[error("the encoded packet is too long ({size} bytes)")]
PacketTooBig { size: usize },
#[error("the encoded packet is too small ({size} bytes) to encode valid data")]
PacketTooSmall { size: usize },
#[error("failed to establish connection with the remote host: {0}")]
ConnectionFailure(String),
#[error("failed to configure the established connection: {0}")]
ConnectionConfigFailure(String),
#[error("connection got closed before finishing the operation")]
ConnectionClosed,
#[error("the received packet was malformed: {0}")]
MalformedPacket(String),
#[error("failed to send bytes across the channel: {0}")]
TransportSendFailure(String),
#[error("failed to receive bytes across the channel: {0}")]
TransportReceiveFailure(String),
}
impl LpTransportError {
pub fn connection_failure(error: impl Into<String>) -> Self {
LpTransportError::ConnectionFailure(error.into())
}
pub fn connection_config(error: impl Into<String>) -> Self {
LpTransportError::ConnectionConfigFailure(error.into())
}
pub fn send_failure(error: std::io::Error) -> Self {
if error.kind() == std::io::ErrorKind::UnexpectedEof {
return LpTransportError::ConnectionClosed;
}
LpTransportError::TransportSendFailure(error.to_string())
}
pub fn receive_failure(error: std::io::Error) -> Self {
if error.kind() == std::io::ErrorKind::UnexpectedEof {
return LpTransportError::ConnectionClosed;
}
LpTransportError::TransportReceiveFailure(error.to_string())
}
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod error;
pub mod traits;
pub use error::LpTransportError;
pub use traits::{LpHandshakeChannel, LpTransportChannel};
+303
View File
@@ -0,0 +1,303 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::packet::{EncryptedLpPacket, OuterHeader};
use crate::transport::error::LpTransportError;
use nym_kkt::context::KKTMode;
use nym_kkt_ciphersuite::KEM;
use std::net::SocketAddr;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tracing::debug;
#[cfg(any(feature = "mock", test))]
use nym_test_utils::mocks::async_read_write::MockIOStream;
pub const MAX_TRANSPORT_PACKET_SIZE: usize = 65536; // 64KB max
pub const MAX_HANDSHAKE_PACKET_SIZE: usize = 524287; // 524'160 for mceliece key + a bit of overhead for safety
/// Simple trait allowing sending bytes across.
/// It is not concerned with encryption. It is up to the caller.
// only used in internal code (and tests)
#[allow(async_fn_in_trait)]
pub trait LpHandshakeChannel: Sized {
/// Write all provided data and immediately flush the buffer
async fn write_all_and_flush(&mut self, data: &[u8]) -> Result<(), LpTransportError>;
/// Wrapper around `ReadExact` to return the `Vec<u8>` of `n` bytes directly
async fn read_n_bytes(&mut self, n: usize) -> Result<Vec<u8>, LpTransportError>;
/// Send the provided handshake message on the connection
async fn send_handshake_message<M: HandshakeMessage>(
&mut self,
message: M,
_: KEM,
) -> Result<(), LpTransportError> {
self.write_all_and_flush(&message.into_bytes()).await
}
/// Attempt to receive a handshake message of the provided type from the stream
async fn receive_handshake_message<M: HandshakeMessage>(
&mut self,
expected_size: usize,
) -> Result<M, LpTransportError> {
let bytes = self.read_n_bytes(expected_size).await?;
M::try_from_bytes(bytes)
}
}
pub trait HandshakeMessage: Sized {
/// Convert this message into bytes
fn into_bytes(self) -> Vec<u8>;
/// Attempt to recover this message from the byte stream
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, LpTransportError>;
/// Expected size of this message based on the provided parameters
fn expected_size(mode: KKTMode, expected_kem: KEM, payload_size: usize) -> usize;
/// Expected size of the response from the remote party.
/// `None` if this is the final (PSQ msg2) message of the exchange
fn response_size(&self, expected_kem: KEM) -> Option<usize>;
}
async fn write_all_and_flush_async_write<W>(
writer: &mut W,
data: &[u8],
) -> Result<(), LpTransportError>
where
W: AsyncWrite + Unpin,
{
writer
.write_all(data)
.await
.map_err(LpTransportError::send_failure)?;
writer.flush().await.map_err(LpTransportError::send_failure)
}
async fn read_n_bytes_async_read<R>(reader: &mut R, n: usize) -> Result<Vec<u8>, LpTransportError>
where
R: AsyncRead + Unpin,
{
if n > MAX_HANDSHAKE_PACKET_SIZE {
return Err(LpTransportError::PacketTooBig { size: n });
}
let mut buf = vec![0u8; n];
reader
.read_exact(&mut buf)
.await
.map_err(LpTransportError::receive_failure)?;
Ok(buf)
}
// only used in internal code (and tests)
#[allow(async_fn_in_trait)]
pub trait LpTransportChannel: Sized {
async fn connect(endpoint: SocketAddr) -> Result<Self, LpTransportError>;
fn set_no_delay(&mut self, nodelay: bool) -> Result<(), LpTransportError>;
/// Sends a serialised and encrypted LP packet over the data stream with length-prefixed framing.
///
/// Format: 4-byte big-endian u32 length + packet bytes
///
/// # Arguments
/// * `packet` - The encrypted LP packet to send
///
/// # Errors
/// Returns an error on network transmission fails.
async fn send_length_prefixed_transport_packet(
&mut self,
packet: &EncryptedLpPacket,
) -> Result<(), LpTransportError>;
/// Receives an LP packet from a TCP stream with length-prefixed framing without additional parsing
///
/// Format: 4-byte big-endian u32 length + packet bytes
///
/// # Errors
/// Returns an error on network transmission fails.
async fn receive_length_prefixed_transport_bytes(
&mut self,
) -> Result<Vec<u8>, LpTransportError>;
/// Receives an LP packet from a TCP stream with length-prefixed framing.
///
/// Format: 4-byte big-endian u32 length + packet bytes
///
/// # Errors
/// Returns an error on network transmission fails.
async fn receive_length_prefixed_transport_packet(
&mut self,
) -> Result<EncryptedLpPacket, LpTransportError> {
let mut bytes = self.receive_length_prefixed_transport_bytes().await?;
if bytes.len() < OuterHeader::SIZE {
return Err(LpTransportError::PacketTooSmall { size: bytes.len() });
}
// split it into the outer header and ciphertext
let ciphertext = bytes.split_off(OuterHeader::SIZE);
// SAFETY: we just checked we have at least OuterHeader::SIZE bytes
#[allow(clippy::unwrap_used)]
let outer_header = OuterHeader::parse(&bytes).unwrap();
tracing::trace!(
"Received LP packet ({} bytes + 4 byte length-prefix)",
bytes.len()
);
Ok(EncryptedLpPacket::new(outer_header, ciphertext))
}
}
async fn send_serialised_packet_async_write<W>(
writer: &mut W,
packet: &EncryptedLpPacket,
) -> Result<(), LpTransportError>
where
W: AsyncWrite + Unpin,
{
// Send 4-byte length prefix (u32 big-endian)
let len = packet.encoded_length() as u32;
writer
.write_all(&len.to_le_bytes())
.await
.inspect_err(|e| debug!("Failed to send packet length: {e}"))
.map_err(LpTransportError::send_failure)?;
// TODO: benchmark whether it'd be faster to concatenate all slices slices and
// use a single `write_all` call
// Send the outer header
writer
.write_all(&packet.outer_header().to_bytes())
.await
.inspect_err(|e| debug!("Failed to send packet data: {e}"))
.map_err(LpTransportError::send_failure)?;
// Send the actual packet data
writer
.write_all(packet.ciphertext())
.await
.inspect_err(|e| debug!("Failed to send packet data: {e}"))
.map_err(LpTransportError::send_failure)?;
// Flush to ensure data is sent immediately
writer
.flush()
.await
.inspect_err(|e| debug!("Failed to flush stream: {e}"))
.map_err(LpTransportError::send_failure)?;
tracing::trace!(
"Sent LP packet ({} bytes + 4 byte length-prefix)",
packet.encoded_length()
);
Ok(())
}
async fn receive_length_prefixed_bytes_async_read<R>(
reader: &mut R,
) -> Result<Vec<u8>, LpTransportError>
where
R: AsyncRead + Unpin,
{
// Read 4-byte length prefix (u32 big-endian)
let mut len_buf = [0u8; 4];
reader
.read_exact(&mut len_buf)
.await
.inspect_err(|e| debug!("Failed to read packet length: {e}"))
.map_err(LpTransportError::receive_failure)?;
let size = u32::from_le_bytes(len_buf) as usize;
// Sanity check to prevent huge allocations
if size > MAX_TRANSPORT_PACKET_SIZE {
return Err(LpTransportError::PacketTooBig { size });
}
// Read the actual packet data
let mut packet_buf = vec![0u8; size];
reader
.read_exact(&mut packet_buf)
.await
.inspect_err(|e| debug!("Failed to read packet data: {e}"))
.map_err(LpTransportError::receive_failure)?;
Ok(packet_buf)
}
impl LpTransportChannel for TcpStream {
async fn connect(endpoint: SocketAddr) -> Result<Self, LpTransportError> {
TcpStream::connect(endpoint)
.await
.map_err(|err| LpTransportError::connection_failure(err.to_string()))
}
fn set_no_delay(&mut self, nodelay: bool) -> Result<(), LpTransportError> {
// Set TCP_NODELAY for low latency
self.set_nodelay(nodelay)
.map_err(|err| LpTransportError::connection_config(err.to_string()))
}
async fn send_length_prefixed_transport_packet(
&mut self,
packet: &EncryptedLpPacket,
) -> Result<(), LpTransportError> {
send_serialised_packet_async_write(self, packet).await
}
async fn receive_length_prefixed_transport_bytes(
&mut self,
) -> Result<Vec<u8>, LpTransportError> {
receive_length_prefixed_bytes_async_read(self).await
}
}
#[cfg(any(feature = "mock", test))]
impl LpTransportChannel for MockIOStream {
async fn connect(_endpoint: SocketAddr) -> Result<Self, LpTransportError> {
Ok(MockIOStream::default())
}
fn set_no_delay(&mut self, _nodelay: bool) -> Result<(), LpTransportError> {
Ok(())
}
async fn send_length_prefixed_transport_packet(
&mut self,
packet: &EncryptedLpPacket,
) -> Result<(), LpTransportError> {
send_serialised_packet_async_write(self, packet).await
}
async fn receive_length_prefixed_transport_bytes(
&mut self,
) -> Result<Vec<u8>, LpTransportError> {
receive_length_prefixed_bytes_async_read(self).await
}
}
#[cfg(any(feature = "mock", test))]
impl LpHandshakeChannel for MockIOStream {
async fn write_all_and_flush(&mut self, data: &[u8]) -> Result<(), LpTransportError> {
write_all_and_flush_async_write(self, data).await
}
async fn read_n_bytes(&mut self, n: usize) -> Result<Vec<u8>, LpTransportError> {
read_n_bytes_async_read(self, n).await
}
}
impl LpHandshakeChannel for TcpStream {
async fn write_all_and_flush(&mut self, data: &[u8]) -> Result<(), LpTransportError> {
write_all_and_flush_async_write(self, data).await
}
async fn read_n_bytes(&mut self, n: usize) -> Result<Vec<u8>, LpTransportError> {
read_n_bytes_async_read(self, n).await
}
}
+8 -5
View File
@@ -5,13 +5,14 @@ use nym_authenticator_requests::AuthenticatorVersion;
use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_ip_packet_requests::IpPair;
use nym_kkt_ciphersuite::{KEM, KEMKeyDigests, SignatureScheme};
use nym_kkt_ciphersuite::{Ciphersuite, KEM, KEMKeyDigests};
use nym_sphinx::addressing::Recipient;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
pub use lp_messages::*;
use nym_crypto::asymmetric::x25519::DHPublicKey;
pub use serialisation::BincodeError;
mod lp_messages;
@@ -56,9 +57,11 @@ pub struct WireguardConfiguration {
#[derive(Clone, Debug)]
pub struct NymNodeLPInformation {
pub address: SocketAddr,
pub expected_kem_key_hashes: HashMap<KEM, KEMKeyDigests>,
pub expected_signing_key_hashes: HashMap<SignatureScheme, KEMKeyDigests>,
pub x25519: x25519::PublicKey,
pub expected_kem_key_hashes: BTreeMap<KEM, KEMKeyDigests>,
pub x25519: DHPublicKey,
// to be inferred from node's version
pub ciphersuite: Ciphersuite,
/// Supported protocol version of the remote gateway.
/// Included in case we have to downgrade our version.
+1 -1
View File
@@ -21,7 +21,7 @@ thiserror = { workspace = true }
zeroize = { workspace = true, features = ["zeroize_derive"] }
[target.'cfg(target_env = "wasm32-unknown-unknown")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
getrandom = { workspace = true, features = ["js"] }
[features]
default = []
+1
View File
@@ -15,6 +15,7 @@ description = "Helpers, traits, and mock definitions for tests"
anyhow = { workspace = true }
futures = { workspace = true }
rand_chacha = { workspace = true }
rand_chacha09 = { workspace = true }
tokio = { workspace = true, features = ["sync", "time", "rt"] }
tracing = { workspace = true }
+47
View File
@@ -1,19 +1,29 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// fine in test code
#![allow(clippy::unwrap_used)]
use crate::traits::Timeboxed;
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};
use rand_chacha::rand_core::SeedableRng;
use rand_chacha09::rand_core::SeedableRng as SeedableRng09;
use std::future::Future;
use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use tokio::time::error::Elapsed;
// 'current' rand crate
pub use rand_chacha::ChaCha20Rng as DeterministicRng;
pub use rand_chacha::rand_core::{CryptoRng, RngCore};
// rand09 compat
pub use rand_chacha09::ChaChaRng as DeterministicRng09;
pub use rand_chacha09::rand_core::{CryptoRng as CryptoRng09, RngCore as RngCore09};
pub fn leak<T>(val: T) -> &'static mut T {
Box::leak(Box::new(val))
}
@@ -26,6 +36,35 @@ where
tokio::spawn(async move { fut.timeboxed().await })
}
pub struct DeterministicRng09Send(Arc<Mutex<DeterministicRng09>>);
impl DeterministicRng09Send {
pub fn new(deterministic_rng09: DeterministicRng09) -> Self {
Self(Arc::new(Mutex::new(deterministic_rng09)))
}
}
impl CryptoRng09 for DeterministicRng09Send {}
// unwraps are perfectly fine in test code
impl RngCore09 for DeterministicRng09Send {
fn next_u32(&mut self) -> u32 {
self.0.lock().unwrap().next_u32()
}
fn next_u64(&mut self) -> u64 {
self.0.lock().unwrap().next_u64()
}
fn fill_bytes(&mut self, dst: &mut [u8]) {
self.0.lock().unwrap().fill_bytes(dst)
}
}
pub fn deterministic_rng_09() -> DeterministicRng09 {
seeded_rng_09([42u8; 32])
}
pub fn deterministic_rng() -> DeterministicRng {
seeded_rng([42u8; 32])
}
@@ -34,10 +73,18 @@ pub fn seeded_rng(seed: [u8; 32]) -> DeterministicRng {
DeterministicRng::from_seed(seed)
}
pub fn seeded_rng_09(seed: [u8; 32]) -> DeterministicRng09 {
DeterministicRng09::from_seed(seed)
}
pub fn u64_seeded_rng(seed: u64) -> DeterministicRng {
DeterministicRng::seed_from_u64(seed)
}
pub fn u64_seeded_rng_09(seed: u64) -> DeterministicRng09 {
DeterministicRng09::seed_from_u64(seed)
}
// test logger to use during debugging
#[allow(clippy::unwrap_used)]
pub fn setup_test_logger() {
+3 -3
View File
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use ::serde::{Deserialize, Serialize};
use nym_api_requests::nym_nodes::SkimmedNode;
use nym_api_requests::nym_nodes::SkimmedNodeV1;
use nym_crypto::asymmetric::ed25519;
use nym_mixnet_contract_common::EpochId;
use nym_sphinx_addressing::nodes::NodeIdentity;
@@ -283,11 +283,11 @@ impl NymTopology {
serde_json::from_reader(file).map_err(Into::into)
}
pub fn add_skimmed_nodes(&mut self, nodes: &[SkimmedNode]) {
pub fn add_skimmed_nodes(&mut self, nodes: &[SkimmedNodeV1]) {
self.add_additional_nodes(nodes.iter())
}
pub fn with_skimmed_nodes(mut self, nodes: &[SkimmedNode]) -> Self {
pub fn with_skimmed_nodes(mut self, nodes: &[SkimmedNodeV1]) -> Self {
self.add_skimmed_nodes(nodes);
self
}
+3 -3
View File
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use nym_api_requests::models::DeclaredRolesV1;
use nym_api_requests::nym_nodes::SkimmedNode;
use nym_api_requests::nym_nodes::SkimmedNodeV1;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_mixnet_contract_common::NodeId;
use nym_sphinx_addressing::nodes::NymNodeRoutingAddress;
@@ -146,10 +146,10 @@ impl<'a> From<&'a RoutingNode> for SphinxNode {
}
}
impl<'a> TryFrom<&'a SkimmedNode> for RoutingNode {
impl<'a> TryFrom<&'a SkimmedNodeV1> for RoutingNode {
type Error = RoutingNodeError;
fn try_from(value: &'a SkimmedNode) -> Result<Self, Self::Error> {
fn try_from(value: &'a SkimmedNodeV1) -> Result<Self, Self::Error> {
// IF YOU EVER ADD "performance" TO RoutingNode,
// MAKE SURE TO UPDATE THE LAZY IMPLEMENTATION OF
// `impl NodeDescriptionTopologyExt for NymNodeDescription`!!!
@@ -1,3 +1,4 @@
[build]
target = "wasm32-unknown-unknown"
target_arch = "wasm32"
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
+1
View File
@@ -1,3 +1,4 @@
[build]
target = "wasm32-unknown-unknown"
target_arch = "wasm32"
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
-2
View File
@@ -13,8 +13,6 @@ documentation.workspace = true
[dependencies]
async-trait = { workspace = true }
getrandom = { workspace = true, features = ["js"] }
js-sys = { workspace = true }
wasm-bindgen = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde-wasm-bindgen = { workspace = true }
+1
View File
@@ -1,3 +1,4 @@
[build]
target = "wasm32-unknown-unknown"
target_arch = "wasm32"
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
+2 -2
View File
@@ -1137,9 +1137,9 @@ checksum = "00af7901ba50898c9e545c24d5c580c96a982298134e8037d8978b6594782c07"
[[package]]
name = "libc"
version = "0.2.153"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libm"
@@ -57,6 +57,37 @@ This page displays a full list of all the changes during our release cycle from
<VarInfo />
## `v2026.4-quark`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.4-quark)
- [`nym-node`](nodes/nym-node.mdx) version `1.26.0`
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2026-02-24T13:43:24.098285047Z
Build Version: 1.26.0
Commit SHA: a2081af6038ef3ef40b3d9368299d2676a2fbb6a
Commit Date: 2026-02-24T12:02:35.000000000+01:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
cargo Profile: release
```
### Operator & Developer Updates
### Features
- [Stateless handshake improvements for LP Gateway](https://github.com/nymtech/nym/pull/6437)
- [HTTP & DNS improvements](https://github.com/nymtech/nym/pull/6423)
- [Endpoint support for exit gateway IPs](https://github.com/nymtech/nym/pull/6418)
### Tools
- **Diagnostic Tool** - a standalone binary for network diagnostics. It performs DNS, HTTP, and gateway connectivity tests, helping developers identify connectivity issues and monitor network performance. It can also be run via the daemon CLI. Read the full guide [here](https://nym.com/docs/developers/tools/diagnostic-tool).
- **Socks5 Score Calculation** - performed by the Gateway probe, which tests `nym-node --mode exit-gateway` instances over Socks5. The probe assigns a latency-based rating: high, medium, low, or offline. Full guide [here](https://nym.com/docs/operators/performance-and-testing#socks5-score-calculation-process).
## `v2026.3-parmigiano`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.3-parmigiano)
- [`nym-node`](nodes/nym-node.mdx) version `1.25.0`
@@ -74,9 +105,9 @@ rustc Channel: stable
cargo Profile: release
```
### Key updates for operators include:
### Operator & Developer Updates
LP Gateway and Client fixes:
### Features
- [Registration client now properly supports fallback](https://github.com/nymtech/nym/pull/6419)
- [Exposed WireGuard PSK for vpn-client](https://github.com/nymtech/nym/pull/6411)
@@ -85,28 +116,14 @@ LP Gateway and Client fixes:
Note: This code is currently deactivated and doesnt involve any changes for operators right now, but it will in the future.
Mixnet & Networking Enhancements:
- [NS API Socks5 support](https://github.com/nymtech/nym/pull/6361)
- [Two-step dvpn registration flow](https://github.com/nymtech/nym/pull/6386)
- [DVPN PSK injection](https://github.com/nymtech/nym/pull/6378)
Security & Encoding Improvements:
- [Hex-encoding for LP key digests](https://github.com/nymtech/nym/pull/6394)
- [Encrypted KKT](https://github.com/nymtech/nym/pull/6331)
- [Reject packets with incompatible versions](https://github.com/nymtech/nym/pull/6326)
Bugfixes & Quality-of-Life:
### Bugfix
- [Share IP allocation fixes](https://github.com/nymtech/nym/pull/6395)
- [Mixnet registration fixes](https://github.com/nymtech/nym/pull/6356)
- [Small QoL changes](https://github.com/nymtech/nym/pull/6340)
Chores & Maintenance:
### Refactors & Maintenance
- [Cleanup x25519/ed22519 usage](https://github.com/nymtech/nym/pull/6335)
- [Upgrade to def_guard_wireguard v0.8.0](https://github.com/nymtech/nym/pull/6315)
## `v2026.2-oscypek`
@@ -138,7 +155,7 @@ Secondly, the outcome of [NIP-7: Nym Exit Policy Update - Opening Ports for Stea
This release brings changes which would lead into a *foreign constraint bug* if operators just switched binaries and restarted the node. To prevent it we need to do a little `sqlite` tweak on the node database.
To simplify this, we made **a build in command, which operators must run after getting the new binary, but beofre restarting the node.**
To simplify this, we made **a build in command, which operators must run after getting the new binary, but before restarting the node.**
These are the steps to follow:
@@ -162,7 +179,7 @@ chmod +x nym-node
```sh
systemctl restart nym-node
```
- Additionaly look for starus or serivice journal
- Additionally look for status or service journal
```sh
service nym-node status
# or
@@ -195,13 +212,13 @@ chmod +x network-tunnel-manager.sh
- [Deriving `Serialize` for `GatewayData`](https://github.com/nymtech/nym/pull/6314): Deriving `Serialize` for gateway data, that will be used by the diagnostic tool in the `vpn-client` repo
- [DNS static table pre-resolve](https://github.com/nymtech/nym/pull/6297): This PR adds pre-resolve stage that returns addres if we have used static table previously. This ensures that we don't continually suffer the penalty of a lookup timeout, while also allowing for the possibility of going back to the default internal secure resolver if one or more nameservers becomes usable again at a future time.
- [DNS static table pre-resolve](https://github.com/nymtech/nym/pull/6297): This PR adds pre-resolve stage that returns address if we have used static table previously. This ensures that we don't continually suffer the penalty of a lookup timeout, while also allowing for the possibility of going back to the default internal secure resolver if one or more nameservers becomes usable again at a future time.
- [Add `Copy+Clone` to `nym_api_provider::Config`](https://github.com/nymtech/nym/pull/6296): Add `Copy+Clone` to `nym_client_core::client::topology_control::nym_api_provider::Config`
- [LP Registration + Telescoping + Gateway Probe Localnet Mode](https://github.com/nymtech/nym/pull/6286): Combines LP registration protocol implementation, adds telescoping/nested sessions support, adds localnet mode for `gateway-probe` testing, integrates KKT & PSQ cryptographic primitives
- [Minor DNS improvements](https://github.com/nymtech/nym/pull/6283): Increase timeouts back to 10 seconds for overall lookup and 5 seconds per query, gnore unreliable test, remove JIT resolution in http client as it is at best not useful, and at worst increasing timeout
- [Minor DNS improvements](https://github.com/nymtech/nym/pull/6283): Increase timeouts back to 10 seconds for overall lookup and 5 seconds per query, ignore unreliable test, remove JIT resolution in http client as it is at best not useful, and at worst increasing timeout
- [HTTP client without default features](https://github.com/nymtech/nym/pull/6281): Fix compile issue caused when using the http client using `default-features=false`
@@ -233,7 +250,7 @@ chmod +x network-tunnel-manager.sh
- [Update nix to `v0.30.1`](https://github.com/nymtech/nym/pull/6316)
- [Rremove repetitive words in comment](https://github.com/nymtech/nym/pull/6313)
- [Remove repetitive words in comment](https://github.com/nymtech/nym/pull/6313)
- [Clippy fixes and use fixed rust version from `REQUIRED_RUSTC_VERSION`](https://github.com/nymtech/nym/pull/6295)
@@ -1302,7 +1319,7 @@ cargo Profile: release
- [Listen for shutdown signals during nym-node startup](https://github.com/nymtech/nym/pull/5879): This is to avoid situation where the process can't be killed without 'kill -9' because the logic to listen to shutdown signals hasn't been hit yet
### Bugfixes
### Bugfix
- [Don't allow mixnode running in exit mode](https://github.com/nymtech/nym/pull/5898)
@@ -24,8 +24,8 @@ Yes, there are..
**Built by community**
* [ExploreNYM](https://explorenym.net/)
* [Mixplorer](https://mixplorer.xyz/)
* [SpectreDAO Explorer](https://explorer.nym.spectredao.net/dashboard)
* [Nymesis](https://nymesis.vercel.app)
### Which VPS providers would you recommend?
@@ -21,17 +21,16 @@ This documentation page provides a guide on how to set up and run a [NYM NODE](.
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2026-01-27T14:54:15.579821601Z
Build Version: 1.24.0
Commit SHA: 83bf9dc7cc2b01f65cab671733f2bf6c3abd471d
Commit Date: 2026-01-27T15:46:52.000000000+01:00
Build Timestamp: 2026-02-24T13:43:24.098285047Z
Build Version: 1.26.0
Commit SHA: a2081af6038ef3ef40b3d9368299d2676a2fbb6a
Commit Date: 2026-02-24T12:02:35.000000000+01:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
cargo Profile: release
```
Detailed version archive and release notes is documented [here](../../changelog.mdx).
{/* COMMENTING THIS OUT ASS WE HAVE TO FIGURE OUT HOW TO SHOW THE LATEST VERSION FROM MASTER BRANCH
+2 -6
View File
@@ -78,13 +78,9 @@ nym-authenticator-requests = { workspace = true }
nym-client-core = { workspace = true, features = ["cli"] }
nym-id = { workspace = true }
nym-service-provider-requests-common = { workspace = true }
# LP dependencies
nym-lp = { path = "../common/nym-lp" }
nym-lp-transport = { path = "../common/nym-lp-transport" }
nym-kcp = { path = "../common/nym-kcp" }
nym-registration-common = { path = "../common/registration" }
bytes = { workspace = true }
nym-lp = { path = "../common/nym-lp" }
defguard_wireguard_rs = { workspace = true }
-4
View File
@@ -15,8 +15,6 @@ pub struct Config {
pub upgrade_mode_watcher: UpgradeModeWatcher,
pub lp: crate::node::lp_listener::LpConfig,
pub debug: Debug,
}
@@ -26,7 +24,6 @@ impl Config {
network_requester: impl Into<NetworkRequester>,
ip_packet_router: impl Into<IpPacketRouter>,
upgrade_mode_watcher: impl Into<UpgradeModeWatcher>,
lp: impl Into<crate::node::lp_listener::LpConfig>,
debug: impl Into<Debug>,
) -> Self {
Config {
@@ -34,7 +31,6 @@ impl Config {
network_requester: network_requester.into(),
ip_packet_router: ip_packet_router.into(),
upgrade_mode_watcher: upgrade_mode_watcher.into(),
lp: lp.into(),
debug: debug.into(),
}
}
-15
View File
@@ -127,27 +127,12 @@ pub enum GatewayError {
#[error("{0}")]
CredentialVerificationError(#[from] nym_credential_verification::Error),
#[error("LP connection error: {0}")]
LpConnectionError(String),
#[error("LP protocol error: {0}")]
LpProtocolError(String),
#[error("LP handshake error: {0}")]
LpHandshakeError(String),
#[error("Service provider {service} is not running")]
ServiceProviderNotRunning { service: String },
#[error("Internal error: {0}")]
InternalError(String),
#[error("Failed to bind listener to {address}: {source}")]
ListenerBindFailure {
address: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("Failed to parse ip address: {source}")]
IpAddrParseError {
#[from]
@@ -1,261 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
//! LP Data Handler - UDP listener for LP data plane (port 51264)
//!
//! This module handles the data plane for LP clients that have completed registration
//! via the control plane (TCP:41264). LP-wrapped Sphinx packets arrive here, get
//! decrypted, and are forwarded into the mixnet.
//!
//! # Packet Flow
//!
//! ```text
//! LP Client → UDP:51264 → LP Data Handler → Mixnet Entry
//! LP(Sphinx) decrypt LP forward Sphinx
//! ```
//!
//! # Wire Format
//!
//! Each UDP packet is a complete LP packet:
//! - Header (8 bytes): receiver_idx (4) + counter (4)
//! - Payload: Outer AEAD encrypted Sphinx packet
//!
//! The receiver_idx is used to look up the session established during LP registration.
use super::LpHandlerState;
use crate::error::GatewayError;
use nym_lp::state_machine::{LpAction, LpInput};
use nym_metrics::inc;
use nym_sphinx::forwarding::packet::MixPacket;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tracing::*;
/// Maximum UDP packet size we'll accept
/// Sphinx packets are typically ~2KB, LP overhead is ~50 bytes, so 4KB is plenty
const MAX_UDP_PACKET_SIZE: usize = 4096;
/// LP Data Handler for UDP data plane
pub struct LpDataHandler {
/// UDP socket for receiving LP-wrapped Sphinx packets
socket: Arc<UdpSocket>,
/// Shared state with TCP control plane
state: LpHandlerState,
/// Shutdown token
shutdown: nym_task::ShutdownToken,
}
impl LpDataHandler {
/// Create a new LP data handler
pub async fn new(
bind_addr: SocketAddr,
state: LpHandlerState,
shutdown: nym_task::ShutdownToken,
) -> Result<Self, GatewayError> {
let socket = UdpSocket::bind(bind_addr).await.map_err(|e| {
error!("Failed to bind LP data socket to {bind_addr}: {e}");
GatewayError::ListenerBindFailure {
address: bind_addr.to_string(),
source: Box::new(e),
}
})?;
info!("LP data handler listening on UDP {bind_addr}");
Ok(Self {
socket: Arc::new(socket),
state,
shutdown,
})
}
/// Run the UDP packet receive loop
pub async fn run(self) -> Result<(), GatewayError> {
let mut buf = vec![0u8; MAX_UDP_PACKET_SIZE];
loop {
tokio::select! {
biased;
_ = self.shutdown.cancelled() => {
info!("LP data handler: received shutdown signal");
break;
}
result = self.socket.recv_from(&mut buf) => {
match result {
Ok((len, src_addr)) => {
// Process packet in place (no spawn - UDP is fast)
if let Err(e) = self.handle_packet(&buf[..len], src_addr).await {
debug!("LP data packet error from {}: {}", src_addr, e);
inc!("lp_data_packet_errors");
}
}
Err(e) => {
warn!("LP data socket recv error: {}", e);
inc!("lp_data_recv_errors");
}
}
}
}
}
info!("LP data handler shutdown complete");
Ok(())
}
/// Handle a single UDP packet
///
/// # Packet Processing Steps
/// 1. Parse LP header to get receiver_idx (for routing)
/// 2. Look up session state machine by receiver_idx
/// 3. Process packet through state machine (handles decryption + replay protection)
/// 4. Forward decrypted Sphinx packet to mixnet
///
/// # Security
/// The state machine's `process_input()` method handles replay protection by:
/// - Checking packet counter against receiving window
/// - Marking counter as used after successful decryption
///
/// This prevents replay attacks where captured packets are re-sent.
async fn handle_packet(&self, packet: &[u8], src_addr: SocketAddr) -> Result<(), GatewayError> {
inc!("lp_data_packets_received");
// Step 1: Parse LP header (always cleartext for routing)
let header = nym_lp::codec::parse_lp_header_only(packet).map_err(|e| {
GatewayError::LpProtocolError(format!("Failed to parse LP header: {}", e))
})?;
let receiver_idx = header.receiver_idx;
let counter = header.counter;
let len = packet.len();
trace!("LP data packet from {src_addr} (receiver_idx={receiver_idx}, counter={counter}, len={len})");
// Step 2: Look up session state machine by receiver_idx (mutable for state updates)
let mut state_entry = self
.state
.session_states
.get_mut(&receiver_idx)
.ok_or_else(|| {
inc!("lp_data_unknown_session");
GatewayError::LpProtocolError(format!(
"Unknown session for receiver_idx {receiver_idx}"
))
})?;
// Update last activity timestamp
state_entry.value().touch();
// Step 3: Get outer AEAD key for packet parsing
let outer_key = state_entry
.value()
.state
.session()
.map_err(|e| GatewayError::LpProtocolError(format!("Session error: {e}")))?
.outer_aead_key();
// Parse full packet with outer AEAD decryption
let lp_packet = nym_lp::codec::parse_lp_packet(packet, Some(outer_key)).map_err(|e| {
inc!("lp_data_decrypt_errors");
GatewayError::LpProtocolError(format!("Failed to decrypt LP packet: {}", e))
})?;
// Step 4: Process packet through state machine
// This handles:
// - Replay protection (counter check + mark)
// - Inner Noise decryption
// - Subsession handling if applicable
let state_machine = &mut state_entry.value_mut().state;
let action = state_machine
.process_input(LpInput::ReceivePacket(lp_packet))
.ok_or_else(|| {
GatewayError::LpProtocolError("State machine returned no action".to_string())
})?
.map_err(|e| {
inc!("lp_data_state_machine_errors");
GatewayError::LpProtocolError(format!("State machine error: {}", e))
})?;
// Release session lock before forwarding
drop(state_entry);
// Step 5: Handle the action from state machine
match action {
LpAction::DeliverData(data) => {
// Decrypted application data - forward as Sphinx packet
self.forward_sphinx_packet(&data.content).await?;
inc!("lp_data_packets_forwarded");
Ok(())
}
LpAction::SendPacket(_response_packet) => {
// UDP is connectionless - we can't send responses back easily
// For subsession rekeying, the client should use TCP control plane
debug!(
"Ignoring SendPacket action on UDP (receiver_idx={receiver_idx}) - use TCP for rekeying",
);
inc!("lp_data_ignored_send_actions");
Ok(())
}
other => {
warn!(
"Unexpected action on UDP data plane from {}: {:?}",
src_addr, other
);
inc!("lp_data_unexpected_actions");
Err(GatewayError::LpProtocolError(format!(
"Unexpected state machine action on UDP: {:?}",
other
)))
}
}
}
/// Parse Sphinx packet bytes and forward to mixnet
///
/// The decrypted LP payload contains a serialized MixPacket that includes:
/// - Packet type (1 byte)
/// - Key rotation (1 byte)
/// - Next hop address (first mix node)
/// - Sphinx packet data
async fn forward_sphinx_packet(&self, sphinx_bytes: &[u8]) -> Result<(), GatewayError> {
// Parse as MixPacket v2 format (packet_type || key_rotation || next_hop || packet)
let mix_packet = MixPacket::try_from_v2_bytes(sphinx_bytes).map_err(|e| {
inc!("lp_data_sphinx_parse_errors");
GatewayError::LpProtocolError(format!("Failed to parse MixPacket: {e}"))
})?;
trace!(
"Forwarding Sphinx packet to mixnet (next_hop={}, type={:?})",
mix_packet.next_hop(),
mix_packet.packet_type()
);
// Forward to mixnet via the shared channel
if let Err(e) = self.state.outbound_mix_sender.forward_packet(mix_packet) {
error!("Failed to forward Sphinx packet to mixnet: {}", e);
inc!("lp_data_forward_errors");
return Err(GatewayError::InternalError(format!(
"Mix packet forwarding failed: {e}",
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// Sphinx packets are typically around 2KB
// LP overhead is small (~50 bytes header + AEAD tag)
// 4KB should be plenty with room to spare
const _: () = {
assert!(MAX_UDP_PACKET_SIZE >= 2048 + 100);
};
}
File diff suppressed because it is too large Load Diff
-674
View File
@@ -1,674 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
// LP (Lewes Protocol) Metrics Documentation
//
// This module implements comprehensive metrics collection for LP operations using nym-metrics macros.
// All metrics are automatically prefixed with the package name (nym_gateway) when registered.
//
// ## Connection Metrics (via NetworkStats in nym-node-metrics)
// - active_lp_connections: Gauge tracking current active LP connections (incremented on accept, decremented on close)
//
// ## Handler Metrics (in handler.rs)
// - lp_connections_total: Counter for total LP connections handled
// - lp_client_hello_failed: Counter for ClientHello failures (timestamp validation, protocol errors)
// - lp_handshakes_success: Counter for successful handshake completions
// - lp_handshakes_failed: Counter for failed handshakes
// - lp_handshake_duration_seconds: Histogram of handshake durations (buckets: 10ms to 10s)
// - lp_timestamp_validation_accepted: Counter for timestamp validations that passed
// - lp_timestamp_validation_rejected: Counter for timestamp validations that failed
// - lp_errors_handshake: Counter for handshake errors
// - lp_errors_send_response: Counter for errors sending registration responses
// - lp_errors_timestamp_too_old: Counter for ClientHello timestamps that are too old
// - lp_errors_timestamp_too_far_future: Counter for ClientHello timestamps that are too far in the future
//
// ## Registration Metrics (in registration.rs)
// - lp_registration_attempts_total: Counter for all registration attempts
// - lp_registration_success_total: Counter for successful registrations (any mode)
// - lp_registration_failed_total: Counter for failed registrations (any mode)
// - lp_registration_failed_timestamp: Counter for registrations rejected due to invalid timestamp
// - lp_registration_duration_seconds: Histogram of registration durations (buckets: 100ms to 30s)
//
// ## Mode-Specific Registration Metrics (in registration.rs)
// - lp_registration_dvpn_attempts: Counter for dVPN mode registration attempts
// - lp_registration_dvpn_success: Counter for successful dVPN registrations
// - lp_registration_dvpn_failed: Counter for failed dVPN registrations
// - lp_registration_mixnet_attempts: Counter for Mixnet mode registration attempts
// - lp_registration_mixnet_success: Counter for successful Mixnet registrations
// - lp_registration_mixnet_failed: Counter for failed Mixnet registrations
//
// ## Credential Verification Metrics (in registration.rs)
// - lp_credential_verification_attempts: Counter for credential verification attempts
// - lp_credential_verification_success: Counter for successful credential verifications
// - lp_credential_verification_failed: Counter for failed credential verifications
// - lp_bandwidth_allocated_bytes_total: Counter for total bandwidth allocated (in bytes)
//
// ## Error Categorization Metrics
// - lp_errors_wg_peer_registration: Counter for WireGuard peer registration failures
//
// ## Connection Lifecycle Metrics (in handler.rs)
// - lp_connection_duration_seconds: Histogram of connection duration from start to end (buckets: 1s to 24h)
// - lp_connection_bytes_received_total: Counter for total bytes received including protocol framing
// - lp_connection_bytes_sent_total: Counter for total bytes sent including protocol framing
// - lp_connections_completed_gracefully: Counter for connections that completed successfully
// - lp_connections_completed_with_error: Counter for connections that terminated with an error
//
// ## State Cleanup Metrics (in cleanup task)
// - lp_states_cleanup_handshake_removed: Counter for stale handshakes removed by cleanup task
// - lp_states_cleanup_session_removed: Counter for stale sessions removed by cleanup task
// - lp_states_cleanup_demoted_removed: Counter for demoted (read-only) sessions removed by cleanup task
//
// ## Subsession/Rekeying Metrics (in handler.rs)
// - lp_subsession_kk2_sent: Counter for SubsessionKK2 responses sent (indicates client initiated rekeying)
// - lp_subsession_complete: Counter for successful subsession promotions
// - lp_subsession_receiver_index_collision: Counter for subsession receiver_index collisions
//
// ## Usage Example
// To view metrics, the nym-metrics registry automatically collects all metrics.
// They can be exported via Prometheus format using the metrics endpoint.
use crate::error::GatewayError;
use crate::node::wireguard::PeerRegistrator;
use crate::node::ActiveClientsStore;
use dashmap::DashMap;
use nym_config::serde_helpers::de_maybe_port;
use nym_credential_verification::ecash::traits::EcashManager;
use nym_gateway_storage::GatewayStorage;
use nym_lp::state_machine::LpStateMachine;
use nym_node_metrics::NymNodeMetrics;
use nym_task::ShutdownTracker;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::Semaphore;
use tracing::*;
pub use nym_lp::peer::LpLocalPeer;
pub use nym_mixnet_client::forwarder::{
mix_forwarding_channels, MixForwardingReceiver, MixForwardingSender,
};
pub use nym_wireguard::{PeerControlRequest, WireguardGatewayData};
mod data_handler;
pub mod handler;
mod registration;
pub type ReceiverIndex = u32;
/// Configuration for LP listener
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct LpConfig {
/// Bind address for the TCP LP control traffic.
/// default: `[::]:41264`
pub control_bind_address: SocketAddr,
/// Bind address for the UDP LP data traffic.
/// default: `[::]:51264`
pub data_bind_address: SocketAddr,
/// Custom announced port for listening for the TCP LP control traffic.
/// If unspecified, the value from the `control_bind_address` will be used instead
/// (default: None)
#[serde(deserialize_with = "de_maybe_port")]
pub announce_control_port: Option<u16>,
/// Custom announced port for listening for the UDP LP data traffic.
/// If unspecified, the value from the `data_bind_address` will be used instead
/// (default: None)
#[serde(deserialize_with = "de_maybe_port")]
pub announce_data_port: Option<u16>,
/// Auxiliary configuration
#[serde(default)]
pub debug: LpDebug,
}
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct LpDebug {
/// Maximum concurrent connections
pub max_connections: usize,
/// Maximum acceptable age of ClientHello timestamp in seconds (default: 30)
///
/// ClientHello messages with timestamps older than this will be rejected
/// to prevent replay attacks. Value should be:
/// - Large enough to account for clock skew and network latency
/// - Small enough to limit replay attack window
///
/// Recommended: 30-60 seconds
#[serde(with = "humantime_serde")]
pub timestamp_tolerance: Duration,
/// Use mock ecash manager for testing (default: false)
///
/// When enabled, the LP listener will use a mock ecash verifier that
/// accepts any credential without blockchain verification. This is
/// useful for testing the LP protocol implementation without requiring
/// a full blockchain/contract setup.
///
/// WARNING: Only use this for local testing! Never enable in production.
pub use_mock_ecash: bool,
/// Maximum age of in-progress handshakes before cleanup (default: 90s)
///
/// Handshakes should complete quickly (3-5 packets). This TTL accounts for:
/// - Network latency and retransmits
/// - Slow clients
/// - Clock skew tolerance
///
/// Stale handshakes are removed by the cleanup task to prevent memory leaks.
#[serde(with = "humantime_serde")]
pub handshake_ttl: Duration,
/// Maximum age of established sessions before cleanup (default: 24h)
///
/// Sessions can be long-lived for dVPN tunnels. This TTL should be set
/// high enough to accommodate expected usage patterns:
/// - dVPN sessions: hours to days
/// - Registration: minutes
///
/// Sessions with no activity for this duration are removed by the cleanup task.
#[serde(with = "humantime_serde")]
pub session_ttl: Duration,
/// Maximum age of demoted (read-only) sessions before cleanup (default: 60s)
///
/// After subsession promotion, old sessions enter ReadOnlyTransport state.
/// They only need to stay alive briefly to drain in-flight packets.
/// This shorter TTL prevents memory buildup from frequent rekeying.
#[serde(with = "humantime_serde")]
pub demoted_session_ttl: Duration,
/// How often to run the state cleanup task (default: 5 minutes)
///
/// The cleanup task scans for and removes stale handshakes and sessions.
/// Lower values = more frequent cleanup but higher overhead.
/// Higher values = less overhead but slower memory reclamation.
#[serde(with = "humantime_serde")]
pub state_cleanup_interval: Duration,
/// Maximum concurrent forward connections (default: 1000)
///
/// Limits simultaneous outbound connections when forwarding LP packets to other gateways
/// during telescope setup. This prevents file descriptor exhaustion under high load.
///
/// When at capacity, new forward requests return an error, signaling the client
/// to choose a different gateway.
pub max_concurrent_forwards: usize,
}
impl LpConfig {
pub const DEFAULT_CONTROL_PORT: u16 = 41264;
pub const DEFAULT_DATA_PORT: u16 = 51264;
pub fn announced_control_port(&self) -> u16 {
self.announce_control_port
.unwrap_or(self.control_bind_address.port())
}
pub fn announced_data_port(&self) -> u16 {
self.announce_data_port
.unwrap_or(self.data_bind_address.port())
}
}
impl Default for LpConfig {
fn default() -> Self {
LpConfig {
control_bind_address: SocketAddr::new(
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
Self::DEFAULT_CONTROL_PORT,
),
data_bind_address: SocketAddr::new(
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
Self::DEFAULT_DATA_PORT,
),
announce_control_port: None,
announce_data_port: None,
debug: Default::default(),
}
}
}
impl LpDebug {
pub const DEFAULT_MAX_CONNECTIONS: usize = 10000;
// 30 seconds - balances security vs clock skew tolerance
pub const DEFAULT_TIMESTAMP_TOLERANCE: Duration = Duration::from_secs(30);
// 90 seconds - handshakes should complete quickly
pub const DEFAULT_HANDSHAKE_TTL: Duration = Duration::from_secs(90);
// 24 hours - for long-lived dVPN sessions
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(86400);
// 1 minute - enough to drain in-flight packets after subsession promotion
pub const DEFAULT_DEMOTED_SESSION_TTL: Duration = Duration::from_secs(60);
// 5 minutes - balances memory reclamation with task overhead
pub const DEFAULT_STATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(300);
// Limits concurrent outbound connections to prevent fd exhaustion
pub const DEFAULT_MAX_CONCURRENT_FORWARDS: usize = 1000;
}
impl Default for LpDebug {
fn default() -> Self {
LpDebug {
max_connections: Self::DEFAULT_MAX_CONNECTIONS,
timestamp_tolerance: Self::DEFAULT_TIMESTAMP_TOLERANCE,
use_mock_ecash: false,
handshake_ttl: Self::DEFAULT_HANDSHAKE_TTL,
session_ttl: Self::DEFAULT_SESSION_TTL,
demoted_session_ttl: Self::DEFAULT_DEMOTED_SESSION_TTL,
state_cleanup_interval: Self::DEFAULT_STATE_CLEANUP_INTERVAL,
max_concurrent_forwards: Self::DEFAULT_MAX_CONCURRENT_FORWARDS,
}
}
}
/// Wrapper for state entries with timestamp tracking for cleanup
///
/// This wrapper adds `created_at` and `last_activity` timestamps to state entries,
/// enabling TTL-based cleanup of stale handshakes and sessions.
pub struct TimestampedState<T> {
/// The actual state (LpStateMachine or LpSession)
pub state: T,
/// When this state was created (never changes)
created_at: std::time::Instant,
/// Last activity timestamp (unix seconds, atomically updated)
///
/// For handshakes: never updated (use created_at for TTL)
/// For sessions: updated on every packet received
last_activity: std::sync::atomic::AtomicU64,
}
impl<T> TimestampedState<T> {
/// Create a new timestamped state
pub fn new(state: T) -> Self {
let now_instant = std::time::Instant::now();
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
state,
created_at: now_instant,
last_activity: std::sync::atomic::AtomicU64::new(now_unix),
}
}
/// Update last_activity timestamp (cheap, lock-free operation)
pub fn touch(&self) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.last_activity
.store(now, std::sync::atomic::Ordering::Relaxed);
}
/// Get age since creation
pub fn age(&self) -> Duration {
self.created_at.elapsed()
}
/// Get time since last activity
pub fn since_activity(&self) -> Duration {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let last = self
.last_activity
.load(std::sync::atomic::Ordering::Relaxed);
Duration::from_secs(now.saturating_sub(last))
}
}
/// Shared state for LP connection handlers
#[derive(Clone)]
pub struct LpHandlerState {
/// Ecash verifier for bandwidth credentials
pub ecash_verifier: Arc<dyn EcashManager + Send + Sync>,
/// Storage backend for persistence
pub storage: GatewayStorage,
/// Encapsulates all required key information of a local Lewes Protocol Peer.
pub local_lp_peer: LpLocalPeer,
/// Metrics collection
pub metrics: NymNodeMetrics,
/// Active clients tracking
pub active_clients_store: ActiveClientsStore,
/// Handle registering new wireguard peers
pub peer_registrator: Option<PeerRegistrator>,
/// LP configuration (for timestamp validation, etc.)
pub lp_config: LpConfig,
/// Channel for forwarding Sphinx packets into the mixnet
///
/// Used by the LP data handler (UDP:51264) to forward decrypted Sphinx packets
/// from LP clients into the mixnet for routing.
pub outbound_mix_sender: MixForwardingSender,
/// Established sessions keyed by session_id
///
/// Used after handshake completes (session_id is deterministically computed from
/// both parties' X25519 keys). Enables stateless transport - each packet lookup
/// by session_id, decrypt/process, respond.
///
/// Wrapped in TimestampedState for TTL-based cleanup of inactive sessions.
///
/// Sessions are stored as LpStateMachine (not LpSession) to enable
/// subsession/rekeying support. The state machine handles subsession initiation
/// (SubsessionKK1/KK2/Ready) during transport phase, allowing long-lived connections
/// to rekey without re-authentication.
pub session_states: Arc<DashMap<ReceiverIndex, TimestampedState<LpStateMachine>>>,
/// Semaphore limiting concurrent forward connections
///
/// Prevents file descriptor exhaustion when forwarding LP packets during
/// telescope setup. When at capacity, forward requests return an error
/// so clients can choose a different gateway.
// Connection limiting (not pooling) chosen for forward requests.
//
// Why not connection pooling?
// 1. Forwarding is one-time per telescope setup (handshake only), not ongoing traffic.
// Once telescope is established, data flows directly through the tunnel.
// 2. Telescope targets are distributed across many different gateways - each client
// typically connects to a different exit gateway, so pooled connections would
// rarely be reused.
// 3. Connections already go out of scope after each request-response. FD exhaustion
// only happens from concurrent spikes, not accumulation.
// 4. A pool would accumulate one idle connection per unique destination, most of
// which would never be reused before TTL expiration.
//
// Why semaphore limiting is better:
// 1. Directly caps concurrent forward connections regardless of destination.
// 2. When at capacity, returns "busy" error - client can choose another gateway.
// This is better than silently queuing requests behind a pool.
// 3. Simple implementation: no TTL management, stale connection handling, or cleanup.
pub forward_semaphore: Arc<Semaphore>,
}
/// LP listener that accepts TCP connections on port 41264
pub struct LpListener {
/// Shared state for connection handlers
handler_state: LpHandlerState,
/// Shutdown coordination
shutdown: ShutdownTracker,
}
impl LpListener {
pub fn new(handler_state: LpHandlerState, shutdown: ShutdownTracker) -> Self {
Self {
handler_state,
shutdown,
}
}
fn lp_config(&self) -> LpConfig {
self.handler_state.lp_config
}
pub async fn run(&mut self) -> Result<(), GatewayError> {
let control_bind_address = self.lp_config().control_bind_address;
let data_bind_address = self.lp_config().data_bind_address;
let listener = TcpListener::bind(control_bind_address).await.map_err(|e| {
error!("Failed to bind LP listener to {control_bind_address}: {e}",);
GatewayError::ListenerBindFailure {
address: control_bind_address.to_string(),
source: Box::new(e),
}
})?;
let shutdown_token = self.shutdown.clone_shutdown_token();
// Spawn background task for state cleanup
let _cleanup_handle = self.spawn_state_cleanup_task();
// Spawn UDP data handler for LP data plane (port 51264)
let _data_handler_handle = self.spawn_data_handler().await?;
info!(
"LP listener started on {control_bind_address} (data handler on: {data_bind_address})",
);
loop {
tokio::select! {
biased;
_ = shutdown_token.cancelled() => {
trace!("LP listener: received shutdown signal");
break;
}
result = listener.accept() => {
match result {
Ok((stream, addr)) => {
self.handle_connection(stream, addr);
}
Err(e) => {
warn!("Failed to accept LP connection: {}", e);
}
}
}
}
}
info!("LP listener shutdown complete");
Ok(())
}
fn handle_connection(&self, stream: tokio::net::TcpStream, remote_addr: SocketAddr) {
// Check connection limit
let active_connections = self.active_lp_connections();
let max_connections = self.lp_config().debug.max_connections;
if active_connections >= max_connections {
warn!(
"LP connection limit exceeded ({active_connections}/{max_connections}), rejecting connection from {remote_addr}"
);
return;
}
debug!(
"Accepting LP connection from {remote_addr} ({active_connections} active connections)"
);
// Increment connection counter
self.handler_state.metrics.network.new_lp_connection();
// Spawn handler task
let handler =
handler::LpConnectionHandler::new(stream, remote_addr, self.handler_state.clone());
let metrics = self.handler_state.metrics.clone();
self.shutdown.try_spawn_named_with_shutdown(
async move {
let result = handler.handle().await;
// Handler emits lifecycle metrics internally on success
// For errors, we need to emit them here since handler is consumed
if let Err(e) = result {
warn!("LP handler error for {remote_addr}: {e}");
// Note: metrics are emitted in handle() for graceful path
// On error path, handle() returns early without emitting
// So we track errors here
}
// Decrement connection counter on exit
metrics.network.lp_connection_closed();
},
&format!("LP::{remote_addr}"),
);
}
/// Spawn the UDP data handler for LP data plane
///
/// The data handler listens on UDP port 51264 and processes LP-wrapped Sphinx packets
/// from registered clients. It decrypts the LP layer and forwards the Sphinx packets
/// into the mixnet.
async fn spawn_data_handler(&self) -> Result<tokio::task::JoinHandle<()>, GatewayError> {
// Create data handler
let data_handler = data_handler::LpDataHandler::new(
self.lp_config().data_bind_address,
self.handler_state.clone(),
self.shutdown.clone_shutdown_token(),
)
.await?;
// Spawn data handler task
let handle = self.shutdown.try_spawn_named(
async move {
if let Err(e) = data_handler.run().await {
error!("LP data handler error: {e}");
}
},
"LP::DataHandler",
);
Ok(handle)
}
/// Spawn background task for cleaning up stale state entries
///
/// This task runs periodically (every `state_cleanup_interval_secs`) to remove:
/// - Handshake states older than `handshake_ttl_secs`
/// - Session states with no activity for `session_ttl_secs`
///
/// The task automatically stops when the shutdown signal is received.
fn spawn_state_cleanup_task(&self) -> tokio::task::JoinHandle<()> {
let session_states = Arc::clone(&self.handler_state.session_states);
let dbg_cfg = self.handler_state.lp_config.debug;
let handshake_ttl = dbg_cfg.handshake_ttl;
let session_ttl = dbg_cfg.session_ttl;
let demoted_session_ttl = dbg_cfg.demoted_session_ttl;
let interval = dbg_cfg.state_cleanup_interval;
let shutdown = self.shutdown.clone_shutdown_token();
let metrics = self.handler_state.metrics.clone();
info!(
"Starting LP state cleanup task (handshake_ttl={}s, session_ttl={}s, demoted_ttl={}s, interval={}s)",
handshake_ttl.as_secs(), session_ttl.as_secs(), demoted_session_ttl.as_secs(), interval.as_secs()
);
self.shutdown.try_spawn_named(
cleanup_task::cleanup_loop(session_states, dbg_cfg, shutdown, metrics),
"LP::StateCleanup",
)
}
fn active_lp_connections(&self) -> usize {
self.handler_state
.metrics
.network
.active_lp_connections_count()
}
}
pub(crate) mod cleanup_task {
use crate::node::lp_listener::{LpDebug, TimestampedState};
use dashmap::DashMap;
use nym_lp::state_machine::LpStateBare;
use nym_lp::LpStateMachine;
use nym_metrics::inc_by;
use nym_node_metrics::NymNodeMetrics;
use std::sync::Arc;
use tracing::{debug, info};
async fn perform_cleanup(
session_states: &Arc<DashMap<u32, TimestampedState<LpStateMachine>>>,
cfg: LpDebug,
) {
let session_ttl = cfg.session_ttl;
let demoted_session_ttl = cfg.demoted_session_ttl;
let start = std::time::Instant::now();
let mut ss_removed = 0u64;
let mut demoted_removed = 0u64;
// Remove stale sessions (based on time since last activity)
// Use shorter TTL for demoted (ReadOnlyTransport) sessions
session_states.retain(|_, timestamped| {
let is_demoted = timestamped.state.bare_state() == LpStateBare::ReadOnlyTransport;
let ttl = if is_demoted {
demoted_session_ttl
} else {
session_ttl
};
if timestamped.since_activity() > ttl {
if is_demoted {
demoted_removed += 1;
} else {
ss_removed += 1;
}
false
} else {
true
}
});
if ss_removed > 0 || demoted_removed > 0 {
let duration = start.elapsed();
info!(
"LP state cleanup: {ss_removed} sessions, {demoted_removed} demoted (took {:.3}s)",
duration.as_secs_f64()
);
// Track metrics
if ss_removed > 0 {
inc_by!("lp_states_cleanup_session_removed", ss_removed as i64);
}
if demoted_removed > 0 {
inc_by!("lp_states_cleanup_demoted_removed", demoted_removed as i64);
}
}
}
/// Background loop for cleaning up stale state entries
///
/// Runs periodically to scan handshake_states and session_states maps,
/// removing entries that have exceeded their TTL.
///
/// Demoted sessions (ReadOnlyTransport) use shorter TTL since they
/// only need to drain in-flight packets after subsession promotion.
pub(crate) async fn cleanup_loop(
session_states: Arc<DashMap<u32, TimestampedState<LpStateMachine>>>,
cfg: LpDebug,
shutdown: nym_task::ShutdownToken,
_metrics: NymNodeMetrics,
) {
let interval = cfg.state_cleanup_interval;
let mut cleanup_interval = tokio::time::interval(interval);
loop {
tokio::select! {
biased;
_ = shutdown.cancelled() => {
debug!("LP state cleanup task: received shutdown signal");
break;
}
_ = cleanup_interval.tick() => {
perform_cleanup(&session_states, cfg).await;
}
}
}
info!("LP state cleanup task shutdown complete");
}
}
+7 -47
View File
@@ -16,9 +16,8 @@ use nym_credential_verification::ecash::{
use nym_credential_verification::upgrade_mode::{
UpgradeModeCheckConfig, UpgradeModeDetails, UpgradeModeState,
};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_crypto::asymmetric::ed25519;
use nym_ip_packet_router::IpPacketRouter;
use nym_lp::peer::LpLocalPeer;
use nym_mixnet_client::forwarder::MixForwardingSender;
use nym_network_defaults::NymNetworkDetails;
use nym_network_requester::NRServiceProviderBuilder;
@@ -33,14 +32,12 @@ use rand::thread_rng;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tracing::*;
use zeroize::Zeroizing;
pub use crate::node::upgrade_mode::watcher::UpgradeModeWatcher;
use crate::node::wireguard::{PeerManager, PeerRegistrator};
pub use client_handling::active_clients::ActiveClientsStore;
pub use lp_listener::LpConfig;
pub use nym_credential_verification::upgrade_mode::UpgradeModeCheckRequestSender;
pub use nym_gateway_stats_storage::PersistentStatsStorage;
pub use nym_gateway_storage::{
@@ -52,7 +49,6 @@ pub use nym_sdk::{NymApiTopologyProvider, NymApiTopologyProviderConfig, UserAgen
pub(crate) mod client_handling;
pub(crate) mod internal_service_providers;
pub mod lp_listener;
mod stale_data_cleaner;
pub mod upgrade_mode;
pub mod wireguard;
@@ -95,12 +91,6 @@ pub struct GatewayTasksBuilder {
/// ed25519 keypair used to assert one's identity.
identity_keypair: Arc<ed25519::KeyPair>,
/// x25519 keypair used within KTT exchange
x25519_keypair: Arc<x25519::KeyPair>,
/// x25519 (for now, to be changed into MlKem) keypair used for the PSQ derivation
kem_psq_keys: Arc<x25519::KeyPair>,
storage: GatewayStorage,
mix_packet_sender: MixForwardingSender,
@@ -115,6 +105,8 @@ pub struct GatewayTasksBuilder {
shutdown_tracker: ShutdownTracker,
use_mock_ecash: bool,
// populated and cached as necessary
ecash_manager:
Option<Arc<dyn nym_credential_verification::ecash::traits::EcashManager + Send + Sync>>,
@@ -129,8 +121,6 @@ impl GatewayTasksBuilder {
pub fn new(
config: Config,
identity: Arc<ed25519::KeyPair>,
x25519: Arc<x25519::KeyPair>,
kem_psq_keys: Arc<x25519::KeyPair>,
storage: GatewayStorage,
mix_packet_sender: MixForwardingSender,
metrics_sender: MetricEventsSender,
@@ -138,6 +128,7 @@ impl GatewayTasksBuilder {
mnemonic: Arc<Zeroizing<bip39::Mnemonic>>,
user_agent: UserAgent,
upgrade_mode_state: UpgradeModeState,
use_mock_ecash: bool,
shutdown_tracker: ShutdownTracker,
) -> GatewayTasksBuilder {
GatewayTasksBuilder {
@@ -148,8 +139,6 @@ impl GatewayTasksBuilder {
wireguard_data: None,
user_agent,
identity_keypair: identity,
x25519_keypair: x25519,
kem_psq_keys,
storage,
mix_packet_sender,
metrics_sender,
@@ -157,6 +146,7 @@ impl GatewayTasksBuilder {
upgrade_mode_state,
mnemonic,
shutdown_tracker,
use_mock_ecash,
ecash_manager: None,
wireguard_peers: None,
wireguard_networks: None,
@@ -235,8 +225,8 @@ impl GatewayTasksBuilder {
GatewayError,
> {
// Check if we should use mock ecash for testing
if self.config.lp.debug.use_mock_ecash {
warn!("Using MockEcashManager for LP testing (credentials NOT verified)");
if self.use_mock_ecash {
warn!("Using MockEcashManager for testing (credentials NOT verified)");
let mock_manager = MockEcashManager::new(Box::new(self.storage.clone()));
return Ok(Arc::new(mock_manager)
as Arc<
@@ -344,36 +334,6 @@ impl GatewayTasksBuilder {
))
}
pub async fn build_lp_listener(
&mut self,
peer_registrator: Option<PeerRegistrator>,
active_clients_store: ActiveClientsStore,
) -> Result<lp_listener::LpListener, GatewayError> {
let handler_state = lp_listener::LpHandlerState {
ecash_verifier: self.ecash_manager().await?,
storage: self.storage.clone(),
local_lp_peer: LpLocalPeer::new(
self.identity_keypair.clone(),
self.x25519_keypair.clone(),
)
.with_kem_psq_key(self.kem_psq_keys.clone()),
metrics: self.metrics.clone(),
active_clients_store,
peer_registrator,
lp_config: self.config.lp,
outbound_mix_sender: self.mix_packet_sender.clone(),
session_states: Arc::new(dashmap::DashMap::new()),
forward_semaphore: Arc::new(Semaphore::new(
self.config.lp.debug.max_concurrent_forwards,
)),
};
Ok(lp_listener::LpListener::new(
handler_state,
self.shutdown_tracker.clone(),
))
}
fn build_network_requester(
&mut self,
topology_provider: Box<dyn TopologyProvider + Send + Sync>,
@@ -1,13 +1,13 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::node::lp_listener::ReceiverIndex;
use crate::node::wireguard::new_peer_registration::pending::{
PendingRegistration, PendingRegistrationData,
};
use crate::node::wireguard::{GatewayWireguardError, PeerRegistrator};
use defguard_wireguard_rs::host::Peer;
use defguard_wireguard_rs::key::Key;
use nym_lp::peer_config::LpReceiverIndex;
use nym_registration_common::{LpRegistrationResponse, WireguardRegistrationData};
use nym_wireguard::ip_pool::{allocated_ip_pair, IpPair};
use nym_wireguard_types::PeerPublicKey;
@@ -58,9 +58,10 @@ impl PeerRegistrator {
pub(super) async fn check_pending_lp_registration(
&self,
sender: ReceiverIndex,
receiver_index: LpReceiverIndex,
) -> Result<Option<LpRegistrationResponse>, GatewayWireguardError> {
let Some(pending_registration) = self.pending_registrations.check_lp(sender).await else {
let Some(pending_registration) = self.pending_registrations.check_lp(receiver_index).await
else {
return Ok(None);
};
@@ -104,7 +105,7 @@ impl PeerRegistrator {
pub(super) async fn process_fresh_initial_lp_registration(
&self,
sender: ReceiverIndex,
receiver_index: LpReceiverIndex,
remote_public: PeerPublicKey,
psk: Key,
) -> Result<LpRegistrationResponse, GatewayWireguardError> {
@@ -121,7 +122,7 @@ impl PeerRegistrator {
.lp
.write()
.await
.insert(sender, pending);
.insert(receiver_index, pending);
Ok(response)
}
@@ -11,7 +11,6 @@
//! 2. Finalisation request message is received, where credential has to be attached is verified.
//! Upon successful completion, pending registration is transformed into a properly inserted peer.
use crate::node::lp_listener::ReceiverIndex;
use crate::node::wireguard::new_peer_registration::pending::{
PendingRegistration, PendingRegistrations,
};
@@ -32,6 +31,7 @@ use nym_credentials_interface::{BandwidthCredential, CredentialSpendingData};
use nym_crypto::asymmetric::x25519;
use nym_gateway_requests::models::CredentialSpendingRequest;
use nym_gateway_storage::models::PersistedBandwidth;
use nym_lp::peer_config::LpReceiverIndex;
use nym_registration_common::dvpn::{
LpDvpnRegistrationFinalisation, LpDvpnRegistrationInitialRequest,
};
@@ -225,7 +225,7 @@ impl PeerRegistrator {
Ok(())
}
pub(crate) async fn on_initial_authenticator_request(
pub async fn on_initial_authenticator_request(
&mut self,
init_message: Box<dyn InitMessage + Send + Sync + 'static>,
protocol: Protocol,
@@ -262,7 +262,7 @@ impl PeerRegistrator {
.await
}
pub(crate) async fn on_final_authenticator_request(
pub async fn on_final_authenticator_request(
&mut self,
final_message: Box<dyn FinalMessage + Send + Sync + 'static>,
protocol: Protocol,
@@ -307,10 +307,10 @@ impl PeerRegistrator {
)
}
pub(crate) async fn on_initial_lp_request(
pub async fn on_initial_lp_request(
&self,
init_msg: LpDvpnRegistrationInitialRequest,
sender: ReceiverIndex,
receiver_index: LpReceiverIndex,
) -> Result<LpRegistrationResponse, GatewayWireguardError> {
let remote_public = init_msg.wg_public_key;
let psk = Key::new(init_msg.psk);
@@ -318,7 +318,9 @@ impl PeerRegistrator {
// 1. check if there's any pending registration already in progress,
// if so, return the same data again without additional processing,
// but update stored PSK
if let Some(pending_registration) = self.check_pending_lp_registration(sender).await? {
if let Some(pending_registration) =
self.check_pending_lp_registration(receiver_index).await?
{
self.update_peer_psk(remote_public, psk).await?;
return Ok(pending_registration);
}
@@ -332,19 +334,19 @@ impl PeerRegistrator {
}
// 3. process fresh registration request
self.process_fresh_initial_lp_registration(sender, remote_public, psk)
self.process_fresh_initial_lp_registration(receiver_index, remote_public, psk)
.await
}
pub(crate) async fn on_final_lp_request(
pub async fn on_final_lp_request(
&self,
final_msg: LpDvpnRegistrationFinalisation,
sender: ReceiverIndex,
receiver_index: LpReceiverIndex,
) -> Result<LpRegistrationResponse, GatewayWireguardError> {
// 1. check if there's any pending registration associated with this peer
let pending_data = self
.pending_registrations
.check_lp(sender)
.check_lp(receiver_index)
.await
.ok_or(GatewayWireguardError::RegistrationNotInProgress)?
.clone();
@@ -356,7 +358,7 @@ impl PeerRegistrator {
.await?;
// 3 remove pending registration
self.pending_registrations.remove_lp(sender).await;
self.pending_registrations.remove_lp(receiver_index).await;
// 4. construct and return the response
Ok(pending_data.to_registered_lp_response(self.upgrade_mode_enabled()))
@@ -1,7 +1,6 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::node::lp_listener::ReceiverIndex;
use crate::node::wireguard::new_peer_registration::helpers::{
build_final_authenticator_response, build_pending_authenticator_response,
};
@@ -9,6 +8,7 @@ use crate::node::wireguard::GatewayWireguardError;
use defguard_wireguard_rs::key::Key;
use nym_authenticator_requests::AuthenticatorVersion;
use nym_crypto::asymmetric::x25519;
use nym_lp::peer_config::LpReceiverIndex;
use nym_registration_common::{LpRegistrationResponse, WireguardRegistrationData};
use nym_sdk::mixnet::Recipient;
use nym_wireguard::ip_pool::IpPair;
@@ -116,9 +116,8 @@ pub(crate) struct PendingRegistrations {
pub(crate) authenticator: Arc<RwLock<HashMap<PeerPublicKey, PendingRegistration>>>,
/// Registrations in progress received from the LP Listener via the
/// [`crate::node::lp_listener::handler::LpConnectionHandler`] and handle through
/// [`crate::node::lp_listener::registration::LpHandlerState`]
pub(crate) lp: Arc<RwLock<HashMap<ReceiverIndex, PendingRegistration>>>,
/// `LpConnectionHandler` and handle through `LpHandlerState`
pub(crate) lp: Arc<RwLock<HashMap<LpReceiverIndex, PendingRegistration>>>,
}
impl PendingRegistrations {
@@ -133,13 +132,13 @@ impl PendingRegistrations {
self.authenticator.write().await.remove(peer);
}
pub(crate) async fn remove_lp(&self, receiver_index: ReceiverIndex) {
pub(crate) async fn remove_lp(&self, receiver_index: LpReceiverIndex) {
self.lp.write().await.remove(&receiver_index);
}
pub(crate) async fn check_lp(
&self,
receiver_index: ReceiverIndex,
receiver_index: LpReceiverIndex,
) -> Option<PendingRegistration> {
self.lp.read().await.get(&receiver_index).cloned()
}
+4 -2
View File
@@ -22,11 +22,13 @@ nym-credential-verification = { path = "../common/credential-verification" }
nym-credentials-interface = { path = "../common/credentials-interface" }
nym-test-utils = { path = "../common/test-utils" }
nym-registration-client = { path = "../nym-registration-client" }
nym-lp-transport = { path = "../common/nym-lp-transport", features = ["io-mocks"] }
nym-gateway = { path = "../gateway" }
nym-node = { path = "../nym-node" }
nym-lp = { path = "../common/nym-lp", features = ["mock"] }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] }
tracing = { workspace = true }
futures = { workspace = true }
nym-kkt = { workspace = true }
nym-kkt-ciphersuite = { workspace = true }
[lints]
workspace = true

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