Compare commits

..

4 Commits

Author SHA1 Message Date
benedettadavico 1fa1b67c8d debug 2026-03-09 09:28:51 +01:00
Jędrzej Stuczyński 05b6f5e282 removed redundant LP states (#6509) 2026-03-03 13:58:47 +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
177 changed files with 10765 additions and 17023 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 -136
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,16 @@ 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",
"rand 0.9.2",
"rand_chacha 0.9.0",
"strum",
@@ -6813,11 +6847,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"
@@ -6836,25 +6880,14 @@ 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 +6919,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 +6931,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 +7076,7 @@ dependencies = [
[[package]]
name = "nym-network-requester"
version = "1.1.72"
version = "1.1.73"
dependencies = [
"addr",
"anyhow",
@@ -7105,13 +7126,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 +7148,7 @@ dependencies = [
"criterion",
"csv",
"cupid",
"dashmap",
"futures",
"hex",
"hkdf",
@@ -7145,6 +7168,7 @@ dependencies = [
"nym-http-api-common",
"nym-ip-packet-router",
"nym-kkt",
"nym-lp",
"nym-metrics",
"nym-mixnet-client",
"nym-network-requester",
@@ -7154,6 +7178,7 @@ dependencies = [
"nym-noise-keys",
"nym-nonexhaustive-delayqueue",
"nym-pemstore",
"nym-registration-common",
"nym-sphinx-acknowledgements",
"nym-sphinx-addressing",
"nym-sphinx-forwarding",
@@ -7163,6 +7188,7 @@ dependencies = [
"nym-sphinx-types",
"nym-statistics-common",
"nym-task",
"nym-test-utils",
"nym-topology",
"nym-types",
"nym-validator-client",
@@ -7172,7 +7198,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 +7246,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",
@@ -7237,7 +7263,7 @@ dependencies = [
[[package]]
name = "nym-node-status-agent"
version = "1.1.2"
version = "1.1.2-test"
dependencies = [
"anyhow",
"clap",
@@ -7256,7 +7282,7 @@ dependencies = [
[[package]]
name = "nym-node-status-api"
version = "4.1.0"
version = "4.1.0-test"
dependencies = [
"ammonia",
"anyhow",
@@ -7445,12 +7471,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 +7517,7 @@ dependencies = [
name = "nym-registration-client"
version = "1.20.4"
dependencies = [
"bincode",
"bytes",
"futures",
"nym-authenticator-client",
@@ -7504,19 +7526,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 +7672,7 @@ dependencies = [
[[package]]
name = "nym-socks5-client"
version = "1.1.71"
version = "1.1.72"
dependencies = [
"bs58",
"clap",
@@ -8018,6 +8040,7 @@ dependencies = [
"futures",
"nym-bin-common",
"rand_chacha 0.3.1",
"rand_chacha 0.9.0",
"tokio",
"tracing",
]
@@ -8251,31 +8274,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 +8333,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 +8469,7 @@ dependencies = [
[[package]]
name = "nymvisor"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"anyhow",
"bytes",
@@ -8829,6 +8825,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 +14096,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",
+18 -5
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 = [
@@ -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,8 @@ 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-kkt = { version = "0.1.0", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.20.4", path = "common/nym-kkt-ciphersuite" }
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,7 +20,7 @@ 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,
@@ -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,
@@ -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,
@@ -17,7 +17,7 @@ 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::{
@@ -117,7 +117,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 +144,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 +302,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 +323,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(
@@ -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"]
+103
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,21 @@ mod tests {
fn assert_zeroize<T: Zeroize>() {}
#[test]
fn test_key_conversion() {
let dalek_kp = super::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 =
libcrux_psq::handshake::types::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;
+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();
+8 -13
View File
@@ -7,35 +7,30 @@ 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 = { path = "../crypto", features = ["hashing"] }
nym-kkt-ciphersuite = { workspace = true, features = ["digests"] }
nym-kkt-context = { path = "../nym-kkt-context" }
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 }
[[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);
+188
View File
@@ -0,0 +1,188 @@
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";
#[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,
b"kkt-carrier-v1",
&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,
b"kkt-carrier-v1",
&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(())
}
}
+36 -7
View File
@@ -3,18 +3,18 @@
use crate::context::KKTStatus;
use nym_kkt_ciphersuite::error::KKTCiphersuiteError;
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(transparent)]
MaskedByteError(#[from] MaskedByteError),
#[error("KEM mapping failure: {}", info)]
KEMMapping { info: &'static str },
@@ -33,9 +33,6 @@ pub enum KKTError {
#[error("KKT Responder Flagged Error: {}", status)]
ResponderFlaggedError { status: KKTStatus },
#[error("KKT Message Count Limit Reached")]
MessageCountLimitReached,
#[error("PSQ KEM Error: {}", info)]
KEMError { info: &'static str },
@@ -48,8 +45,40 @@ pub enum KKTError {
#[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(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))
}
}
+188
View File
@@ -0,0 +1,188 @@
// 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,
match payload {
Some(payload_vec) => payload_vec,
None => Vec::with_capacity(0),
},
))
}
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());
}
}
+184 -452
View File
@@ -1,498 +1,230 @@
// 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 nym_kkt_ciphersuite::{Ciphersuite, HashFunction, HashLength, KEM, SignatureScheme};
use rand09::RngCore;
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,
};
#[test]
fn test_kkt_psq_e2e_clear() {
fn test_kkt_psq_e2e_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));
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
] {
let ciphersuite = Ciphersuite::resolve_ciphersuite(
kem,
hash_function,
crate::ciphersuite::SignatureScheme::Ed25519,
None,
)
.unwrap();
// generate kem public keys
let (responder_kem_public_key, initiator_kem_public_key) = match kem {
KEM::MlKem768 => (
EncapsulationKey::MlKem768(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
EncapsulationKey::MlKem768(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
),
KEM::XWing => (
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
EncapsulationKey::XWing(generate_keypair_libcrux(&mut rng, kem).unwrap().1),
),
KEM::X25519 => (
EncapsulationKey::X25519(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
EncapsulationKey::X25519(
generate_keypair_libcrux(&mut rng, kem).unwrap().1,
),
),
KEM::McEliece => (
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
EncapsulationKey::McEliece(generate_keypair_mceliece(&mut rng).1),
),
};
let i_kem_key_bytes = initiator_kem_public_key.encode();
let r_kem_key_bytes = responder_kem_public_key.encode();
let i_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&i_kem_key_bytes,
);
let r_dir_hash = hash_encapsulation_key(
&ciphersuite.hash_function(),
ciphersuite.hash_len(),
&r_kem_key_bytes,
);
// Anonymous Initiator, OneWay
{
let (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,
)
.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, 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)
}
}
}
}
#[test]
fn test_kkt_psq_e2e_encrypted() {
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);
// generate responder x25519 keys
let responder_x25519_keypair = generate_keypair_x25519(&mut rng);
let responder_x25519_keypair = generate_lp_keypair_x25519(&mut rng);
for kem in [KEM::MlKem768, KEM::XWing, KEM::X25519, KEM::McEliece] {
for hash_function in [
HashFunction::Blake3,
HashFunction::SHA256,
HashFunction::Shake128,
HashFunction::Shake256,
] {
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 initiator_mlkem_keypair = generate_keypair_mlkem(&mut rng);
let initiator_mceliece_keypair = generate_keypair_mceliece(&mut rng);
let _i_dir_hash_mlkem = hash_encapsulation_key(
hash_function,
HashLength::Default.value(),
initiator_mlkem_keypair.public_key().as_slice(),
);
let _i_dir_hash_mceliece = hash_encapsulation_key(
hash_function,
HashLength::Default.value(),
initiator_mceliece_keypair.pk.as_ref(),
);
let responder = KKTResponder::new(
&responder_x25519_keypair,
&responder_kem,
&[
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();
// encryption - initiator frame
let (i_session_secret, i_bytes) = encrypt_initial_kkt_frame(
&mut rng,
responder_x25519_keypair.public_key(),
&i_frame,
)
let result = initiator
.process_response(processed_request.response, 0)
.unwrap();
// decryption - initiator frame
assert_eq!(
result.encapsulation_key.as_bytes(),
responder_kem.ml_kem768_encapsulation_key().as_slice(),
)
}
// Mutual - MlKem
{
let ciphersuite = Ciphersuite::resolve_ciphersuite(
KEM::MlKem768,
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_mlkem,
1u8,
Some(payload.clone()),
)
.unwrap();
let (r_session_secret, i_frame_r, i_context_r) =
decrypt_initial_kkt_frame(responder_x25519_keypair.private_key(), &i_bytes)
.unwrap();
let processed_request = responder.process_request(request, payload.len()).unwrap();
let (r_context, _) =
responder_ingest_message(&i_context_r, None, None, &i_frame_r).unwrap();
assert_eq!(processed_request.request_payload, payload);
let r_frame = responder_process(
&r_context,
i_frame_r.session_id(),
responder_ed25519_keypair.private_key(),
&responder_kem_public_key,
)
// if we keep unverified keys, this should change
assert!(processed_request.remote_encapsulation_key.is_none());
let processed_response = initiator
.process_response(processed_request.response, 0)
.unwrap();
// encryption - responder frame
let r_bytes =
encrypt_kkt_frame(&mut rng, &r_session_secret, &r_frame, KKT_RESPONSE_AAD)
.unwrap();
assert_eq!(
processed_response.encapsulation_key.as_bytes(),
responder_kem.ml_kem768_encapsulation_key().as_slice(),
)
}
// decryption - responder frame
// 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_frame_r, i_context_r) =
decrypt_kkt_frame(&i_session_secret, &r_bytes, KKT_RESPONSE_AAD).unwrap();
let processed_request = responder.process_request(request, payload.len()).unwrap();
assert_eq!(processed_request.request_payload, payload);
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_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,
)
assert_eq!(
processed_response.encapsulation_key.as_bytes(),
responder_kem.mc_eliece_encapsulation_key().as_ref()
)
}
// Mutual - MlKem
{
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 processed_request = responder.process_request(request, payload.len()).unwrap();
assert_eq!(processed_request.request_payload, payload);
// if we keep unverified keys, this should change
assert!(processed_request.remote_encapsulation_key.is_none());
let processed_response = initiator
.process_response(processed_request.response, 0)
.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(x25519::PUBLIC_KEY_LENGTH + MASKED_BYTE_LEN);
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() != x25519::PUBLIC_KEY_LENGTH + MASKED_BYTE_LEN {
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>,
}
+257
View File
@@ -0,0 +1,257 @@
// 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 and XWing.
///
/// This protocol is safe if it runs under a trusted secure channel.
///
/// Bandwidth costs:
/// Request (MlKem768): 1216 bytes
/// Response (MlKem768): 1088 bytes
/// Request (XWing): 1248 bytes
/// Response (XWing): 1120 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 and XWing)
///
/// 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 MlKem768
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()
)
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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, SignatureScheme};
/// 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,
/// 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,
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,
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(),
})
}
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 => {
// So we can either fetch the remote hash here using some async call to the directory,
// which might make registration hang or accept the sent key then verify later.
// If we choose to not accept, the response's status will be KKTStatus::UnverifiedKEMKey.
// The response would still contain the responder's encapsulation key.
responder_ingest_message(None, 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::with_capacity(0);
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
// we could still keep it here and then verify later...
// let received_encapsulation_key = EncapsulationKey::decode(
// own_context.ciphersuite().kem(),
// remote_frame.body_ref(),
// )?;
// Ok((own_context, Some(received_encapsulation_key)))
//
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
}
}
+9 -23
View File
@@ -7,50 +7,36 @@ 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-crypto = { path = "../crypto", features = ["hashing"] }
nym-kkt = { path = "../nym-kkt" }
nym-lp-common = { path = "../nym-lp-common" }
nym-lp-transport = { path = "../nym-lp-transport" }
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"] }
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
+6 -6
View File
@@ -18,7 +18,7 @@ pub const DEFAULT_PSK_TTL_SECS: u64 = 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpConfig {
/// KEM algorithm for PSQ key encapsulation.
/// X25519 = classical (testing), MlKem768 = PQ, XWing = hybrid.
/// Supported KEMs: MlKem768, McEliece
#[serde(with = "kem_serde")]
pub kem_algorithm: KEM,
@@ -32,7 +32,7 @@ pub struct LpConfig {
impl Default for LpConfig {
fn default() -> Self {
Self {
kem_algorithm: KEM::X25519,
kem_algorithm: KEM::MlKem768,
psk_ttl_secs: DEFAULT_PSK_TTL_SECS,
enable_kkt: true,
}
@@ -55,10 +55,10 @@ mod kem_serde {
S: Serializer,
{
match kem {
KEM::X25519 => "X25519",
KEM::MlKem768 => "MlKem768",
KEM::XWing => "XWing",
KEM::McEliece => "McEliece",
KEM::X25519 => return Err(serde::ser::Error::custom("Unsupported KEM: X25519")),
KEM::XWing => return Err(serde::ser::Error::custom("Unsupported KEM: XWing")),
}
.serialize(serializer)
}
@@ -69,10 +69,10 @@ mod kem_serde {
{
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),
"X25519" => Err(serde::de::Error::custom("Unsupported KEM: X25519")),
"XWing" => Err(serde::de::Error::custom("Unsupported KEM: XWing")),
_ => Err(serde::de::Error::custom(format!("Unknown KEM: {}", s))),
}
}
+64 -50
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),
/// 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,
// /// 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,
#[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");
}
}
}
+83 -311
View File
@@ -2,32 +2,40 @@
// 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 nym_kkt_ciphersuite::{
Ciphersuite, HashFunction, HashLength, KEM, KEMKeyDigests, SignatureScheme,
};
#[cfg(any(feature = "mock", test))]
pub use replay::{ReceivingKeyCounterValidator, ReplayError};
pub use session::LpSession;
pub use session_manager::SessionManager;
pub use state_machine::LpStateMachine;
pub const NOISE_PATTERN: &str = "Noise_XKpsk3_25519_ChaChaPoly_SHA256";
pub const NOISE_PSK_INDEX: u8 = 3;
#[cfg(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 {
@@ -37,118 +45,103 @@ pub struct SessionsMock {
#[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),
};
SessionsMock {
initiator: LpSession::new(
session_id,
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,
),
)
.unwrap(),
responder: LpSession::new(
session_id,
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
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);
let sessions = SessionsMock::mock_post_handshake(KEM::default());
(sessions.initiator, sessions.responder)
}
@@ -156,224 +149,3 @@ pub fn sessions_for_tests() -> (LpSession, LpSession) {
pub fn mock_session_for_test() -> LpSession {
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();
+63 -92
View File
@@ -1,102 +1,77 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
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_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> {
pub fn x25519(&self) -> &Arc<DHKeyPair> {
&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
.as_ref()
.map(|kp| kp.public_key())
.ok_or(LpError::ResponderWithMissingKEMKey)
}
/// 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", &self.x25519.pk)
.field("kem_keypairs", &self.kem_keypairs)
.finish()
}
}
@@ -104,45 +79,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
}
@@ -168,30 +129,40 @@ impl LpRemotePeer {
}
}
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))
}
+482
View File
@@ -0,0 +1,482 @@
// 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 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: [u8; LP_PEER_CONFIG_SIZE] = [0u8; LP_PEER_CONFIG_SIZE];
output_bytes[0..4].copy_from_slice(self.pack_config().as_slice());
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 {
Err(LpError::DeserializationError(format!(
"Invalid Lp Config Length ({}), expected ({})",
bytes.len(),
LP_PEER_CONFIG_SIZE
)))
} else {
let (hop_id, is_exit, node_initiator, censorship_resistance) =
Self::unpack_first_byte(bytes[0]);
let mut filler: [u8; FILLER_LEN] = [0u8; FILLER_LEN];
filler.copy_from_slice(&bytes[CONFIG_LEN..CONFIG_LEN + FILLER_LEN]);
let mut seed: [u8; SEED_LEN] = [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 {}
+300 -351
View File
@@ -1,391 +1,340 @@
// 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, LpSession};
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>
pub struct PSQHandshakeStateInitiator<'a, S> {
pub(super) inner_state: PSQHandshakeState<'a, S>,
pub(super) initiator_data: InitiatorData,
}
pub(crate) fn build_psq_principal<R>(
rng: R,
version: u8,
ciphersuite: InitiatorCiphersuite,
) -> Result<RegistrationInitiator<R>, LpError>
where
S: LpTransport + Unpin,
R: rand09::CryptoRng,
{
/// 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()?;
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 }),
};
// 1. Generate and send ClientHelloData with fresh salt and both public keys
let timestamp = current_timestamp()?;
PrincipalBuilder::new(rng)
.outer_aad(outer_aad)
.inner_aad(inner_aad)
.context(ctx)
.build_registration_initiator(ciphersuite)
.map_err(|inner| LpError::PSQInitiatorBuilderFailure { inner })
}
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)
}
/// 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(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,
{
/// 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() {
return Err(LpError::kkt_psq_handshake(
"noise handshake not finished after msg3",
));
}
Ok(())
Ok(resp.into())
}
/// 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,
)),
}
}
async fn complete_as_initiator_inner(
&mut self,
) -> Result<LpSession, IntermediateHandshakeFailure>
pub async fn complete_handshake(self) -> Result<LpSession, LpError>
where
S: LpTransport + Unpin,
S: LpHandshakeChannel + 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"),
));
};
let mut rng = rand09::rngs::StdRng::from_os_rng();
self.complete_handshake_with_rng(&mut rng).await
}
// 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;
pub async fn complete_handshake_with_rng<R>(mut self, rng: &mut R) -> Result<LpSession, LpError>
where
S: LpHandshakeChannel + Unpin,
R: rand09::CryptoRng,
{
let ciphersuite = self.inner_state.local_peer.ciphersuite();
let kem = ciphersuite.kem();
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 lp_peer_config = LpPeerConfig::new_client_to_entry(rng, false);
// 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;
// 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) = 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())),
)?;
// derive the receiver index from the request
// let receiver_index = kkt_request
// 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,
})?;
self.send_kkt_request(kkt_request).await?;
// 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,
})?;
// 3. receive and process KKT response
let raw_response = self.receive_kkt_response().await?;
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,
})?;
// the responder does not send a payload
let response = initiator.process_response(raw_response, 0)?;
// 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,
});
// 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 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(
"handshake not finished after receiving psq response",
));
}
// 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,
});
}
let initiator_authenticator = Authenticator::Dh(self.inner_state.local_peer.x25519().pk);
// 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 receiver_index =
lp_peer_config.derive_receiver_index(&initiator_authenticator, &encapsulation_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.initiator_data.remote_peer.x25519_public,
responder_pq_pk: Some(encapsulation_key),
};
// 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),
}
let psq_session = psq_initiator.into_session()?;
LpSession::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};
#[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();
// 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);
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,
&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(())
}
}
+290 -258
View File
@@ -1,172 +1,108 @@
// 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, SignatureScheme};
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>,
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>,
}
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],
}
}
}
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) -> PSQHandshakeStateInitiator<'a, S> {
PSQHandshakeStateInitiator {
initiator_data,
inner_state: self,
}
err.source
}
/// 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 +110,263 @@ 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 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));
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();
// 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 (init, resp) = mock_peers();
let resp_remote = resp.as_remote();
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();
// as initiator
let mut handshake_init = PSQHandshakeState::new(&mut conn_init, ciphersuite, init)
.with_protocol_version(1)
.with_remote_peer(resp_remote);
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 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(())
}
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,
&supported_hash,
&supported_sigs,
&[protocol_version],
)
.unwrap();
// 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();
// SETUP END
let ciphersuite = Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
);
let (_, resp) = mock_peers();
let lp_peer_config = LpPeerConfig::new_client_to_entry(&mut rng, false);
// as initiator
let mut handshake_resp = PSQHandshakeState::new(&mut conn_resp, ciphersuite, resp);
// 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();
// 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 processed_req = kkt_responder
.process_request(request, LP_PEER_CONFIG_SIZE)
.unwrap();
#[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);
let response = initiator
.process_response(processed_req.response, 0)
.unwrap();
let encapsulation_key = response.encapsulation_key;
let too_old = current_time - DEFAULT_TIMESTAMP_TOLERANCE - Duration::from_secs(1);
let too_recent = current_time + DEFAULT_TIMESTAMP_TOLERANCE + Duration::from_secs(1);
let mut payload_buf_responder = vec![0u8; 4096];
let mut payload_buf_initiator = vec![0u8; 4096];
let ciphersuite = Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
);
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();
// TOO OLD
let mut conn_init = MockIOStream::default();
let mut conn_resp = conn_init.try_get_remote_handle();
let (init, resp) = mock_peers();
let responder_ciphersuite = responder::build_psq_ciphersuite(&resp, kem).unwrap();
let mut responder = responder::build_psq_principal(
rand09::rng(),
protocol_version,
responder_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());
// 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());
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"));
// Read first message
let (_, _) = responder
.read_message(&buf, &mut payload_buf_responder)
.unwrap();
// TOO RECENT
let mut conn_init = MockIOStream::default();
let mut conn_resp = conn_init.try_get_remote_handle();
let (init, resp) = mock_peers();
// 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 mut handshake_resp = PSQHandshakeState::new(&mut conn_resp, ciphersuite, resp);
let client_hello_too_recent = init.build_client_hello_data(too_recent.as_secs());
// Respond
let mut buf = [0u8; PSQ_MSG2_SIZE];
let len_r = responder.write_message(&[], &mut buf).unwrap();
assert_eq!(len_r, buf.len());
conn_init
.send_packet(client_hello_too_recent.into_lp_packet(1), None)
.await?;
let err = handshake_resp.receive_client_hello().await.unwrap_err();
// Finalize on registration initiator
let (len_i_deserialized, _) = initiator
.read_message(&buf, &mut payload_buf_initiator)
.unwrap();
assert!(err.to_string().contains("too future"));
Ok(())
// 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);
}
}
}
+312 -422
View File
@@ -1,461 +1,351 @@
// 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, LpSession};
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,
{
/// Attempt to receive a KKT request from a one-way client
async fn receive_one_way_kkt_request(&mut self) -> Result<KKTRequest, LpError> {
let packet_len = KKTRequest::size_excluding_payload(
KKTMode::OneWay,
self.inner_state.local_peer.ciphersuite.kem(),
) + LP_PEER_CONFIG_SIZE;
let req = self
.inner_state
.connection
.receive_handshake_message::<handshake_message::KKTRequest>(packet_len)
.await?;
Ok(req.into())
}
/// 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.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<LpSession, 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<LpSession, LpError>
where
S: LpHandshakeChannel + Unpin,
R: rand09::CryptoRng,
{
// 1. receive and process KKTRequest
let kkt_request = self.receive_one_way_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;
// 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),
};
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()?;
LpSession::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(())
}
}
+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]
+190 -607
View File
@@ -4,218 +4,177 @@
//! Session management for the Lewes Protocol.
//!
//! This module implements session management functionality, including replay protection
//! and Noise protocol state handling.
use crate::codec::OuterAeadKey;
use crate::message::EncryptedDataPayload;
// noiserm
use crate::noise_protocol::{NoiseError, NoiseProtocol, ReadResult};
use crate::packet::LpHeader;
use crate::codec::{decrypt_lp_packet, encrypt_lp_packet};
use crate::packet::{EncryptedLpPacket, LpHeader, LpMessage, LpPacket};
use crate::peer::{LpLocalPeer, LpRemotePeer};
use crate::psk::derive_subsession_psk;
use crate::psq::PSQHandshakeState;
use crate::replay::ReceivingKeyCounterValidator;
use crate::{LpError, LpMessage, LpPacket};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt::ciphersuite::{Ciphersuite, HashFunction, HashLength, KEM, SignatureScheme};
use nym_lp_transport::traits::LpTransport;
use parking_lot::Mutex;
use snow::Builder;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::peer_config::LpReceiverIndex;
use crate::psq::{
InitiatorData, PSQHandshakeState, PSQHandshakeStateInitiator, PSQHandshakeStateResponder,
ResponderData,
};
use crate::replay::validator::PacketCount;
use crate::transport::LpHandshakeChannel;
use crate::{LpError, replay::ReceivingKeyCounterValidator};
use libcrux_psq::handshake::types::{Authenticator, DHPublicKey};
use libcrux_psq::session::{Session, SessionBinding};
use nym_kkt::keys::EncapsulationKey;
use std::fmt::{Debug, Formatter};
/// PQ shared secret wrapper with automatic memory zeroization.
/// Ensures K_pq is cleared from memory when dropped.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct PqSharedSecret([u8; 32]);
impl PqSharedSecret {
pub fn new(secret: [u8; 32]) -> Self {
Self(secret)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl std::fmt::Debug for PqSharedSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PqSharedSecret")
.field("secret", &"<redacted>")
.finish()
}
}
pub type SessionId = [u8; 32];
/// A session in the Lewes Protocol, handling connection state with Noise.
///
/// Sessions manage connection state, including LP replay protection.
/// Each session has a unique receiving index and sending index for connection identification.
#[derive(Debug)]
pub struct LpSession {
/// Id of the established session
session_id: u32,
/// The underlying established session
psq_session: Session,
/// The public key material bound to the underlying session. Used for serialisation.
session_binding: PersistentSessionBinding,
/// The current active transport channel
// In the future it might get split between UDP and TCP transports
active_transport: libcrux_psq::session::Transport,
/// Look-up index established during the initial KKT exchange
receiver_index: LpReceiverIndex,
/// Negotiated protocol version from handshake.
/// Set during handshake completion from the ClientHello/ServerHello packet header.
/// Used for future version negotiation and compatibility checks.
version: u8,
/// Outer AEAD key for packet encryption (derived from PSK after PSQ handshake).
outer_aead_key: OuterAeadKey,
/// Representation of a local Lewes Protocol peer
/// encapsulating all the known information and keys.
local_peer: LpLocalPeer,
/// Representation of a remote Lewes Protocol peer
/// encapsulating all the known information and keys.
remote_peer: LpRemotePeer,
// TODO: ALL BELOW maybe not needed after all?
/// Raw PQ shared secret (K_pq) from PSQ KEM encapsulation/decapsulation.
/// Stored after PSQ handshake completes for subsession PSK derivation.
pq_shared_secret: PqSharedSecret,
/// Noise protocol state machine
noise_state: NoiseProtocol,
protocol_version: u8,
/// Counter for outgoing packets
sending_counter: u64,
/// Validator for incoming packet counters to prevent replay attacks
receiving_counter: ReceivingKeyCounterValidator,
}
/// Monotonically increasing counter for subsession indices.
/// Each subsession gets a unique index to ensure unique PSK derivation.
/// Uses u64 to make overflow practically impossible (~585k years at 1M/sec).
subsession_counter: u64,
/// Wraps public key material that is bound to a session.
#[derive(Clone)]
pub struct PersistentSessionBinding {
/// The initiator's authenticator value, i.e. a long-term DH public value or signature verification key.
pub initiator_authenticator: Authenticator,
/// True if this session has been demoted to read-only mode.
/// Demoted sessions can still receive/decrypt but cannot send/encrypt.
read_only: bool,
/// The responder's long term DH public value.
pub responder_ecdh_pk: DHPublicKey,
/// ID of the successor session that replaced this one.
/// Set when demote() is called.
successor_session_id: Option<u32>,
/// The responder's long term PQ-KEM public key (if any).
pub responder_pq_pk: Option<EncapsulationKey>,
}
impl Debug for PersistentSessionBinding {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PersistentSessionBinding")
.field("initiator_authenticator", &"<initiator_authenticator>")
.field("responder_ecdh_pk", &self.responder_ecdh_pk)
.field("responder_pq_pk", &self.responder_pq_pk)
.finish()
}
}
impl<'a> From<&'a PersistentSessionBinding> for SessionBinding<'a> {
fn from(value: &'a PersistentSessionBinding) -> Self {
SessionBinding {
initiator_authenticator: &value.initiator_authenticator,
responder_ecdh_pk: &value.responder_ecdh_pk,
responder_pq_pk: value
.responder_pq_pk
.as_ref()
.map(|k| k.as_pq_encapsulation_key()),
}
}
}
impl Debug for LpSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LpSession")
.field("session_id", &self.psq_session.identifier())
.field("session_binding", &self.session_binding)
.field("active_transport_id", &self.active_transport.identifier())
.field("protocol_version", &self.protocol_version)
.field("sending_counter", &self.sending_counter)
.field("receiving_counter", &self.receiving_counter)
.finish()
}
}
impl LpSession {
/// Creates a new session after completed KTT/PSQ exchange
///
/// # Arguments
///
/// * `session_id` - Session identifier
/// * `version` - Protocol version to attach in all `LpPacket`s
/// * `outer_aead_key` - Outer AEAD key for packet encryption
/// * `local_peer` - This side's LP peer's keys
/// * `remote_peer` - The remote's LP peer's keys
/// * `pq_shared_secret` - Raw PQ shared secret (K_pq) from PSQ KEM encapsulation/decapsulation.
/// * `noise_state` - Noise protocol state machine
pub fn new(
session_id: u32,
version: u8,
outer_aead_key: OuterAeadKey,
local_peer: LpLocalPeer,
remote_peer: LpRemotePeer,
pq_shared_secret: PqSharedSecret,
noise_state: NoiseProtocol,
) -> Self {
LpSession {
session_id,
version,
outer_aead_key,
local_peer,
remote_peer,
pq_shared_secret,
noise_state,
mut psq_session: Session,
session_binding: PersistentSessionBinding,
receiver_index: LpReceiverIndex,
protocol_version: u8,
) -> Result<Self, LpError> {
// attempt to derive initial transport
let transport = psq_session
.transport_channel()
.map_err(|inner| LpError::TransportDerivationFailure { inner })?;
Ok(LpSession {
psq_session,
session_binding,
active_transport: transport,
receiver_index,
protocol_version,
sending_counter: 0,
receiving_counter: Default::default(),
subsession_counter: 0,
read_only: false,
successor_session_id: None,
}
}
/// Create an instance of `Ciphersuite` using hardcoded defaults.
/// This is a temporary workaround until values can be properly inferred
/// from reported version
pub fn default_ciphersuite() -> Ciphersuite {
Ciphersuite::new(
KEM::X25519,
HashFunction::Blake3,
SignatureScheme::Ed25519,
HashLength::Default,
)
})
}
/// Helper function to create `PSQHandshakeState` for the handshake initiator
pub fn complete_as_initiator<S>(
pub fn psq_handshake_initiator<S>(
connection: &'_ mut S,
ciphersuite: Ciphersuite,
local_peer: LpLocalPeer,
remote_peer: LpRemotePeer,
remote_protocol_version: u8,
) -> PSQHandshakeState<'_, S>
) -> PSQHandshakeStateInitiator<'_, S>
where
S: LpTransport + Unpin,
S: LpHandshakeChannel + Unpin,
{
PSQHandshakeState::new(connection, ciphersuite, local_peer)
.with_protocol_version(remote_protocol_version)
.with_remote_peer(remote_peer)
PSQHandshakeState::new(connection, local_peer)
.as_initiator(InitiatorData::new(remote_protocol_version, remote_peer))
}
/// Helper function to create `PSQHandshakeState` for the handshake responder
pub fn psq_handshake_responder<S>(
connection: &'_ mut S,
ciphersuite: Ciphersuite,
local_peer: LpLocalPeer,
) -> PSQHandshakeState<'_, S>
) -> PSQHandshakeStateResponder<'_, S>
where
S: LpTransport + Unpin,
S: LpHandshakeChannel + Unpin,
{
PSQHandshakeState::new(connection, ciphersuite, local_peer)
PSQHandshakeState::new(connection, local_peer).as_responder(ResponderData::default())
}
pub fn id(&self) -> u32 {
self.session_id
pub fn session_binding(&self) -> &PersistentSessionBinding {
&self.session_binding
}
pub fn active_transport(&mut self) -> &mut libcrux_psq::session::Transport {
&mut self.active_transport
}
pub fn session_identifier(&self) -> &[u8; 32] {
self.psq_session.identifier()
}
pub fn receiver_index(&self) -> LpReceiverIndex {
self.receiver_index
}
/// Returns the negotiated protocol version from the handshake.
///
/// Set during `LpSession` creation after sending / receiving `ClientHelloData`
pub fn negotiated_version(&self) -> u8 {
self.version
}
/// Returns the local X25519 public key.
///
/// This is used for KKT protocol when the responder needs to send their
/// KEM public key in the KKT response.
pub fn local_x25519_public(&self) -> x25519::PublicKey {
*self.local_peer.x25519.public_key()
}
/// Returns the remote ed25519 public key.
pub fn remote_ed25519_public(&self) -> ed25519::PublicKey {
self.remote_peer.ed25519_public
}
/// Returns the remote X25519 public key.
///
/// Used for tie-breaking in simultaneous subsession initiation.
/// Lower key loses and becomes responder.
pub fn remote_x25519_public(&self) -> &x25519::PublicKey {
&self.remote_peer.x25519_public
}
/// Returns the outer AEAD key for packet encryption/decryption.
pub fn outer_aead_key(&self) -> &OuterAeadKey {
&self.outer_aead_key
self.protocol_version
}
pub fn next_packet(&mut self, message: LpMessage) -> Result<LpPacket, LpError> {
let counter = self.next_counter();
let header = LpHeader::new(self.id(), counter, self.version);
let header = LpHeader::new(self.receiver_index(), counter, self.protocol_version);
let packet = LpPacket::new(header, message);
Ok(packet)
}
@@ -274,508 +233,132 @@ impl LpSession {
/// A tuple containing:
/// * The next expected counter value for incoming packets
/// * The total number of received packets
pub fn current_packet_cnt(&self) -> (u64, u64) {
pub fn current_packet_cnt(&self) -> PacketCount {
self.receiving_counter.current_packet_cnt()
}
/// Returns the PQ shared secret (K_pq).
///
/// This is the raw KEM output from PSQ before Blake3 KDF combination.
/// Used for deriving subsession PSKs to maintain PQ protection.
pub fn pq_shared_secret(&self) -> &PqSharedSecret {
&self.pq_shared_secret
}
/// Gets the next subsession index and increments the counter.
///
/// Each subsession requires a unique index to ensure unique PSK derivation.
/// The index is monotonically increasing per session.
pub fn next_subsession_index(&mut self) -> u64 {
let next = self.subsession_counter;
self.subsession_counter += 1;
next
}
/// Returns true if this session is in read-only mode.
///
/// Read-only sessions have been demoted after a subsession was promoted.
/// They can still decrypt incoming messages but cannot encrypt outgoing ones.
pub fn is_read_only(&self) -> bool {
self.read_only
}
/// Demotes this session to read-only mode after a subsession replaces it.
///
/// After demotion:
/// - `encrypt_data()` will return `NoiseError::SessionReadOnly`
/// - `decrypt_data()` still works (to drain in-flight messages)
/// - Session should be cleaned up after TTL expires
///
/// # Arguments
/// * `successor_idx` - The receiver index of the session that replaced this one
pub fn demote(&mut self, successor_idx: u32) {
self.successor_session_id = Some(successor_idx);
self.read_only = true;
}
/// Returns the successor session ID if this session was demoted.
pub fn successor_session_id(&self) -> Option<u32> {
self.successor_session_id
}
/// Encrypts application data payload using the established Noise transport session.
/// Encrypts a produced application using the established transport session
/// and produce an `EncryptedLpPacket`
///
/// # Arguments
///
/// * `payload` - The application data to encrypt.
/// * `data` - plaintext data to encrypt
///
/// # Returns
///
/// * `Ok(Vec<u8>)` containing the encrypted Noise message ciphertext.
/// * `Err(NoiseError)` if the session is not in transport mode or encryption fails.
pub fn encrypt_data(&mut self, payload: &[u8]) -> Result<LpMessage, NoiseError> {
// Check if session is read-only (demoted)
if self.read_only {
return Err(NoiseError::SessionReadOnly);
}
let payload = self.noise_state.write_message(payload)?;
Ok(LpMessage::EncryptedData(EncryptedDataPayload(payload)))
/// * `Ok(EncryptedLpPacket)` containing the encrypted message ciphertext.
/// * `Err(LpError)` if the session is not in transport mode or encryption fails.
pub(crate) fn encrypt_application_data(
&mut self,
data: LpMessage,
) -> Result<EncryptedLpPacket, LpError> {
let packet = self.next_packet(data)?;
encrypt_lp_packet(packet, &mut self.active_transport)
}
/// Decrypts an incoming Noise message containing application data.
/// Decrypts an incoming LpPacket
///
/// # Arguments
///
/// * `noise_ciphertext` - The encrypted Noise message received from the peer.
/// * `ciphertext` - The encrypted packet
///
/// # Returns
///
/// * `Ok(Vec<u8>)` containing the decrypted application data payload.
/// * `Err(NoiseError)` if the session is not in transport mode, decryption fails, or the message is not data.
pub fn decrypt_data(&mut self, noise_ciphertext: &LpMessage) -> Result<Vec<u8>, NoiseError> {
let payload = noise_ciphertext.payload();
match self.noise_state.read_message(payload)? {
ReadResult::DecryptedData(data) => Ok(data),
_ => Err(NoiseError::IncorrectStateError),
}
}
/// Creates a new subsession using Noise KKpsk0 pattern.
///
/// KKpsk0 reuses parent's static X25519 keys (both parties know each other from parent session).
/// PSK is derived from parent's PQ shared secret, preserving quantum resistance.
///
/// # Arguments
/// * `subsession_index` - Unique index for this subsession (use `next_subsession_index()`)
/// * `is_initiator` - True if this side initiates the subsession handshake
///
/// # Returns
/// `SubsessionHandshake` ready for KK1/KK2 message exchange
///
/// # Errors
/// * Returns error if parent handshake not complete
/// * Returns error if PQ shared secret not available
pub fn create_subsession(
&self,
subsession_index: u64,
is_initiator: bool,
) -> Result<SubsessionHandshake, LpError> {
// Get PQ shared secret
let pq_secret = self.pq_shared_secret();
// Derive subsession PSK from parent's PQ shared secret
let subsession_psk = derive_subsession_psk(pq_secret.as_bytes(), subsession_index);
// Build KKpsk0 handshake
// Pattern: Noise_KKpsk0_25519_ChaChaPoly_SHA256
// Both parties already know each other's static keys from parent session
let pattern_name = "Noise_KKpsk0_25519_ChaChaPoly_SHA256";
let params = pattern_name.parse()?;
let local_key_bytes = self.local_peer.x25519.private_key().to_bytes();
let remote_key_bytes = self.remote_x25519_public().to_bytes();
let builder = Builder::new(params)
.local_private_key(&local_key_bytes)
.remote_public_key(&remote_key_bytes)
.psk(0, &subsession_psk); // PSK at position 0 for KKpsk0
let handshake_state = if is_initiator {
builder.build_initiator().map_err(LpError::SnowKeyError)?
} else {
builder.build_responder().map_err(LpError::SnowKeyError)?
};
Ok(SubsessionHandshake {
index: subsession_index,
noise_state: Mutex::new(NoiseProtocol::new(handshake_state)),
is_initiator,
local_peer: self.local_peer.clone(),
remote_peer: self.remote_peer.clone(),
pq_shared_secret: self.pq_shared_secret.clone(),
subsession_psk,
negotiated_version: self.version,
})
}
}
/// Subsession created via Noise KKpsk0 handshake tunneled through parent session.
///
/// Subsessions provide fresh session keys while inheriting PQ protection from parent's
/// ML-KEM shared secret. After handshake completes, the subsession can be promoted
/// to replace the parent session.
///
/// # Lifecycle
/// 1. Parent calls `create_subsession()` to get `SubsessionHandshake`
/// 2. Initiator calls `prepare_message()` to get KK1
/// 3. KK1 sent through parent session (encrypted tunnel)
/// 4. Responder calls `process_message(kk1)` to process KK1
/// 5. Responder calls `prepare_message()` to get KK2
/// 6. KK2 sent through parent session
/// 7. Initiator calls `process_message(kk2)` to complete handshake
/// 8. Both call `is_complete()` to verify
#[derive(Debug)]
pub struct SubsessionHandshake {
/// Subsession index (unique per parent session)
pub index: u64,
/// Noise KKpsk0 handshake state
noise_state: Mutex<NoiseProtocol>,
/// Is this side the initiator?
is_initiator: bool,
// Key material inherited from parent session for into_session() conversion
/// Representation of a local Lewes Protocol peer
/// encapsulating all the known information and keys.
local_peer: LpLocalPeer,
/// Representation of a remote Lewes Protocol peer
/// encapsulating all the known information and keys.
remote_peer: LpRemotePeer,
/// PQ shared secret inherited from parent (for creating further subsessions)
pq_shared_secret: PqSharedSecret,
/// Subsession PSK (for deriving outer AEAD key)
subsession_psk: [u8; 32],
/// Negotiated protocol version from handshake.
negotiated_version: u8,
}
impl SubsessionHandshake {
/// Prepares the next KK handshake message (KK1 or KK2 depending on role/state).
///
/// # Returns
/// Noise handshake message bytes to send through parent session tunnel.
pub fn prepare_message(&self) -> Result<Vec<u8>, LpError> {
let mut noise_state = self.noise_state.lock();
noise_state
.get_bytes_to_send()
.ok_or_else(|| LpError::Internal("Not our turn to send".into()))?
.map_err(LpError::NoiseError)
}
/// Processes a received KK handshake message (KK1 or KK2).
///
/// # Arguments
/// * `message` - Noise handshake message received through parent session tunnel.
///
/// # Returns
/// Any payload embedded in the handshake message (usually empty for KK).
pub fn process_message(&self, message: &[u8]) -> Result<Vec<u8>, LpError> {
let mut noise_state = self.noise_state.lock();
let result = noise_state
.read_message(message)
.map_err(LpError::NoiseError)?;
match result {
ReadResult::HandshakeComplete | ReadResult::NoOp => Ok(vec![]),
ReadResult::DecryptedData(data) => Ok(data),
}
}
/// Checks if the handshake is complete (ready for transport mode).
pub fn is_complete(&self) -> bool {
self.noise_state.lock().is_handshake_finished()
}
/// Returns whether this side is the initiator.
pub fn is_initiator(&self) -> bool {
self.is_initiator
}
/// Returns the subsession index.
pub fn subsession_index(&self) -> u64 {
self.index
}
/// Convert completed subsession handshake into a full LpSession.
///
/// This consumes the SubsessionHandshake and creates a new LpSession
/// that can be used as a replacement for the parent session.
///
/// # Arguments
/// * `receiver_index` - New receiver index for the promoted session
///
/// # Errors
/// Returns error if handshake is not complete
pub fn into_session(self, receiver_index: u32) -> Result<LpSession, LpError> {
if !self.is_complete() {
return Err(LpError::Internal(
"Cannot convert incomplete subsession to session".to_string(),
));
}
// Extract the noise state (now in transport mode)
let noise_state = self.noise_state.into_inner();
// Derive outer AEAD key from the subsession PSK
let outer_key = OuterAeadKey::from_psk(&self.subsession_psk);
Ok(LpSession {
// noiserm
session_id: receiver_index,
noise_state,
sending_counter: 0,
receiving_counter: ReceivingKeyCounterValidator::new(0),
local_peer: self.local_peer,
remote_peer: self.remote_peer,
outer_aead_key: outer_key,
pq_shared_secret: self.pq_shared_secret,
subsession_counter: 0,
read_only: false,
successor_session_id: None,
version: self.negotiated_version,
})
/// * `Ok(LpPacket)` containing the decrypted application data payload.
/// * `Err(LpError)` if the session is not in transport mode, decryption fails, or the message is not data.
pub(crate) fn decrypt_packet(
&mut self,
packet: EncryptedLpPacket,
) -> Result<LpPacket, LpError> {
decrypt_lp_packet(packet, &mut self.active_transport)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SessionsMock, replay::ReplayError, sessions_for_tests};
use rand::thread_rng;
// Helper function to generate keypairs for tests
fn generate_x25519_keypair() -> x25519::KeyPair {
x25519::KeyPair::new(&mut thread_rng())
}
use crate::{ReplayError, SessionsMock};
use nym_kkt_ciphersuite::{IntoEnumIterator, KEM};
#[test]
fn test_session_creation() {
let mut session = sessions_for_tests().0;
for kem in KEM::iter() {
let mut session = SessionsMock::mock_post_handshake(kem).responder;
// Initial counter should be zero
let counter = session.next_counter();
assert_eq!(counter, 0);
// Initial counter should be zero
let counter = session.next_counter();
assert_eq!(counter, 0);
// Counter should increment
let counter = session.next_counter();
assert_eq!(counter, 1);
// Counter should increment
let counter = session.next_counter();
assert_eq!(counter, 1);
}
}
// NOTE: These tests are obsolete after removing optional KEM parameters.
// PSQ now always runs using X25519 keys internally converted to KEM format.
// The new tests at the end of this file (test_psq_*) cover PSQ integration.
/*
#[test]
fn test_session_creation_with_psq_state_initiator() {
// OLD API - REMOVED
}
#[test]
fn test_session_creation_with_psq_state_responder() {
// OLD API - REMOVED
}
*/
#[test]
fn test_replay_protection_sequential() {
let mut session = sessions_for_tests().1;
for kem in KEM::iter() {
let mut session = SessionsMock::mock_post_handshake(kem).responder;
// Sequential counters should be accepted
assert!(session.receiving_counter_quick_check(0).is_ok());
assert!(session.receiving_counter_mark(0).is_ok());
// Sequential counters should be accepted
assert!(session.receiving_counter_quick_check(0).is_ok());
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_quick_check(1).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
assert!(session.receiving_counter_quick_check(1).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
// Duplicates should be rejected
assert!(session.receiving_counter_quick_check(0).is_err());
let err = session.receiving_counter_mark(0).unwrap_err();
match err {
LpError::Replay(replay_error) => {
assert!(matches!(replay_error, ReplayError::DuplicateCounter));
// Duplicates should be rejected
assert!(session.receiving_counter_quick_check(0).is_err());
let err = session.receiving_counter_mark(0).unwrap_err();
match err {
LpError::Replay(replay_error) => {
assert!(matches!(replay_error, ReplayError::DuplicateCounter));
}
_ => panic!("Expected replay error"),
}
_ => panic!("Expected replay error"),
}
}
#[test]
fn test_replay_protection_out_of_order() {
let mut session = sessions_for_tests().1;
for kem in KEM::iter() {
let mut session = SessionsMock::mock_post_handshake(kem).responder;
// Receive packets in order
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
assert!(session.receiving_counter_mark(2).is_ok());
// Receive packets in order
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
assert!(session.receiving_counter_mark(2).is_ok());
// Skip ahead
assert!(session.receiving_counter_mark(10).is_ok());
// Skip ahead
assert!(session.receiving_counter_mark(10).is_ok());
// Can still receive out-of-order packets within window
assert!(session.receiving_counter_quick_check(5).is_ok());
assert!(session.receiving_counter_mark(5).is_ok());
// Can still receive out-of-order packets within window
assert!(session.receiving_counter_quick_check(5).is_ok());
assert!(session.receiving_counter_mark(5).is_ok());
// But duplicates are still rejected
assert!(session.receiving_counter_quick_check(5).is_err());
assert!(session.receiving_counter_mark(5).is_err());
}
#[test]
fn test_packet_stats() {
let mut session = sessions_for_tests().1;
// Initial stats
let (next, received) = session.current_packet_cnt();
assert_eq!(next, 0);
assert_eq!(received, 0);
// After receiving packets
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
let (next, received) = session.current_packet_cnt();
assert_eq!(next, 2);
assert_eq!(received, 2);
}
/*
// These tests remain commented as they rely on the old mock crypto functions
#[test]
fn test_mock_crypto() {
let mut session = create_test_session(true);
let data = [1, 2, 3, 4, 5];
let mut encrypted = [0; 5];
let mut decrypted = [0; 5];
// Mock encrypt should copy the data
// let encrypted_len = session.encrypt_packet(&data, &mut encrypted).unwrap(); // Removed method
// assert_eq!(encrypted_len, 5);
// assert_eq!(encrypted, data);
// Mock decrypt should copy the data
// let decrypted_len = session.decrypt_packet(&encrypted, &mut decrypted).unwrap(); // Removed method
// assert_eq!(decrypted_len, 5);
// assert_eq!(decrypted, data);
}
#[test]
fn test_mock_crypto_buffer_too_small() {
let mut session = create_test_session(true);
let data = [1, 2, 3, 4, 5];
let mut too_small = [0; 3];
// Should fail with buffer too small
// let result = session.encrypt_packet(&data, &mut too_small); // Removed method
// assert!(result.is_err());
// match result.unwrap_err() {
// LpError::InsufficientBufferSize => {} // Error type might change
// _ => panic!("Expected InsufficientBufferSize error"),
// }
}
*/
/// Test that X25519 keys are correctly converted to KEM format
#[test]
fn test_x25519_to_kem_conversion() {
use nym_kkt::ciphersuite::EncapsulationKey;
let initiator_keys = generate_x25519_keypair();
let responder_keys = generate_x25519_keypair();
// Verify we can convert X25519 public key to KEM format (as done in session.rs)
let x25519_public_bytes = responder_keys.public_key().as_bytes();
let libcrux_public_key =
libcrux_kem::PublicKey::decode(libcrux_kem::Algorithm::X25519, x25519_public_bytes)
.expect("X25519 public key should convert to libcrux PublicKey");
let _kem_key = EncapsulationKey::X25519(libcrux_public_key);
// Verify we can convert X25519 private key to KEM format
let x25519_private_bytes = initiator_keys.private_key().to_bytes();
let _libcrux_private_key =
libcrux_kem::PrivateKey::decode(libcrux_kem::Algorithm::X25519, &x25519_private_bytes)
.expect("X25519 private key should convert to libcrux PrivateKey");
// Successful conversion is sufficient - actual encapsulation is tested in psk.rs
// (libcrux_kem::PrivateKey is an enum with no len() method, conversion success is enough)
}
#[test]
fn test_demote_sets_read_only() {
let sessions = SessionsMock::mock_post_handshake(12345);
let mut session = sessions.initiator;
// Initially not read-only
assert!(!session.is_read_only());
assert!(session.successor_session_id().is_none());
// Demote the session
session.demote(99999);
// Now read-only with successor
assert!(session.is_read_only());
assert_eq!(session.successor_session_id(), Some(99999));
}
#[test]
fn test_encrypt_fails_after_demotion() {
let receiver_index = 12345;
let sessions = SessionsMock::mock_post_handshake(receiver_index);
let mut initiator_session = sessions.initiator;
// Encryption works before demotion
let plaintext = b"Hello before demotion";
assert!(initiator_session.encrypt_data(plaintext).is_ok());
// Demote the session
initiator_session.demote(99999);
// Encryption fails after demotion
let result = initiator_session.encrypt_data(plaintext);
assert!(result.is_err());
match result.unwrap_err() {
NoiseError::SessionReadOnly => {
// Expected
}
e => panic!("Expected SessionReadOnly error, got: {:?}", e),
// But duplicates are still rejected
assert!(session.receiving_counter_quick_check(5).is_err());
assert!(session.receiving_counter_mark(5).is_err());
}
}
#[test]
fn test_decrypt_works_after_demotion() {
// --- Setup Handshake ---
let receiver_index = 12345;
let sessions = SessionsMock::mock_post_handshake(receiver_index);
let mut initiator_session = sessions.initiator;
let mut responder_session = sessions.responder;
fn test_packet_stats() {
for kem in KEM::iter() {
let mut session = SessionsMock::mock_post_handshake(kem).responder;
// Responder encrypts a message
let plaintext = b"Message to demoted initiator";
let ciphertext = responder_session
.encrypt_data(plaintext)
.expect("Encryption failed");
// Initial stats
let packet_count = session.current_packet_cnt();
assert_eq!(packet_count.next, 0);
assert_eq!(packet_count.received, 0);
// Demote the initiator session
initiator_session.demote(99999);
assert!(initiator_session.is_read_only());
// After receiving packets
assert!(session.receiving_counter_mark(0).is_ok());
assert!(session.receiving_counter_mark(1).is_ok());
// Decryption still works on demoted session (drain in-flight)
let decrypted = initiator_session
.decrypt_data(&ciphertext)
.expect("Decryption should work on demoted session");
assert_eq!(decrypted, plaintext);
let packet_count = session.current_packet_cnt();
assert_eq!(packet_count.next, 2);
assert_eq!(packet_count.received, 2);
}
}
}
File diff suppressed because it is too large Load Diff
+75 -75
View File
@@ -6,17 +6,20 @@
//! This module implements session lifecycle management functionality, handling
//! creation, retrieval, and storage of sessions.
use crate::packet::{EncryptedLpPacket, LpMessage};
use crate::peer_config::LpReceiverIndex;
use crate::state_machine::{LpAction, LpInput, LpStateBare};
use crate::{LpError, LpMessage, LpSession, LpStateMachine};
use crate::{LpError, LpSession, LpStateMachine};
use std::collections::HashMap;
pub use crate::replay::validator::PacketCount;
/// 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
pub struct SessionManager {
/// Manages state machines directly, keyed by lp_id
state_machines: HashMap<u32, LpStateMachine>,
state_machines: HashMap<LpReceiverIndex, LpStateMachine>,
}
impl Default for SessionManager {
@@ -35,62 +38,47 @@ impl SessionManager {
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())?
}
pub fn closed(&self, lp_id: u32) -> Result<bool, LpError> {
pub fn send_data(
&mut self,
lp_id: LpReceiverIndex,
data: LpMessage,
) -> Result<LpAction, LpError> {
self.process_input(lp_id, LpInput::SendData(data))?
.ok_or(LpError::NotInTransport)
}
pub fn receive_packet(
&mut self,
lp_id: LpReceiverIndex,
packet: EncryptedLpPacket,
) -> Result<Option<LpAction>, LpError> {
self.process_input(lp_id, LpInput::ReceivePacket(packet))
}
pub fn closed(&self, lp_id: LpReceiverIndex) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Closed)
}
pub fn transport(&self, lp_id: u32) -> Result<bool, LpError> {
pub fn transport(&self, lp_id: LpReceiverIndex) -> Result<bool, LpError> {
Ok(self.get_state(lp_id)? == LpStateBare::Transport)
}
#[cfg(test)]
fn get_state_machine_id(&self, lp_id: u32) -> Result<u32, LpError> {
self.with_state_machine(lp_id, |sm| sm.id())?
fn get_state_machine_id(&self, lp_id: LpReceiverIndex) -> Result<LpReceiverIndex, LpError> {
self.with_state_machine(lp_id, |sm| sm.receiver_index())?
}
pub fn get_state(&self, lp_id: u32) -> Result<LpStateBare, LpError> {
pub fn get_state(&self, lp_id: LpReceiverIndex) -> 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> {
pub fn current_packet_cnt(&self, lp_id: LpReceiverIndex) -> Result<PacketCount, LpError> {
self.with_state_machine(lp_id, |sm| Ok(sm.session()?.current_packet_cnt()))?
}
@@ -98,43 +86,54 @@ impl SessionManager {
self.state_machines.len()
}
pub fn state_machine_exists(&self, lp_id: u32) -> bool {
pub fn state_machine_exists(&self, lp_id: LpReceiverIndex) -> bool {
self.state_machines.contains_key(&lp_id)
}
pub fn with_state_machine<F, R>(&self, lp_id: u32, f: F) -> Result<R, LpError>
pub fn with_state_machine<F, R>(&self, lp_id: LpReceiverIndex, f: F) -> Result<R, LpError>
where
F: FnOnce(&LpStateMachine) -> R,
{
if let Some(sm) = self.state_machines.get(&lp_id) {
Ok(f(sm))
} else {
Err(LpError::StateMachineNotFound { lp_id })
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_state_machine_mut<F, R>(
&mut self,
lp_id: LpReceiverIndex,
f: F,
) -> Result<R, LpError>
where
F: FnOnce(&mut LpStateMachine) -> R, // Closure takes mutable ref
{
if let Some(sm) = self.state_machines.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();
pub fn create_session_state_machine(
&mut self,
lp_session: LpSession,
) -> Result<LpReceiverIndex, LpError> {
let session_id = lp_session.receiver_index();
if self.state_machines.contains_key(&session_id) {
return Err(LpError::DuplicateSessionId(session_id));
}
let sm = LpStateMachine::new(lp_session);
self.state_machines.insert(receiver_index, sm);
receiver_index
self.state_machines.insert(session_id, sm);
Ok(session_id)
}
/// Method to remove a state machine
pub fn remove_state_machine(&mut self, lp_id: u32) -> bool {
pub fn remove_state_machine(&mut self, lp_id: LpReceiverIndex) -> bool {
let removed = self.state_machines.remove(&lp_id);
removed.is_some()
@@ -145,21 +144,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.create_session_state_machine(local_session).unwrap();
assert_eq!(sm_1_id, id);
let retrieved = manager.state_machine_exists(id);
assert!(retrieved);
let not_found = manager.state_machine_exists(99);
let not_found = manager.state_machine_exists(123);
assert!(!not_found);
}
@@ -167,8 +166,7 @@ mod tests {
fn test_session_manager_remove() {
let mut manager = SessionManager::new();
let local_session = mock_session_for_test();
let sm_1_id = manager.create_session_state_machine(local_session);
let sm_1_id = manager.create_session_state_machine(local_session).unwrap();
let removed = manager.remove_state_machine(sm_1_id);
assert!(removed);
@@ -180,24 +178,26 @@ mod tests {
#[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.create_session_state_machine(session1).unwrap();
let sm_2 = manager.create_session_state_machine(session2).unwrap();
let sm_3 = manager.create_session_state_machine(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_state_machine_id(sm_1).unwrap();
let retrieved2 = manager.get_state_machine_id(sm_2).unwrap();
let retrieved3 = manager.get_state_machine_id(sm_3).unwrap();
assert_eq!(retrieved1, sm_1);
assert_eq!(retrieved2, sm_2);
assert_eq!(retrieved3, sm_3);
assert_eq!(retrieved1, sm_1);
assert_eq!(retrieved2, sm_2);
assert_eq!(retrieved3, sm_3);
}
}
#[test]
@@ -206,7 +206,7 @@ mod tests {
let sesion = mock_session_for_test();
let sm = manager.create_session_state_machine(sesion);
let sm = manager.create_session_state_machine(sesion).unwrap();
assert_eq!(manager.session_count(), 1);
let retrieved = manager.get_state_machine_id(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())
}
}
@@ -1,4 +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};
+302
View File
@@ -0,0 +1,302 @@
// 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,
{
let mut buf = vec![0u8; n];
if n > MAX_HANDSHAKE_PACKET_SIZE {
return Err(LpTransportError::PacketTooBig { size: 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() {
@@ -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"
+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
+6 -46
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,
@@ -116,6 +106,8 @@ pub struct GatewayTasksBuilder {
shutdown_tracker: ShutdownTracker,
// populated and cached as necessary
use_mock_ecash: bool,
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,7 +225,7 @@ impl GatewayTasksBuilder {
GatewayError,
> {
// Check if we should use mock ecash for testing
if self.config.lp.debug.use_mock_ecash {
if self.use_mock_ecash {
warn!("Using MockEcashManager for LP testing (credentials NOT verified)");
let mock_manager = MockEcashManager::new(Box::new(self.storage.clone()));
return Ok(Arc::new(mock_manager)
@@ -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
+150 -127
View File
@@ -9,16 +9,20 @@ mod tests {
use nym_credential_verification::upgrade_mode::testing::mock_dummy_upgrade_mode_details;
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_gateway::GatewayError;
use nym_gateway::node::lp_listener::handler::LpConnectionHandler;
use nym_gateway::node::lp_listener::{
LpDebug, LpHandlerState, LpLocalPeer, MixForwardingReceiver, PeerControlRequest,
WireguardGatewayData, mix_forwarding_channels,
use nym_kkt::key_utils::{
generate_keypair_mceliece, generate_keypair_mlkem, generate_lp_keypair_x25519,
};
use nym_gateway::node::wireguard::{PeerManager, PeerRegistrator};
use nym_gateway::node::{ActiveClientsStore, GatewayStorage, LpConfig};
use nym_kkt::keys::KEMKeys;
use nym_kkt_ciphersuite::Ciphersuite;
use nym_lp::peer::LpLocalPeer;
use nym_node::config::{LpConfig, LpDebug};
use nym_node::node::GatewayStorage;
use nym_node::node::lp::error::LpHandlerError;
use nym_node::node::lp::handler::LpConnectionHandler;
use nym_node::node::lp::{LpHandlerState, MixForwardingReceiver, mix_forwarding_channels};
use nym_node::wireguard::{PeerManager, PeerRegistrator};
use nym_registration_client::{LpClientError, LpRegistrationClient};
use nym_test_utils::helpers::{CryptoRng, RngCore, u64_seeded_rng};
use nym_test_utils::helpers::{CryptoRng09, seeded_rng};
use nym_test_utils::mocks::async_read_write::MockIOStream;
use nym_test_utils::traits::Timeboxed;
use nym_wireguard::peer_controller::IpPair;
@@ -26,11 +30,10 @@ mod tests {
Key, KeyWrapper, MockPeerController, MockPeerControllerState, PeerControlRequestType,
RegisteredResponse, mock_peer_controller,
};
use nym_wireguard::{IpPool, WireguardConfig};
use nym_wireguard::{IpPool, PeerControlRequest, WireguardConfig, WireguardGatewayData};
use std::mem;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::sync::mpsc::Receiver;
use tokio::task::JoinHandle;
@@ -48,6 +51,7 @@ mod tests {
}
struct Party {
identity: ed25519::KeyPair,
peer: LpLocalPeer,
x25519_wg_keys: Arc<x25519::KeyPair>,
socket_addr: SocketAddr,
@@ -55,20 +59,31 @@ mod tests {
}
impl Party {
fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
fn generate(rng: &mut impl CryptoRng09) -> Self {
let mut ip = [0u8; 4];
let mut port = [0u8; 2];
// generate a valid instance of rand08
let mut seed = [0u8; 32];
rng.fill_bytes(&mut seed);
let mut rng08 = seeded_rng(seed);
rng.fill_bytes(&mut ip);
rng.fill_bytes(&mut port);
let ed25519_keys = Arc::new(ed25519::KeyPair::new(rng));
let x25519_wg_keys = Arc::new(x25519::KeyPair::new(rng));
let ed25519_keys = ed25519::KeyPair::new(&mut rng08);
let x25519_wg_keys = Arc::new(x25519::KeyPair::new(&mut rng08));
let lp_x25519_keys = Arc::new(ed25519_keys.to_x25519());
let lp_x25519_keys = Arc::new(generate_lp_keypair_x25519(rng));
let mlkem_keypair = generate_keypair_mlkem(rng);
let mceliece_keypair = generate_keypair_mceliece(rng);
let lp_kem_keys = KEMKeys::new(mceliece_keypair, mlkem_keypair);
let ciphersuite = Ciphersuite::default();
Party {
peer: LpLocalPeer::new(ed25519_keys, lp_x25519_keys.clone())
.with_kem_psq_key(lp_x25519_keys),
identity: ed25519_keys,
peer: LpLocalPeer::new(ciphersuite, lp_x25519_keys.clone())
.with_kem_keys(lp_kem_keys),
x25519_wg_keys,
socket_addr: SocketAddr::from((ip, u16::from_le_bytes(port))),
lp_version: 1,
@@ -83,7 +98,7 @@ mod tests {
}
impl Client {
fn mock(rng: &mut (impl RngCore + CryptoRng)) -> Self {
fn mock(rng: &mut impl CryptoRng09) -> Self {
Client {
base: Party::generate(rng),
ticket_provider: Default::default(),
@@ -108,7 +123,7 @@ mod tests {
handler: LpConnectionHandler<MockIOStream>,
},
Running {
handle: JoinHandle<Option<Result<(), GatewayError>>>,
handle: JoinHandle<Option<Result<(), LpHandlerError>>>,
},
Finished,
}
@@ -177,7 +192,7 @@ mod tests {
Ok(GatewayStorage::from_connection_pool(conn_pool, 100).await?)
}
async fn mock(rng: &mut (impl RngCore + CryptoRng)) -> anyhow::Result<Self> {
async fn mock(rng: &mut impl CryptoRng09) -> anyhow::Result<Self> {
let base = Party::generate(rng);
// 1. create in-memory gateway storage
@@ -188,7 +203,6 @@ mod tests {
let lp_config = LpConfig {
debug: LpDebug {
timestamp_tolerance: Duration::from_secs(30),
..Default::default()
},
..Default::default()
@@ -218,19 +232,10 @@ mod tests {
);
let lp_state = LpHandlerState {
// use mock instance of ecash verifier
ecash_verifier,
// use in-memory database (no need for persistency)
storage,
local_lp_peer: base.peer.clone(),
metrics: Default::default(),
// no clients at the beginning
active_clients_store: ActiveClientsStore::new(),
// use default lp config (with enabled flag)
lp_config,
@@ -381,107 +386,116 @@ mod tests {
#[cfg(test)]
mod using_lp_registration_client {
use super::*;
use nym_kkt_ciphersuite::{IntoEnumIterator, KEM};
use nym_registration_client::NestedLpSession;
use nym_test_utils::helpers::u64_seeded_rng_09;
use nym_wireguard::DefguardPeer;
#[tokio::test]
async fn test_basic_lp_entry_registration() -> anyhow::Result<()> {
// nym_test_utils::helpers::setup_test_logger();
// initialise random, but deterministic, keys, addresses, etc. for the parties
let mut client_rng = u64_seeded_rng(0);
let mut gateway_rng = u64_seeded_rng(1);
let client_data = Client::mock(&mut client_rng);
let client_key = *client_data.base.x25519_wg_keys.public_key();
let mut entry = Gateway::mock(&mut gateway_rng).await?;
for kem in KEM::iter() {
let ciphersuite = Ciphersuite::default().with_kem(kem);
let mut client = LpRegistrationClient::<MockIOStream>::new_with_default_config(
client_data.base.peer.ed25519().clone(),
entry.base.peer.as_remote(),
entry.base.socket_addr,
entry.base.lp_version,
);
// initialise random, but deterministic, keys, addresses, etc. for the parties
let mut client_rng = u64_seeded_rng_09(0);
let mut gateway_rng = u64_seeded_rng_09(1);
// 1. establish mock connection between client and gateway and retrieve gateway's handle
client.ensure_connected().await?;
let gateway_conn = client
.connection()
.as_ref()
.context("mock connection has failed!")?
.try_get_remote_handle();
let client_data = Client::mock(&mut client_rng);
let client_key = *client_data.base.x25519_wg_keys.public_key();
let mut entry = Gateway::mock(&mut gateway_rng).await?;
// 2. create and spawn gateway handler for the client connection
entry.create_lp_handler(gateway_conn, client_data.base.socket_addr);
entry.spawn_lp_handler();
let mut client = LpRegistrationClient::<MockIOStream>::new_with_default_config(
client_data.base.peer.x25519().clone(),
entry.base.peer.as_remote(),
entry.base.socket_addr,
ciphersuite,
entry.base.lp_version,
);
// 3. register all needed responses for the dvpn registration that will reach the peer controller
// 1) peer registration - ip pair allocation
let ip_pair = entry.pre_allocate_ip_pair();
let reg_res = Ok::<_, nym_wireguard::Error>(ip_pair);
// 1. establish mock connection between client and gateway and retrieve gateway's handle
client.ensure_connected().await?;
let gateway_conn = client
.connection()
.as_ref()
.context("mock connection has failed!")?
.try_get_remote_handle();
entry
.register_peer_controller_response(
PeerControlRequestType::AllocatePeerIpPair {},
reg_res,
)
.await;
// 2. create and spawn gateway handler for the client connection
entry.create_lp_handler(gateway_conn, client_data.base.socket_addr);
entry.spawn_lp_handler();
// 2) new peer inclusion - in non-mock system it would spawn handlers,
// here we'll just set a flag and say it's all fine
let public_key = client_key.to_wg_key();
let add_res = Ok::<_, nym_wireguard::Error>(());
entry
.register_peer_controller_response(
PeerControlRequestType::AddPeer { public_key },
add_res,
)
.await;
// 3. register all needed responses for the dvpn registration that will reach the peer controller
// 1) peer registration - ip pair allocation
let ip_pair = entry.pre_allocate_ip_pair();
let reg_res = Ok::<_, nym_wireguard::Error>(ip_pair);
// 3) peer query - check for prior registrations
let query_res = Ok::<_, nym_wireguard::Error>(Option::<DefguardPeer>::None);
let key = client_key.to_wg_key();
entry
.register_peer_controller_response(
PeerControlRequestType::QueryPeer { key },
query_res,
)
.await;
entry
.register_peer_controller_response(
PeerControlRequestType::AllocatePeerIpPair {},
reg_res,
)
.await;
// 4. spawn peer controller to be able to handle dvpn registration requests
entry.spawn_peer_controller();
// 2) new peer inclusion - in non-mock system it would spawn handlers,
// here we'll just set a flag and say it's all fine
let public_key = client_key.to_wg_key();
let add_res = Ok::<_, nym_wireguard::Error>(());
entry
.register_peer_controller_response(
PeerControlRequestType::AddPeer { public_key },
add_res,
)
.await;
// 5. perform client handshake
client.perform_handshake().timeboxed().await??;
// 3) peer query - check for prior registrations
let query_res = Ok::<_, nym_wireguard::Error>(Option::<DefguardPeer>::None);
let key = client_key.to_wg_key();
entry
.register_peer_controller_response(
PeerControlRequestType::QueryPeer { key },
query_res,
)
.await;
// 6. perform registration with entry only
let wg_keypair = client_data.base.x25519_wg_keys;
let gateway_identity = entry.base.peer.ed25519().public_key();
let registration_result = client
.register_dvpn(
&mut client_rng,
&wg_keypair,
gateway_identity,
&client_data.ticket_provider,
TicketType::V1WireguardEntry,
)
.timeboxed()
.await??;
// 4. spawn peer controller to be able to handle dvpn registration requests
entry.spawn_peer_controller();
// 7. verify registration result
let peers_guard = entry.mock_peer_controller_state.peers.read().await;
let peer = peers_guard.get_by_x25519_key(&client_key).unwrap().clone();
drop(peers_guard);
assert!(peer.add_success);
// 5. perform client handshake
client.perform_handshake().timeboxed().await??;
assert_eq!(registration_result.private_ipv4, ip_pair.ipv4);
assert_eq!(registration_result.private_ipv6, ip_pair.ipv6);
assert_eq!(
registration_result.public_key,
*entry.base.x25519_wg_keys.public_key()
);
// 6. perform registration with entry only
let wg_keypair = client_data.base.x25519_wg_keys;
let gateway_identity = entry.base.identity.public_key();
let registration_result = client
.register_dvpn(
&mut client_rng,
&wg_keypair,
gateway_identity,
&client_data.ticket_provider,
TicketType::V1WireguardEntry,
)
.timeboxed()
.await??;
// 7. verify registration result
let peers_guard = entry.mock_peer_controller_state.peers.read().await;
let peer = peers_guard.get_by_x25519_key(&client_key).unwrap().clone();
drop(peers_guard);
assert!(peer.add_success);
assert_eq!(registration_result.private_ipv4, ip_pair.ipv4);
assert_eq!(registration_result.private_ipv6, ip_pair.ipv6);
assert_eq!(
registration_result.public_key,
*entry.base.x25519_wg_keys.public_key()
);
// 8. stop the gateway task and finish the test
entry.stop_tasks().await?;
}
// 8. stop the gateway task and finish the test
entry.stop_tasks().await?;
Ok(())
}
@@ -489,16 +503,19 @@ mod tests {
async fn registration_is_not_allowed_without_prior_handshake() -> anyhow::Result<()> {
// nym_test_utils::helpers::setup_test_logger();
// initialise random, but deterministic, keys, addresses, etc. for the parties
let mut client_rng = u64_seeded_rng(0);
let mut gateway_rng = u64_seeded_rng(1);
let mut client_rng = u64_seeded_rng_09(0);
let mut gateway_rng = u64_seeded_rng_09(1);
let client_data = Client::mock(&mut client_rng);
let mut entry = Gateway::mock(&mut gateway_rng).await?;
let ciphersuite = Ciphersuite::default();
let mut client = LpRegistrationClient::<MockIOStream>::new_with_default_config(
client_data.base.peer.ed25519().clone(),
client_data.base.peer.x25519().clone(),
entry.base.peer.as_remote(),
entry.base.socket_addr,
ciphersuite,
entry.base.lp_version,
);
@@ -521,7 +538,7 @@ mod tests {
// 4. perform registration with entry only
// but WITHOUT performing the handshake
let wg_keypair = client_data.base.x25519_wg_keys;
let gateway_identity = entry.base.peer.ed25519().public_key();
let gateway_identity = entry.base.identity.public_key();
let registration_result = client
.register_dvpn(
&mut client_rng,
@@ -534,13 +551,9 @@ mod tests {
.await?
.unwrap_err();
let LpClientError::Transport(err) = registration_result else {
let LpClientError::IncompleteHandshake = registration_result else {
panic!("unexpected error");
};
assert_eq!(
err,
"State machine not available - has the handshake been completed?"
);
// 5. stop the gateway task and finish the test
entry.stop_tasks().await?;
@@ -550,10 +563,16 @@ mod tests {
#[tokio::test]
async fn test_basic_lp_exit_registration() -> anyhow::Result<()> {
// nym_test_utils::helpers::setup_test_logger();
// TODO: update the test once mceliece works
let kem = KEM::MlKem768;
let ciphersuite = Ciphersuite::default().with_kem(kem);
// initialise random, but deterministic, keys, addresses, etc. for the parties
let mut client_rng = u64_seeded_rng(0);
let mut entry_rng = u64_seeded_rng(1);
let mut exit_rng = u64_seeded_rng(2);
let mut client_rng = u64_seeded_rng_09(0);
let mut entry_rng = u64_seeded_rng_09(1);
let mut exit_rng = u64_seeded_rng_09(2);
let client_data = Client::mock(&mut client_rng);
let client_key = *client_data.base.x25519_wg_keys.public_key();
@@ -561,9 +580,10 @@ mod tests {
let mut exit = Gateway::mock(&mut exit_rng).await?;
let mut entry_client = LpRegistrationClient::<MockIOStream>::new_with_default_config(
client_data.base.peer.ed25519().clone(),
client_data.base.peer.x25519().clone(),
entry.base.peer.as_remote(),
entry.base.socket_addr,
ciphersuite,
entry.base.lp_version,
);
@@ -678,18 +698,21 @@ mod tests {
// but crypto is going to work the same
let mut nested_session = NestedLpSession::new(
exit.base.socket_addr,
client_data.base.peer.ed25519().clone(),
client_data.base.peer.x25519().clone(),
exit.base.peer.as_remote(),
ciphersuite,
exit.base.lp_version,
);
// 13. Perform handshake and registration with exit gateway (all via entry forwarding)
nested_session.perform_handshake(&mut entry_client).await?;
let exit_registration_result = nested_session
.handshake_and_register_dvpn(
.register_dvpn(
&mut entry_client,
&mut client_rng,
&client_data.base.x25519_wg_keys,
exit.base.peer.ed25519().public_key(),
exit.base.identity.public_key(),
&client_data.ticket_provider,
TicketType::V1WireguardExit,
)
@@ -701,7 +724,7 @@ mod tests {
.register_dvpn(
&mut client_rng,
&client_data.base.x25519_wg_keys,
entry.base.peer.ed25519().public_key(),
entry.base.identity.public_key(),
&client_data.ticket_provider,
TicketType::V1WireguardEntry,
)
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "nym-api"
license = "GPL-3.0"
version = "1.1.74"
version = "1.1.75"
authors.workspace = true
edition = "2021"
rust-version.workspace = true
-1
View File
@@ -52,7 +52,6 @@ nym-ecash-signer-check-types = { workspace = true }
nym-kkt-ciphersuite = { workspace = true }
[dev-dependencies]
rand_chacha = { workspace = true }
nym-crypto = { workspace = true, features = ["rand"] }
nym-test-utils = { workspace = true }
+2 -8
View File
@@ -790,13 +790,7 @@ mod tests {
use nym_compact_ecash::scheme::keygen::KeyPairUser;
use nym_compact_ecash::withdrawal_request;
use nym_ecash_time::{ecash_today_date, EcashTime};
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
pub fn test_rng() -> ChaCha20Rng {
let dummy_seed = [42u8; 32];
ChaCha20Rng::from_seed(dummy_seed)
}
use nym_test_utils::helpers::deterministic_rng;
// had some issues with `Date` and serde...
// so might as well leave this unit test in case we do something to the helper
@@ -821,7 +815,7 @@ mod tests {
#[test]
fn decoding_attribute_commitments() {
let mut rng = test_rng();
let mut rng = deterministic_rng();
let keys = ed25519::KeyPair::new(&mut rng);
let dummy_sig = keys.private_key().sign("foomp");
let dummy_keypair = KeyPairUser::new();
@@ -5,15 +5,16 @@
//! and defines required conversion methods
use celes::Country;
use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey;
use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey;
use nym_crypto::asymmetric::ed25519::serde_helpers::{bs58_ed25519_pubkey, bs58_ed25519_signature};
use nym_crypto::asymmetric::x25519::serde_helpers::{bs58_dh_public_key, bs58_x25519_pubkey};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_kkt_ciphersuite::{HashFunction, SignatureScheme, KEM};
use nym_network_defaults::{WG_METADATA_PORT, WG_TUNNEL_PORT};
use nym_node_requests::api::SignedData;
use nym_noise_keys::VersionedNoiseKeyV1;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::net::IpAddr;
use strum_macros::{Display, EnumString};
use thiserror::Error;
@@ -200,6 +201,26 @@ pub struct WireguardDetailsV1 {
// even if you put `#[serde(default)]` all over the place
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq)]
pub struct LewesProtocolDetailsV1 {
pub content: LewesProtocolDetailsDataV1,
#[serde(with = "bs58_ed25519_signature")]
#[schemars(with = "String")]
#[schema(value_type = String)]
pub signature: ed25519::Signature,
}
impl LewesProtocolDetailsV1 {
pub fn verify(&self, key: &ed25519::PublicKey) -> bool {
let Ok(plaintext) = serde_json::to_string(&self.content) else {
return false;
};
key.verify(plaintext, &self.signature).is_ok()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq)]
pub struct LewesProtocolDetailsDataV1 {
/// Helper field that specifies whether the LP listener(s) is enabled on this node.
/// It is directly controlled by the node's role (i.e. it is enabled if it supports 'entry' mode)
pub enabled: bool,
@@ -210,28 +231,23 @@ pub struct LewesProtocolDetailsV1 {
/// LP UDP data address (default: 51264) for Sphinx packets wrapped in LP
pub data_port: u16,
#[serde(with = "bs58_x25519_pubkey")]
#[serde(with = "bs58_dh_public_key")]
#[schemars(with = "String")]
#[schema(value_type = String)]
/// LP public key
pub x25519: x25519::PublicKey,
pub x25519: x25519::DHPublicKey,
/// Digests of the KEM keys available to this node alongside hashing algorithms used
/// for their computation.
/// note: digests are hex encoded
pub kem_keys: HashMap<LPKEM, HashMap<LPHashFunction, String>>,
/// Digests of the signing keys available to this node alongside hashing algorithms used
/// for their computation.
/// note: digests are hex encoded
pub signing_keys: HashMap<LPSignatureScheme, HashMap<LPHashFunction, String>>,
pub kem_keys: BTreeMap<LPKEM, BTreeMap<LPHashFunction, String>>,
}
impl LewesProtocolDetailsV1 {
impl LewesProtocolDetailsDataV1 {
fn decode_digests(
digests: &HashMap<LPHashFunction, String>,
) -> Result<HashMap<HashFunction, Vec<u8>>, MalformedLPData> {
let mut kem_digests = HashMap::new();
digests: &BTreeMap<LPHashFunction, String>,
) -> Result<BTreeMap<HashFunction, Vec<u8>>, MalformedLPData> {
let mut kem_digests = BTreeMap::new();
for (hash_function, digest) in digests {
let digest = hex::decode(digest).map_err(|source| MalformedLPData::MalformedHash {
value: digest.to_string(),
@@ -244,31 +260,20 @@ impl LewesProtocolDetailsV1 {
pub fn kem_keys(
&self,
) -> Result<HashMap<KEM, HashMap<HashFunction, Vec<u8>>>, MalformedLPData> {
let mut kem_keys = HashMap::new();
) -> Result<BTreeMap<KEM, BTreeMap<HashFunction, Vec<u8>>>, MalformedLPData> {
let mut kem_keys = BTreeMap::new();
for (kem, digests) in &self.kem_keys {
let kem_digests = Self::decode_digests(digests)?;
kem_keys.insert((*kem).try_into()?, kem_digests);
}
Ok(kem_keys)
}
pub fn signing_keys(
&self,
) -> Result<HashMap<SignatureScheme, HashMap<HashFunction, Vec<u8>>>, MalformedLPData> {
let mut signing_keys = HashMap::new();
for (signature_scheme, digests) in &self.signing_keys {
let kem_digests = Self::decode_digests(digests)?;
signing_keys.insert((*signature_scheme).try_into()?, kem_digests);
}
Ok(signing_keys)
}
}
/// Convert map of digests from `nym_node_requests` types into `nym-api-requests` types
fn translate_digests(
digests: HashMap<nym_node_requests::api::v1::lewes_protocol::models::LPHashFunction, String>,
) -> HashMap<LPHashFunction, String> {
digests: BTreeMap<nym_node_requests::api::v1::lewes_protocol::models::LPHashFunction, String>,
) -> BTreeMap<LPHashFunction, String> {
digests
.into_iter()
.map(|(hash_fn, digest)| (hash_fn.into(), digest))
@@ -289,13 +294,12 @@ fn translate_digests(
Display,
EnumString,
ToSchema,
Ord,
)]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPKEM {
MlKem768,
XWing,
X25519,
McEliece,
}
@@ -313,6 +317,7 @@ pub enum LPKEM {
Display,
EnumString,
ToSchema,
Ord,
)]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
@@ -455,25 +460,26 @@ impl From<nym_node_requests::api::v1::gateway::models::Wireguard> for WireguardD
}
}
impl From<nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol>
impl From<SignedData<nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol>>
for LewesProtocolDetailsV1
{
fn from(value: nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol) -> Self {
fn from(
value: SignedData<nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol>,
) -> Self {
LewesProtocolDetailsV1 {
enabled: value.enabled,
control_port: value.control_port,
data_port: value.data_port,
x25519: value.x25519,
kem_keys: value
.kem_keys
.into_iter()
.map(|(kem, digests)| (kem.into(), translate_digests(digests)))
.collect(),
signing_keys: value
.signing_keys
.into_iter()
.map(|(scheme, digests)| (scheme.into(), translate_digests(digests)))
.collect(),
signature: value.signature,
content: LewesProtocolDetailsDataV1 {
enabled: value.enabled,
control_port: value.control_port,
data_port: value.data_port,
x25519: value.x25519,
kem_keys: value
.data
.kem_keys
.into_iter()
.map(|(kem, digests)| (kem.into(), translate_digests(digests)))
.collect(),
},
}
}
}
@@ -482,8 +488,6 @@ impl From<nym_node_requests::api::v1::lewes_protocol::models::LPKEM> for LPKEM {
fn from(value: nym_node_requests::api::v1::lewes_protocol::models::LPKEM) -> Self {
match value {
nym_node_requests::api::v1::lewes_protocol::models::LPKEM::MlKem768 => LPKEM::MlKem768,
nym_node_requests::api::v1::lewes_protocol::models::LPKEM::XWing => LPKEM::XWing,
nym_node_requests::api::v1::lewes_protocol::models::LPKEM::X25519 => LPKEM::X25519,
nym_node_requests::api::v1::lewes_protocol::models::LPKEM::McEliece => LPKEM::McEliece,
}
}
@@ -543,8 +547,6 @@ impl TryFrom<LPKEM> for KEM {
fn try_from(value: LPKEM) -> Result<Self, Self::Error> {
match value {
LPKEM::MlKem768 => Ok(KEM::MlKem768),
LPKEM::XWing => Ok(KEM::XWing),
LPKEM::X25519 => Ok(KEM::X25519),
LPKEM::McEliece => Ok(KEM::McEliece),
// TODO: for backwards compatibility once variants within the LP crate change
// other => Err(MalformedLPData::UnknownLpKEM { value: other }),
@@ -576,3 +578,48 @@ impl TryFrom<LPSignatureScheme> for SignatureScheme {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nym_node_requests::api::SignedLewesProtocol;
#[test]
fn signature_validity_after_conversion() {
use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM};
let signing_key = ed25519::PrivateKey::from_bytes(&[42u8; 32]).unwrap();
let verification_key = signing_key.public_key();
let x25519_key = x25519::DHPublicKey::from_bytes(&[42u8; 32]);
let mut dummy_kems = BTreeMap::new();
for kem in [LPKEM::McEliece, LPKEM::McEliece] {
let mut kem_digests = BTreeMap::new();
for sf in [
LPHashFunction::Blake3,
LPHashFunction::Shake128,
LPHashFunction::Shake256,
LPHashFunction::Sha256,
] {
kem_digests.insert(sf, "0xdeadbeef".to_string());
}
dummy_kems.insert(kem, kem_digests);
}
// make sure the serialisation stays the same and signature is still valid
let dummy_lp = nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol {
enabled: false,
control_port: 123,
data_port: 345,
x25519: x25519_key,
kem_keys: dummy_kems,
};
let dummy_signed_lp = SignedLewesProtocol::new(dummy_lp, &signing_key).unwrap();
// sanity check
assert!(dummy_signed_lp.verify(&verification_key));
let converted = LewesProtocolDetailsV1::from(dummy_signed_lp);
assert!(converted.verify(&verification_key));
}
}
@@ -247,7 +247,7 @@ impl From<NymNodeDescriptionV1> for NymNodeDescriptionV2 {
#[cfg(test)]
pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV2 {
use crate::models::{LPHashFunction, LPSignatureScheme, LPKEM};
use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM};
use nym_test_utils::helpers::{u64_seeded_rng, RngCore};
let mut rng = u64_seeded_rng(seed);
@@ -257,22 +257,33 @@ pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV2 {
// just reuse the same x25519 key for everything - this is just a data mock
let x25519 = x25519::KeyPair::new(&mut rng);
let mut kem_hashes_wrapper = std::collections::HashMap::new();
let mut signing_keys_hashes_wrapper = std::collections::HashMap::new();
let mut kem_hashes = std::collections::HashMap::new();
let mut signing_keys_hashes = std::collections::HashMap::new();
let mut dummy_kems = std::collections::BTreeMap::new();
for kem in [LPKEM::McEliece, LPKEM::McEliece] {
let mut kem_digests = std::collections::BTreeMap::new();
for (i, sf) in [
LPHashFunction::Blake3,
LPHashFunction::Shake128,
LPHashFunction::Shake256,
LPHashFunction::Sha256,
]
.iter()
.enumerate()
{
kem_digests.insert(*sf, hex::encode([((seed + i as u64) % 256) as u8; 32]));
}
dummy_kems.insert(kem, kem_digests);
}
kem_hashes.insert(
LPHashFunction::Sha256,
hex::encode([(seed % 256) as u8; 32]),
);
kem_hashes_wrapper.insert(LPKEM::X25519, kem_hashes);
signing_keys_hashes.insert(
LPHashFunction::Sha256,
hex::encode([(seed % 256) as u8; 32]),
);
signing_keys_hashes_wrapper.insert(LPSignatureScheme::Ed25519, signing_keys_hashes);
// make sure the serialisation stays the same and signature is still valid
let dummy_lp = nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol {
enabled: false,
control_port: 123,
data_port: 345,
x25519: (*x25519.public_key()).into(),
kem_keys: dummy_kems,
};
let dummy_signed_lp =
nym_node_requests::api::SignedLewesProtocol::new(dummy_lp, ed25519.private_key()).unwrap();
NymNodeDescriptionV2 {
node_id: rng.next_u32(),
@@ -339,14 +350,7 @@ pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV2 {
metadata_port: 456,
public_key: x25519.public_key().to_base58_string(),
}),
lewes_protocol: Some(LewesProtocolDetailsV1 {
enabled: true,
control_port: 1234,
data_port: 2345,
x25519: *x25519.public_key(),
kem_keys: kem_hashes_wrapper,
signing_keys: signing_keys_hashes_wrapper,
}),
lewes_protocol: Some(dummy_signed_lp.into()),
mixnet_websockets: WebSocketsV2 {
ws_port: 9000,
wss_port: None,
+1 -1
View File
@@ -231,7 +231,7 @@ async fn get_bonded_nodes(
),
params(PaginationRequest)
)]
// #[deprecated(note = "use '/v2/nym-nodes/described' instead")]
#[deprecated(note = "use '/v2/nym-nodes/described' instead")]
async fn get_described_nodes(
State(state): State<AppState>,
Query(pagination): Query<PaginationRequest>,
+15 -19
View File
@@ -1,14 +1,14 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node_status_api::models::{AxumErrorResponse, AxumResult};
use crate::node_status_api::models::AxumResult;
use crate::support::http::helpers::PaginationRequest;
use crate::support::http::state::AppState;
use axum::extract::{Query, State};
use axum::routing::get;
use axum::Router;
use nym_api_requests::models::NymNodeDescriptionV2;
use nym_api_requests::pagination::PaginatedResponse;
use nym_api_requests::pagination::{PaginatedResponse, Pagination};
use nym_http_api_common::FormattedResponse;
use tower_http::compression::CompressionLayer;
@@ -36,23 +36,19 @@ async fn get_described_nodes(
State(state): State<AppState>,
Query(pagination): Query<PaginationRequest>,
) -> AxumResult<FormattedResponse<PaginatedResponse<NymNodeDescriptionV2>>> {
let _ = state;
// TODO: implement it
let _ = pagination;
Err(AxumErrorResponse::not_implemented())
let output = pagination.output.unwrap_or_default();
// // TODO: implement it
// let _ = pagination;
// let output = pagination.output.unwrap_or_default();
//
// let cache = state.described_nodes_cache.get().await?;
// let descriptions = cache.all_nodes().cloned().collect::<Vec<_>>();
//
// Ok(output.to_response(PaginatedResponse {
// pagination: Pagination {
// total: descriptions.len(),
// page: 0,
// size: descriptions.len(),
// },
// data: descriptions,
// }))
let cache = state.described_nodes_cache.get().await?;
let descriptions = cache.all_nodes().cloned().collect::<Vec<_>>();
Ok(output.to_response(PaginatedResponse {
pagination: Pagination {
total: descriptions.len(),
page: 0,
size: descriptions.len(),
},
data: descriptions,
}))
}

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