Compare commits

...

26 Commits

Author SHA1 Message Date
Simon Wicky 847aa65d66 better ip address extraction 2025-06-05 14:05:01 +02:00
Simon Wicky 8ba58ba11e [Feature] Noise XKpsk3 integration (2025 version) (#5692)
* grand Noise squaheroo

* fix merge conflicts and adapt code for the key rotation changes (#5824)

* remove file that should have been ignored
2025-06-05 11:34:55 +02:00
Simon Wicky be16fddc75 [Stats API] Infallible network view (#5825)
* infallible network view and cheddar model for current compatibility

* bump patch version

* typo
2025-06-04 17:08:44 +02:00
dependabot[bot] a7d6cba11d build(deps-dev): bump http-proxy-middleware (#5810)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.4 to 2.0.9.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.4...v2.0.9)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 2.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 13:42:41 +02:00
dependabot[bot] a67ff33054 build(deps): bump tokio from 1.44.2 to 1.45.1 (#5798)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.44.2 to 1.45.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.44.2...tokio-1.45.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.45.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 10:21:00 +02:00
dependabot[bot] 61badfdcfe build(deps): bump undici in /.github/actions/nym-hash-releases/src (#5771)
Bumps [undici](https://github.com/nodejs/undici) from 5.28.5 to 5.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.28.5...v5.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 5.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 09:40:05 +02:00
dependabot[bot] 19dfbeb2b4 build(deps): bump cargo_metadata from 0.18.1 to 0.19.2 (#5765)
Bumps [cargo_metadata](https://github.com/oli-obk/cargo_metadata) from 0.18.1 to 0.19.2.
- [Release notes](https://github.com/oli-obk/cargo_metadata/releases)
- [Changelog](https://github.com/oli-obk/cargo_metadata/blob/main/CHANGELOG.md)
- [Commits](https://github.com/oli-obk/cargo_metadata/compare/0.18.1...0.19.2)

---
updated-dependencies:
- dependency-name: cargo_metadata
  dependency-version: 0.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 09:37:54 +02:00
dependabot[bot] 9f13616c24 build(deps): bump tempfile from 3.19.1 to 3.20.0 (#5764)
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.19.1 to 3.20.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.19.1...v3.20.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 09:37:39 +02:00
Jędrzej Stuczyński d8c84cc4d6 feat: key rotation (#5777)
* wip

* wip: wrap node's sphinx key with a manager

* wip: choosing correct key for packet processing

* further propagation of key rotation information

* attaching key rotation information to reply surbs

* added basic key rotation information to mixnet contract

* wip: introducing cached queries for key rotation info from nym api

* unified nym-api contract cache refreshing

* finish packet decoding

* multi api client + retrieving rotation id

* rotating sphinx key files

* logic for migrating config file

* wip: putting new sphinx keys to self described endpoints

* processing loop of KeyRotationController

* fixed sphinx key loading

* rotating bloomfilters

* wired up KeyRotationController

* flushing bloomfilters to disk and loading

* most of nym-node changes

* post rebase fixes

* fixes due to backwards compatible hostkeys

* split http state.rs file

* dont use deprecated fields

* fixed backwards compatible deserialisation of host information

* split up node describe cache

* added a dedicated CacheRefresher listener to perform full refresh outside the set interval

* controlling announced sphinx keys within nym-api

* retrieving rotation id when pulling topology

* split nym-nodes http handlers

* v2 nym-api endpoints to retrieve nodes with additional metadata information

* bug fixes...

* additional bugfixes and guards against stuck epoch

* testnet manager: set first nym-api as the rewarder

* fixed host information deserialisation

* fixed panic during first key rotation

* post rebase fixes

* clippy

* more guards against stuck epochs

* added helper method to reset node's sphinx key

* instantiate mixnet contract with custom key rotation validity

* additional bugfixes and debugging nym-api deadlock

* passing shutdown to nym apis client

* remove dead test

* post rebasing fixes

* missing MixnetQueryClient variants

* remove usage of deprecated methods in sdk example

* fix: incorrect method signature

* post rebasing fixes

* attempt to retrieve key rotation id before doing any config migration work

* ignore tests relying on networking behaviour

* allow networking failures in certain tests
2025-06-03 11:22:51 +01:00
Simon Wicky adbe0392ca Nym-statistics-api : Postgres schema and SSL handling + Dockerfile and GitHub action (#5817)
* add option for ssl mode

* add dockerfile and dev util

* add github workflow for nym-statistics api

* apply review comments

* ci check for version + removed checks from push
2025-06-03 12:06:00 +02:00
windy-ux 3c6567ae64 [DOCs]: redirectsl (#5816)
+ /docs/developers/tutorials/rust-sdk.html
2025-06-03 09:28:55 +00:00
Jack Wampler 8384a411df Bug Fix for Wallet build (#5820)
revert url used for connection-tester
2025-06-02 14:19:43 -06:00
Jack Wampler c56ebd9ceb Url scheme warning log (#5819)
fix conditions for logging about url scheme
2025-06-02 09:11:16 -06:00
Jędrzej Stuczyński b081b20a83 chore: adjust heuristic for wireguard peer activity (#5818)
* chore: adjust heuristic for wireguard peer activity

* fixed incorrect delta_tx calculation + typo
2025-06-02 15:37:37 +01:00
Andrej Mihajlov 866d547745 Merge pull request #5795 from nymtech/am/update-sqlx-0.8.6
Update to sqlx 0.8.6
2025-06-02 13:23:09 +02:00
Andrej Mihajlov 64e3f066a7 Use type override to enforce i64 type instead of Option<i64> 2025-05-30 10:17:19 +02:00
Andrej Mihajlov 62520c9308 Update sqlx cache 2025-05-30 09:28:48 +02:00
Andrej Mihajlov e65d455c91 Switch counters to i64 since sqlx started giving it back 2025-05-30 09:28:48 +02:00
Andrej Mihajlov 9b9c82a02a Run unchecked as sqlx does not understand COALESCE on NULL value 2025-05-30 09:28:48 +02:00
Andrej Mihajlov 1a38a2503e Stick to OffsetDateTime 2025-05-30 09:28:48 +02:00
Andrej Mihajlov 318f293983 All count() calls return i64 from now on 2025-05-30 09:28:48 +02:00
Andrej Mihajlov 5f2aba19c2 Update to sqlx 0.8.6 2025-05-30 09:28:48 +02:00
Jack Wampler 814ee45b4d HTTP Client Retries, Fallbacks, and Redirects (#5789)
updates to nym HTTP api client with multiple features relating to request domains
2025-05-29 10:37:07 -06:00
dynco-nym 56ed915626 Replace chrono with time in NS API (#5811)
* Replace chrono with time in NS API

* Replace chrono in client

* Bump package version
2025-05-29 16:33:00 +02:00
Jędrzej Stuczyński 2de8f8bc21 feature: nympool contract (#5464)
* squashed nym-pool commits

initialised nym-pool contract and updated all bls12_381 to make it possible

create scaffolding for tests

ability to control the contract admin

introducing contract grants

grant type validation

basic grant operations + stubs for other messages

added queries

use transaction stubs

added expiration information to grant queries

setting initial grant state based on the current environment

allowance logic for attempting to spend part of a grant

implemented all remaining transactions

made public api for coin locking perform validation

tests for locked tokens storage

nympool storage tests

added messages for changing granter set

tests and fixes for sufficient tokens when inserting grants

tests for initial state + more bugfixes

queries tests

additional tests for transactions and fixes

post rebase fixes

updated contract dependencies

removed redundant wasm constructor

dont ask me why this suddenly became an issue - no clue

removed redundant wasm constructor

dont ask me why this suddenly became an issue - no clue

* missing schema + added nym_pool to the main Makefile
2025-05-29 10:31:01 +01:00
import this f04cb6f6a6 [DOCs/operators]: Release notes v2025.10-brie (#5808)
* finish release notes and operator updates

* add NSL update - ready for merge

* address review comment
2025-05-28 11:59:35 +00:00
417 changed files with 25306 additions and 5200 deletions
+3 -3
View File
@@ -415,9 +415,9 @@
}
},
"node_modules/undici": {
"version": "5.28.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.5.tgz",
"integrity": "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==",
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
@@ -0,0 +1,57 @@
name: ci-check-nym-stats-api-version
on:
pull_request:
paths:
- "nym-statistics-api/**"
env:
WORKING_DIRECTORY: "nym-statistics-api"
jobs:
check-if-tag-exists:
runs-on: arc-ubuntu-22.04-dind
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
- name: Check if git tag exists
run: |
TAG=${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }}
if [[ -z "$TAG" ]]; then
echo "Tag is empty"
exit 1
fi
git ls-remote --tags origin | awk '{print $2}'
if git ls-remote --tags origin | awk '{print $2}' | grep -q "refs/tags/$TAG$" ; then
echo "Tag '$TAG' ALREADY EXISTS on the remote"
exit 1
else
echo "Tag '$TAG' does not exist on the remote"
fi
- name: Check if harbor tag exists
run: |
TAG=${{ steps.get_version.outputs.result }}
registry=https://harbor.nymte.ch
repo_name=nym/nym-statistics-api
if [[ -z $TAG ]]; then
echo "Tag is empty"
exit 1
fi
curl -su ${{ secrets.HARBOR_ROBOT_USERNAME }}:${{ secrets.HARBOR_ROBOT_SECRET }} "$registry/v2/$repo_name/tags/list" | jq
exists=$(curl -su ${{ secrets.HARBOR_ROBOT_USERNAME }}:${{ secrets.HARBOR_ROBOT_SECRET }} "$registry/v2/$repo_name/tags/list" | jq --arg tag $TAG '.tags | contains([$tag])' )
if [[ $exists = "true" ]]; then
echo "Version '$TAG' defined in Cargo.toml ALREADY EXISTS as tag in harbor repo"
exit 1
elif [[ $exists = "false" ]]; then
echo "Version '$TAG' doesn't exist on the remote"
else
echo "Unknown output '$exists'"
exit 1
fi
@@ -56,6 +56,7 @@ jobs:
cp contracts/target/wasm32-unknown-unknown/release/cw3_flex_multisig.wasm $OUTPUT_DIR
cp contracts/target/wasm32-unknown-unknown/release/cw4_group.wasm $OUTPUT_DIR
cp contracts/target/wasm32-unknown-unknown/release/nym_ecash.wasm $OUTPUT_DIR
cp contracts/target/wasm32-unknown-unknown/release/nym_pool_contract.wasm $OUTPUT_DIR
- name: Deploy branch to CI www
continue-on-error: true
@@ -0,0 +1,42 @@
name: Build and upload Nym Statistics API container to harbor.nymte.ch
on:
workflow_dispatch:
env:
WORKING_DIRECTORY: "nym-statistics-api"
CONTAINER_NAME: "nym-statistics-api"
jobs:
build-container:
runs-on: arc-ubuntu-22.04-dind
steps:
- name: Login to Harbor
uses: docker/login-action@v3
with:
registry: harbor.nymte.ch
username: ${{ secrets.HARBOR_ROBOT_USERNAME }}
password: ${{ secrets.HARBOR_ROBOT_SECRET }}
- name: Checkout repo
uses: actions/checkout@v4
- name: Configure git identity
run: |
git config --global user.email "lawrence@nymtech.net"
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
- name: Create tag
run: |
git tag -a ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} -m "Version ${{ steps.get_version.outputs.result }}"
git push origin ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }}
- name: BuildAndPushImageOnHarbor
run: |
docker build -f ${{ env.WORKING_DIRECTORY }}/Dockerfile . -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:${{ steps.get_version.outputs.result }} -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:latest
docker push harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }} --all-tags
Generated
+909 -430
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -33,11 +33,13 @@ members = [
"common/commands",
"common/config",
"common/cosmwasm-smart-contracts/coconut-dkg",
"common/cosmwasm-smart-contracts/contracts-common", "common/cosmwasm-smart-contracts/easy_addr",
"common/cosmwasm-smart-contracts/contracts-common",
"common/cosmwasm-smart-contracts/easy_addr",
"common/cosmwasm-smart-contracts/ecash-contract",
"common/cosmwasm-smart-contracts/group-contract",
"common/cosmwasm-smart-contracts/mixnet-contract",
"common/cosmwasm-smart-contracts/multisig-contract",
"common/cosmwasm-smart-contracts/nym-pool-contract",
"common/cosmwasm-smart-contracts/vesting-contract",
"common/credential-storage",
"common/credential-utils",
@@ -64,6 +66,8 @@ members = [
"common/nym-id",
"common/nym-metrics",
"common/nym_offline_compact_ecash",
"common/nymnoise",
"common/nymnoise/keys",
"common/nymsphinx",
"common/nymsphinx/acknowledgements",
"common/nymsphinx/addressing",
@@ -132,7 +136,8 @@ members = [
"tools/internal/testnet-manager",
"tools/internal/testnet-manager",
"tools/internal/testnet-manager/dkg-bypass-contract",
"tools/internal/testnet-manager/dkg-bypass-contract", "tools/internal/validator-status-check",
"tools/internal/testnet-manager/dkg-bypass-contract",
"tools/internal/validator-status-check",
"tools/nym-cli",
"tools/nym-id-cli",
"tools/nym-nr-query",
@@ -200,7 +205,7 @@ bloomfilter = "3.0.1"
bs58 = "0.5.1"
bytecodec = "0.4.15"
bytes = "1.10.1"
cargo_metadata = "0.18.1"
cargo_metadata = "0.19.2"
celes = "2.6.0"
cfg-if = "1.0.0"
chacha20 = "0.9.0"
@@ -307,8 +312,9 @@ serde_with = "3.9.0"
serde_yaml = "0.9.25"
sha2 = "0.10.9"
si-scale = "0.2.3"
snow = "0.9.6"
sphinx-packet = "=0.6.0"
sqlx = "0.7.4"
sqlx = "0.8.6"
strum = "0.26"
strum_macros = "0.26"
subtle-encoding = "0.5"
@@ -316,10 +322,10 @@ syn = "1"
sysinfo = "0.33.0"
tap = "1.0.1"
tar = "0.4.44"
tempfile = "3.19"
tempfile = "3.20"
thiserror = "2.0"
time = "0.3.41"
tokio = "1.44"
tokio = "1.45"
tokio-postgres = "0.7"
tokio-stream = "0.1.17"
tokio-test = "0.4.4"
@@ -346,7 +352,6 @@ utoipauto = "0.2"
uuid = "*"
vergen = { version = "=8.3.1", default-features = false }
walkdir = "2"
wasm-bindgen-test = "0.3.49"
x25519-dalek = "2.0.0"
zeroize = "1.7.0"
@@ -394,6 +399,7 @@ serde-wasm-bindgen = "0.6.5"
tsify = "0.4.5"
wasm-bindgen = "0.2.99"
wasm-bindgen-futures = "0.4.49"
wasm-bindgen-test = "0.3.49"
wasmtimer = "0.4.1"
web-sys = "0.3.76"
+1 -1
View File
@@ -133,7 +133,7 @@ clippy: sdk-wasm-lint
# Build contracts ready for deploy
# -----------------------------------------------------------------------------
CONTRACTS=vesting_contract mixnet_contract nym_ecash cw3_flex_multisig cw4_group nym_coconut_dkg
CONTRACTS=vesting_contract mixnet_contract nym_ecash cw3_flex_multisig cw4_group nym_coconut_dkg nym_pool_contract
CONTRACTS_WASM=$(addsuffix .wasm, $(CONTRACTS))
CONTRACTS_OUT_DIR=contracts/target/wasm32-unknown-unknown/release
@@ -2048,10 +2048,11 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.4.tgz",
"integrity": "sha512-m/4FxX17SUvz4lJ5WPXOHDUuCwIqXLfLHs1s0uZ3oYjhoXlx9csYxaOa0ElDEJ+h8Q4iJ1s+lTMbiCa4EXIJqg==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
@@ -6095,9 +6096,9 @@
}
},
"http-proxy-middleware": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.4.tgz",
"integrity": "sha512-m/4FxX17SUvz4lJ5WPXOHDUuCwIqXLfLHs1s0uZ3oYjhoXlx9csYxaOa0ElDEJ+h8Q4iJ1s+lTMbiCa4EXIJqg==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"requires": {
"@types/http-proxy": "^1.17.8",
@@ -108,7 +108,7 @@ impl GatewayClient {
#[cfg(feature = "verify")]
pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> {
// use gateways key as a ref to an x25519_dalek key
let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key);
let dh = gateway_key.inner().diffie_hellman(&self.pub_key);
// TODO: change that to use our nym_crypto::hmac module instead
#[allow(clippy::expect_used)]
@@ -117,7 +117,7 @@ impl GatewayClient {
#[cfg(feature = "verify")]
pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> {
// use gateways key as a ref to an x25519_dalek key
let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key);
let dh = gateway_key.inner().diffie_hellman(&self.pub_key);
// TODO: change that to use our nym_crypto::hmac module instead
#[allow(clippy::expect_used)]
@@ -117,7 +117,7 @@ impl GatewayClient {
#[cfg(feature = "verify")]
pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> {
// use gateways key as a ref to an x25519_dalek key
let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key);
let dh = gateway_key.inner().diffie_hellman(&self.pub_key);
// TODO: change that to use our nym_crypto::hmac module instead
#[allow(clippy::expect_used)]
@@ -169,7 +169,7 @@ impl GatewayClient {
#[cfg(feature = "verify")]
pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> {
// use gateways key as a ref to an x25519_dalek key
let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key);
let dh = gateway_key.inner().diffie_hellman(&self.pub_key);
// TODO: change that to use our nym_crypto::hmac module instead
#[allow(clippy::expect_used)]
@@ -169,7 +169,7 @@ impl GatewayClient {
#[cfg(feature = "verify")]
pub fn verify(&self, gateway_key: &PrivateKey, nonce: u64) -> Result<(), Error> {
// use gateways key as a ref to an x25519_dalek key
let dh = (gateway_key.as_ref()).diffie_hellman(&self.pub_key);
let dh = gateway_key.inner().diffie_hellman(&self.pub_key);
// TODO: change that to use our nym_crypto::hmac module instead
#[allow(clippy::expect_used)]
+3 -1
View File
@@ -44,7 +44,6 @@ nym-sphinx = { path = "../nymsphinx" }
nym-statistics-common = { path = "../statistics" }
nym-pemstore = { path = "../pemstore" }
nym-topology = { path = "../topology", features = ["persistence"] }
nym-mixnet-client = { path = "../client-libs/mixnet-client", default-features = false }
nym-validator-client = { path = "../client-libs/validator-client", default-features = false }
nym-task = { path = "../task" }
nym-credentials-interface = { path = "../credentials-interface" }
@@ -57,6 +56,9 @@ nym-client-core-surb-storage = { path = "./surb-storage" }
nym-client-core-gateways-storage = { path = "./gateways-storage" }
nym-ecash-time = { path = "../ecash-time" }
[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
nym-mixnet-client = { path = "../client-libs/mixnet-client", default-features = false }
### For serving prometheus metrics
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.hyper]
workspace = true
@@ -87,7 +87,7 @@ impl StorageManager {
sqlx::query!("SELECT EXISTS (SELECT 1 FROM registered_gateway WHERE gateway_id_bs58 = ?) AS 'exists'", gateway_id)
.fetch_one(&self.connection_pool)
.await
.map(|result| result.exists == Some(1))
.map(|result| result.exists == 1)
}
pub(crate) async fn maybe_get_registered_gateway(
@@ -12,7 +12,7 @@ use crate::client::topology_control::{TopologyAccessor, TopologyReadPermit};
use nym_sphinx::acknowledgements::AckKey;
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::{AnonymousSenderTag, RepliableMessage, ReplyMessage};
use nym_sphinx::anonymous_replies::{ReplySurb, SurbEncryptionKey};
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use nym_sphinx::chunking::fragment::{Fragment, FragmentIdentifier};
use nym_sphinx::message::NymMessage;
use nym_sphinx::params::{PacketSize, PacketType};
@@ -44,7 +44,10 @@ pub enum PreparationError {
}
impl PreparationError {
fn return_surbs(self, returned_surbs: Vec<ReplySurb>) -> SurbWrappedPreparationError {
fn return_surbs(
self,
returned_surbs: Vec<ReplySurbWithKeyRotation>,
) -> SurbWrappedPreparationError {
SurbWrappedPreparationError {
source: self,
returned_surbs: Some(returned_surbs),
@@ -58,7 +61,7 @@ pub struct SurbWrappedPreparationError {
#[source]
source: PreparationError,
returned_surbs: Option<Vec<ReplySurb>>,
returned_surbs: Option<Vec<ReplySurbWithKeyRotation>>,
}
impl<T> From<T> for SurbWrappedPreparationError
@@ -268,10 +271,10 @@ where
}
}
async fn generate_reply_surbs_with_keys(
async fn generate_reply_surbs(
&mut self,
amount: usize,
) -> Result<(Vec<ReplySurb>, Vec<SurbEncryptionKey>), PreparationError> {
) -> Result<Vec<ReplySurbWithKeyRotation>, PreparationError> {
let topology_permit = self.topology_access.get_read_permit().await;
let topology = self.get_topology(&topology_permit)?;
@@ -281,19 +284,14 @@ where
topology,
)?;
let reply_keys = reply_surbs
.iter()
.map(|s| *s.encryption_key())
.collect::<Vec<_>>();
Ok((reply_surbs, reply_keys))
Ok(reply_surbs)
}
pub(crate) async fn try_send_single_surb_message(
&mut self,
target: AnonymousSenderTag,
message: ReplyMessage,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
is_extra_surb_request: bool,
) -> Result<(), SurbWrappedPreparationError> {
let msg = NymMessage::new_reply(message);
@@ -347,7 +345,7 @@ where
pub(crate) async fn try_request_additional_reply_surbs(
&mut self,
from: AnonymousSenderTag,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
amount: u32,
) -> Result<(), SurbWrappedPreparationError> {
debug!("requesting {amount} reply SURBs from {from}");
@@ -387,7 +385,7 @@ where
&mut self,
target: AnonymousSenderTag,
fragments: Vec<FragmentWithMaxRetransmissions>,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
lane: TransmissionLane,
) -> Result<(), SurbWrappedPreparationError> {
// TODO: technically this is performing an unnecessary cloning, but in the grand scheme of things
@@ -404,7 +402,7 @@ where
&mut self,
target: AnonymousSenderTag,
fragments: Vec<(TransmissionLane, FragmentWithMaxRetransmissions)>,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Result<(), SurbWrappedPreparationError> {
let prepared_fragments = self
.prepare_reply_chunks_for_sending(
@@ -541,8 +539,12 @@ where
) -> Result<(), PreparationError> {
debug!("Sending additional reply SURBs with packet type {packet_type}");
let sender_tag = self.get_or_create_sender_tag(&recipient);
let (reply_surbs, reply_keys) =
self.generate_reply_surbs_with_keys(amount as usize).await?;
let reply_surbs = self.generate_reply_surbs(amount as usize).await?;
let reply_keys = reply_surbs
.iter()
.map(|s| *s.encryption_key())
.collect::<Vec<_>>();
let message = NymMessage::new_repliable(RepliableMessage::new_additional_surbs(
self.config.use_legacy_sphinx_format,
@@ -579,9 +581,12 @@ where
) -> Result<(), SurbWrappedPreparationError> {
debug!("Sending message with reply SURBs with packet type {packet_type}");
let sender_tag = self.get_or_create_sender_tag(&recipient);
let (reply_surbs, reply_keys) = self
.generate_reply_surbs_with_keys(num_reply_surbs as usize)
.await?;
let reply_surbs = self.generate_reply_surbs(num_reply_surbs as usize).await?;
let reply_keys = reply_surbs
.iter()
.map(|s| *s.encryption_key())
.collect::<Vec<_>>();
let message = NymMessage::new_repliable(RepliableMessage::new_data(
self.config.use_legacy_sphinx_format,
@@ -629,7 +634,7 @@ where
pub(crate) async fn prepare_reply_chunks_for_sending(
&mut self,
fragments: Vec<Fragment>,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Result<Vec<PreparedFragment>, SurbWrappedPreparationError> {
debug_assert_eq!(
fragments.len(),
@@ -665,7 +670,7 @@ where
pub(crate) async fn try_prepare_single_reply_chunk_for_sending(
&mut self,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
chunk: Fragment,
) -> Result<PreparedFragment, SurbWrappedPreparationError> {
let topology_permit = self.topology_access.get_read_permit().await;
@@ -11,7 +11,7 @@ use futures::StreamExt;
use log::{debug, error, info, trace, warn};
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_sphinx::anonymous_replies::ReplySurb;
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use nym_sphinx::chunking::fragment::FragmentIdentifier;
use nym_task::connections::{ConnectionId, TransmissionLane};
use nym_task::TaskClient;
@@ -499,7 +499,7 @@ where
async fn handle_received_surbs(
&mut self,
from: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
from_surb_request: bool,
) {
trace!("handling received surbs");
@@ -6,7 +6,7 @@ use futures::channel::{mpsc, oneshot};
use log::error;
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_sphinx::anonymous_replies::ReplySurb;
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use nym_task::connections::{ConnectionId, TransmissionLane};
use std::sync::Weak;
@@ -81,7 +81,7 @@ impl ReplyControllerSender {
pub(crate) fn send_additional_surbs(
&self,
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
from_surb_request: bool,
) -> Result<(), ReplyControllerSenderError> {
self.0
@@ -167,7 +167,7 @@ pub enum ReplyControllerMessage {
AdditionalSurbs {
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
from_surb_request: bool,
},
@@ -4,7 +4,7 @@
use async_trait::async_trait;
use log::{debug, error, warn};
use nym_topology::provider_trait::TopologyProvider;
use nym_topology::NymTopology;
use nym_topology::{NymTopology, NymTopologyMetadata};
use nym_validator_client::UserAgent;
use rand::prelude::SliceRandom;
use rand::thread_rng;
@@ -89,55 +89,84 @@ impl NymApiTopologyProvider {
let rewarded_set_fut = self.validator_client.get_current_rewarded_set();
let topology = if self.config.use_extended_topology {
let all_nodes_fut = self.validator_client.get_all_basic_nodes();
let all_nodes_fut = self.validator_client.get_all_basic_nodes_with_metadata();
// Join rewarded_set_fut and all_nodes_fut concurrently
let (rewarded_set, all_nodes) = futures::try_join!(rewarded_set_fut, all_nodes_fut)
let (rewarded_set, all_nodes_res) = futures::try_join!(rewarded_set_fut, all_nodes_fut)
.inspect_err(|err| error!("failed to get network nodes: {err}"))
.ok()?;
let metadata = all_nodes_res.metadata;
let all_nodes = all_nodes_res.nodes;
debug!(
"there are {} nodes on the network (before filtering)",
all_nodes.len()
);
let mut topology = NymTopology::new_empty(rewarded_set);
topology.add_additional_nodes(all_nodes.iter().filter(|n| {
n.performance.round_to_integer() >= self.config.min_node_performance()
}));
let nodes_filtered = all_nodes
.into_iter()
.filter(|n| n.performance.round_to_integer() >= self.config.min_node_performance())
.collect::<Vec<_>>();
topology
NymTopology::new(
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id),
rewarded_set,
Vec::new(),
)
.with_skimmed_nodes(&nodes_filtered)
} else {
// if we're not using extended topology, we're only getting active set mixnodes and gateways
let mixnodes_fut = self
.validator_client
.get_all_basic_active_mixing_assigned_nodes();
.get_all_basic_active_mixing_assigned_nodes_with_metadata();
// TODO: we really should be getting ACTIVE gateways only
let gateways_fut = self.validator_client.get_all_basic_entry_assigned_nodes();
let gateways_fut = self
.validator_client
.get_all_basic_entry_assigned_nodes_v2();
let (rewarded_set, mixnodes, gateways) =
let (rewarded_set, mixnodes_res, gateways_res) =
futures::try_join!(rewarded_set_fut, mixnodes_fut, gateways_fut)
.inspect_err(|err| {
error!("failed to get network nodes: {err}");
})
.ok()?;
let metadata = mixnodes_res.metadata;
let mixnodes = mixnodes_res.nodes;
if gateways_res.metadata != metadata {
warn!("inconsistent nodes metadata between mixnodes and gateways calls! {metadata:?} and {:?}", gateways_res.metadata);
return None;
}
let gateways = gateways_res.nodes;
debug!(
"there are {} mixnodes and {} gateways in total (before performance filtering)",
mixnodes.len(),
gateways.len()
);
let mut topology = NymTopology::new_empty(rewarded_set);
topology.add_additional_nodes(mixnodes.iter().filter(|m| {
m.performance.round_to_integer() >= self.config.min_mixnode_performance
}));
topology.add_additional_nodes(gateways.iter().filter(|m| {
m.performance.round_to_integer() >= self.config.min_gateway_performance
}));
let mut nodes = Vec::new();
for mix in mixnodes {
if mix.performance.round_to_integer() >= self.config.min_mixnode_performance {
nodes.push(mix)
}
}
for gateway in gateways {
if gateway.performance.round_to_integer() >= self.config.min_gateway_performance {
nodes.push(gateway)
}
}
topology
NymTopology::new(
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id),
rewarded_set,
Vec::new(),
)
.with_skimmed_nodes(&nodes)
};
if !topology.is_minimally_routable() {
+1 -1
View File
@@ -107,7 +107,7 @@ pub async fn gateways_for_init<R: Rng>(
log::debug!("Fetching list of gateways from: {nym_api}");
let gateways = client.get_all_basic_entry_assigned_nodes().await?;
let gateways = client.get_all_basic_entry_assigned_nodes_v2().await?.nodes;
info!("nym api reports {} gateways", gateways.len());
log::trace!("Gateways: {:#?}", gateways);
@@ -0,0 +1,8 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: Apache-2.0
*/
-- default value of 0 implies 'unknown' variant
ALTER TABLE reply_surb
ADD COLUMN encoded_key_rotation TINYINT NOT NULL DEFAULT 0;
@@ -205,7 +205,10 @@ impl StorageManager {
) -> Result<Vec<StoredReplySurb>, sqlx::Error> {
sqlx::query_as!(
StoredReplySurb,
"SELECT * FROM reply_surb WHERE reply_surb_sender_id = ?",
r#"
SELECT reply_surb_sender_id, reply_surb, encoded_key_rotation as "encoded_key_rotation: u8" FROM reply_surb
WHERE reply_surb_sender_id = ?
"#,
sender_id
)
.fetch_all(&self.connection_pool)
@@ -230,10 +233,11 @@ impl StorageManager {
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO reply_surb(reply_surb_sender_id, reply_surb) VALUES (?, ?);
INSERT INTO reply_surb(reply_surb_sender_id, reply_surb, encoded_key_rotation) VALUES (?, ?, ?);
"#,
stored_reply_surb.reply_surb_sender_id,
stored_reply_surb.reply_surb
stored_reply_surb.reply_surb,
stored_reply_surb.encoded_key_rotation
)
.execute(&self.connection_pool)
.await?;
@@ -8,8 +8,10 @@ use nym_crypto::Digest;
use nym_sphinx::addressing::clients::{Recipient, RecipientBytes};
use nym_sphinx::anonymous_replies::encryption_key::EncryptionKeyDigest;
use nym_sphinx::anonymous_replies::requests::{AnonymousSenderTag, SENDER_TAG_SIZE};
use nym_sphinx::anonymous_replies::{ReplySurb, SurbEncryptionKey, SurbEncryptionKeySize};
use nym_sphinx::params::ReplySurbKeyDigestAlgorithm;
use nym_sphinx::anonymous_replies::{
ReplySurb, ReplySurbWithKeyRotation, SurbEncryptionKey, SurbEncryptionKeySize,
};
use nym_sphinx::params::{ReplySurbKeyDigestAlgorithm, SphinxKeyRotation};
#[derive(Debug, Clone)]
pub struct StoredSenderTag {
@@ -146,24 +148,40 @@ impl TryFrom<StoredSurbSender> for (AnonymousSenderTag, i64) {
pub struct StoredReplySurb {
pub reply_surb_sender_id: i64,
pub reply_surb: Vec<u8>,
// encodes only whether it's 'even', 'odd' or 'unknown' (default)
// and not the whole id because that's redundant
pub encoded_key_rotation: u8,
}
impl StoredReplySurb {
pub fn new(reply_surb_sender_id: i64, reply_surb: &ReplySurb) -> Self {
pub fn new(reply_surb_sender_id: i64, reply_surb: &ReplySurbWithKeyRotation) -> Self {
StoredReplySurb {
reply_surb_sender_id,
reply_surb: reply_surb.to_bytes(),
reply_surb: reply_surb.inner_reply_surb().to_bytes(),
encoded_key_rotation: reply_surb.key_rotation() as u8,
}
}
}
impl TryFrom<StoredReplySurb> for ReplySurb {
impl TryFrom<StoredReplySurb> for ReplySurbWithKeyRotation {
type Error = StorageError;
fn try_from(value: StoredReplySurb) -> Result<Self, Self::Error> {
ReplySurb::from_bytes(&value.reply_surb).map_err(|err| StorageError::CorruptedData {
details: format!("failed to recover the reply surb: {err}"),
})
let key_rotation =
SphinxKeyRotation::try_from(value.encoded_key_rotation).map_err(|err| {
StorageError::CorruptedData {
details: format!("stored key rotation was malformed: {err}"),
}
})?;
let reply_surb = ReplySurb::from_bytes(&value.reply_surb).map_err(|err| {
StorageError::CorruptedData {
details: format!("failed to recover the reply surb: {err}"),
}
})?;
Ok(reply_surb.with_key_rotation(key_rotation))
}
}
@@ -5,7 +5,7 @@ use dashmap::iter::Iter;
use dashmap::DashMap;
use log::trace;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_sphinx::anonymous_replies::ReplySurb;
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -134,7 +134,7 @@ impl ReceivedReplySurbsMap {
&self,
target: &AnonymousSenderTag,
amount: usize,
) -> (Option<Vec<ReplySurb>>, usize) {
) -> (Option<Vec<ReplySurbWithKeyRotation>>, usize) {
if let Some(mut entry) = self.inner.data.get_mut(target) {
let surbs_left = entry.items_left();
if surbs_left < self.min_surb_threshold() + amount {
@@ -150,7 +150,7 @@ impl ReceivedReplySurbsMap {
pub fn get_reply_surb_ignoring_threshold(
&self,
target: &AnonymousSenderTag,
) -> Option<(Option<ReplySurb>, usize)> {
) -> Option<(Option<ReplySurbWithKeyRotation>, usize)> {
self.inner
.data
.get_mut(target)
@@ -160,7 +160,7 @@ impl ReceivedReplySurbsMap {
pub fn get_reply_surb(
&self,
target: &AnonymousSenderTag,
) -> Option<(Option<ReplySurb>, usize)> {
) -> Option<(Option<ReplySurbWithKeyRotation>, usize)> {
self.inner.data.get_mut(target).map(|mut entry| {
let surbs_left = entry.items_left();
if surbs_left < self.min_surb_threshold() {
@@ -171,7 +171,7 @@ impl ReceivedReplySurbsMap {
})
}
pub fn insert_surbs<I: IntoIterator<Item = ReplySurb>>(
pub fn insert_surbs<I: IntoIterator<Item = ReplySurbWithKeyRotation>>(
&self,
target: &AnonymousSenderTag,
surbs: I,
@@ -189,14 +189,14 @@ impl ReceivedReplySurbsMap {
pub struct ReceivedReplySurbs {
// in the future we'd probably want to put extra data here to indicate when the SURBs got received
// so we could invalidate entries from the previous key rotations
data: VecDeque<ReplySurb>,
data: VecDeque<ReplySurbWithKeyRotation>,
pending_reception: u32,
surbs_last_received_at_timestamp: i64,
}
impl ReceivedReplySurbs {
fn new(initial_surbs: VecDeque<ReplySurb>) -> Self {
fn new(initial_surbs: VecDeque<ReplySurbWithKeyRotation>) -> Self {
ReceivedReplySurbs {
data: initial_surbs,
pending_reception: 0,
@@ -206,7 +206,7 @@ impl ReceivedReplySurbs {
#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))]
pub fn new_retrieved(
surbs: Vec<ReplySurb>,
surbs: Vec<ReplySurbWithKeyRotation>,
surbs_last_received_at_timestamp: i64,
) -> ReceivedReplySurbs {
ReceivedReplySurbs {
@@ -217,7 +217,7 @@ impl ReceivedReplySurbs {
}
#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))]
pub fn surbs_ref(&self) -> &VecDeque<ReplySurb> {
pub fn surbs_ref(&self) -> &VecDeque<ReplySurbWithKeyRotation> {
&self.data
}
@@ -243,7 +243,10 @@ impl ReceivedReplySurbs {
self.pending_reception = 0;
}
pub fn get_reply_surbs(&mut self, amount: usize) -> (Option<Vec<ReplySurb>>, usize) {
pub fn get_reply_surbs(
&mut self,
amount: usize,
) -> (Option<Vec<ReplySurbWithKeyRotation>>, usize) {
if self.items_left() < amount {
(None, self.items_left())
} else {
@@ -252,11 +255,11 @@ impl ReceivedReplySurbs {
}
}
pub fn get_reply_surb(&mut self) -> (Option<ReplySurb>, usize) {
pub fn get_reply_surb(&mut self) -> (Option<ReplySurbWithKeyRotation>, usize) {
(self.pop_surb(), self.items_left())
}
fn pop_surb(&mut self) -> Option<ReplySurb> {
fn pop_surb(&mut self) -> Option<ReplySurbWithKeyRotation> {
self.data.pop_front()
}
@@ -265,7 +268,10 @@ impl ReceivedReplySurbs {
}
// realistically we're always going to be getting multiple surbs at once
pub fn insert_reply_surbs<I: IntoIterator<Item = ReplySurb>>(&mut self, surbs: I) {
pub fn insert_reply_surbs<I: IntoIterator<Item = ReplySurbWithKeyRotation>>(
&mut self,
surbs: I,
) {
let mut v = surbs.into_iter().collect::<VecDeque<_>>();
trace!("storing {} surbs in the storage", v.len());
self.data.append(&mut v);
@@ -21,8 +21,8 @@ use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::registration::handshake::client_handshake;
use nym_gateway_requests::{
BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersionExt,
SensitiveServerResponse, ServerResponse, SharedGatewayKey, SharedSymmetricKey,
CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION,
GatewayRequestsError, SensitiveServerResponse, ServerResponse, SharedGatewayKey,
SharedSymmetricKey, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION,
};
use nym_sphinx::forwarding::packet::MixPacket;
use nym_statistics_common::clients::connection::ConnectionStatsEvent;
@@ -662,6 +662,7 @@ impl<C, St> GatewayClient<C, St> {
let supports_aes_gcm_siv = gw_protocol.supports_aes256_gcm_siv();
let supports_auth_v2 = gw_protocol.supports_authenticate_v2();
let supports_key_rotation_info = gw_protocol.supports_key_rotation_packet();
if !supports_aes_gcm_siv {
warn!("this gateway is on an old version that doesn't support AES256-GCM-SIV");
@@ -669,6 +670,9 @@ impl<C, St> GatewayClient<C, St> {
if !supports_auth_v2 {
warn!("this gateway is on an old version that doesn't support authentication v2")
}
if !supports_key_rotation_info {
warn!("this gateway is on an old version that doesn't support key rotation packets")
}
if self.authenticated {
debug!("Already authenticated");
@@ -849,6 +853,22 @@ impl<C, St> GatewayClient<C, St> {
}
}
fn mix_packet_to_ws_message(&self, packet: MixPacket) -> Result<Message, GatewayRequestsError> {
// note: into_ws_message encrypts the requests and adds a MAC on it. Perhaps it should
// be more explicit in the naming?
let req = if self.negotiated_protocol.supports_key_rotation_packet() {
BinaryRequest::ForwardSphinxV2 { packet }
} else {
BinaryRequest::ForwardSphinx { packet }
};
req.into_ws_message(
self.shared_key
.as_ref()
.expect("no shared key present even though we're authenticated!"),
)
}
pub async fn batch_send_mix_packets(
&mut self,
packets: Vec<MixPacket>,
@@ -877,13 +897,7 @@ impl<C, St> GatewayClient<C, St> {
let messages: Result<Vec<_>, _> = packets
.into_iter()
.map(|mix_packet| {
BinaryRequest::ForwardSphinx { packet: mix_packet }.into_ws_message(
self.shared_key
.as_ref()
.expect("no shared key present even though we're authenticated!"),
)
})
.map(|mix_packet| self.mix_packet_to_ws_message(mix_packet))
.collect();
if let Err(err) = self
@@ -949,13 +963,8 @@ impl<C, St> GatewayClient<C, St> {
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
// note: into_ws_message encrypts the requests and adds a MAC on it. Perhaps it should
// be more explicit in the naming?
let msg = BinaryRequest::ForwardSphinx { packet: mix_packet }.into_ws_message(
self.shared_key
.as_ref()
.expect("no shared key present even though we're authenticated!"),
)?;
let msg = self.mix_packet_to_ws_message(mix_packet)?;
self.send_with_reconnection_on_failure(msg).await
}
+6 -1
View File
@@ -16,9 +16,14 @@ tokio-util = { workspace = true, features = ["codec"], optional = true }
tokio-stream = { workspace = true }
# internal
nym-noise = { path = "../../nymnoise" }
nym-sphinx = { path = "../../nymsphinx" }
nym-task = { path = "../../task", optional = true }
[features]
default = ["client"]
client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"]
client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"]
[dev-dependencies]
nym-crypto = { path = "../../crypto" }
rand = { workspace = true }
+48 -25
View File
@@ -3,11 +3,11 @@
use dashmap::DashMap;
use futures::StreamExt;
use nym_sphinx::addressing::nodes::NymNodeRoutingAddress;
use nym_noise::config::NoiseConfig;
use nym_noise::upgrade_noise_initiator;
use nym_sphinx::forwarding::packet::MixPacket;
use nym_sphinx::framing::codec::NymCodec;
use nym_sphinx::framing::packet::FramedNymPacket;
use nym_sphinx::params::PacketType;
use nym_sphinx::NymPacket;
use std::io;
use std::net::SocketAddr;
use std::ops::Deref;
@@ -49,23 +49,19 @@ impl Config {
pub trait SendWithoutResponse {
// Without response in this context means we will not listen for anything we might get back (not
// that we should get anything), including any possible io errors
fn send_without_response(
&self,
address: NymNodeRoutingAddress,
packet: NymPacket,
packet_type: PacketType,
) -> io::Result<()>;
fn send_without_response(&self, packet: MixPacket) -> io::Result<()>;
}
pub struct Client {
active_connections: ActiveConnections,
noise_config: NoiseConfig,
connections_count: Arc<AtomicUsize>,
config: Config,
}
#[derive(Default, Clone)]
pub struct ActiveConnections {
inner: Arc<DashMap<NymNodeRoutingAddress, ConnectionSender>>,
inner: Arc<DashMap<SocketAddr, ConnectionSender>>,
}
impl ActiveConnections {
@@ -82,7 +78,7 @@ impl ActiveConnections {
}
impl Deref for ActiveConnections {
type Target = DashMap<NymNodeRoutingAddress, ConnectionSender>;
type Target = DashMap<SocketAddr, ConnectionSender>;
fn deref(&self) -> &Self::Target {
&self.inner
}
@@ -104,6 +100,7 @@ impl ConnectionSender {
struct ManagedConnection {
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: ReceiverStream<FramedNymPacket>,
connection_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
@@ -112,12 +109,14 @@ struct ManagedConnection {
impl ManagedConnection {
fn new(
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: mpsc::Receiver<FramedNymPacket>,
connection_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
) -> Self {
ManagedConnection {
address,
noise_config,
message_receiver: ReceiverStream::new(message_receiver),
connection_timeout,
current_reconnection,
@@ -132,9 +131,21 @@ impl ManagedConnection {
Ok(stream_res) => match stream_res {
Ok(stream) => {
debug!("Managed to establish connection to {}", self.address);
// if we managed to connect, reset the reconnection count (whatever it might have been)
let noise_stream =
match upgrade_noise_initiator(stream, &self.noise_config).await {
Ok(noise_stream) => noise_stream,
Err(err) => {
error!("Failed to perform Noise handshake with {address} - {err}");
// we failed to finish the noise handshake - increase reconnection attempt
self.current_reconnection.fetch_add(1, Ordering::SeqCst);
return;
}
};
// if we managed to connect AND do the noise handshake, reset the reconnection count (whatever it might have been)
self.current_reconnection.store(0, Ordering::Release);
Framed::new(stream, NymCodec)
debug!("Noise initiator handshake completed for {:?}", address);
Framed::new(noise_stream, NymCodec)
}
Err(err) => {
debug!("failed to establish connection to {address} (err: {err})",);
@@ -167,9 +178,14 @@ impl ManagedConnection {
}
impl Client {
pub fn new(config: Config, connections_count: Arc<AtomicUsize>) -> Client {
pub fn new(
config: Config,
noise_config: NoiseConfig,
connections_count: Arc<AtomicUsize>,
) -> Client {
Client {
active_connections: Default::default(),
noise_config,
connections_count,
config,
}
@@ -196,7 +212,7 @@ impl Client {
}
}
fn make_connection(&self, address: NymNodeRoutingAddress, pending_packet: FramedNymPacket) {
fn make_connection(&self, address: SocketAddr, pending_packet: FramedNymPacket) {
let (sender, receiver) = mpsc::channel(self.config.maximum_connection_buffer_size);
// this CAN'T fail because we just created the channel which has a non-zero capacity
@@ -224,6 +240,7 @@ impl Client {
let initial_connection_timeout = self.config.initial_connection_timeout;
let connections_count = self.connections_count.clone();
let noise_config = self.noise_config.clone();
tokio::spawn(async move {
// before executing the manager, wait for what was specified, if anything
if let Some(backoff) = backoff {
@@ -233,7 +250,8 @@ impl Client {
connections_count.fetch_add(1, Ordering::SeqCst);
ManagedConnection::new(
address.into(),
address,
noise_config,
receiver,
initial_connection_timeout,
current_reconnection_attempt,
@@ -246,18 +264,14 @@ impl Client {
}
impl SendWithoutResponse for Client {
fn send_without_response(
&self,
address: NymNodeRoutingAddress,
packet: NymPacket,
packet_type: PacketType,
) -> io::Result<()> {
trace!("Sending packet to {address:?}");
let framed_packet = FramedNymPacket::new(packet, packet_type);
fn send_without_response(&self, packet: MixPacket) -> io::Result<()> {
let address = packet.next_hop_address();
trace!("Sending packet to {address}");
let framed_packet = FramedNymPacket::from(packet);
let Some(sender) = self.active_connections.get_mut(&address) else {
// there was never a connection to begin with
debug!("establishing initial connection to {}", address);
debug!("establishing initial connection to {address}");
// it's not a 'big' error, but we did not manage to send the packet, but queue the packet
// for sending for as soon as the connection is created
self.make_connection(address, framed_packet);
@@ -302,8 +316,12 @@ impl SendWithoutResponse for Client {
#[cfg(test)]
mod tests {
use super::*;
use nym_crypto::asymmetric::x25519;
use nym_noise::config::NoiseNetworkView;
use rand::rngs::OsRng;
fn dummy_client() -> Client {
let mut rng = OsRng; //for test only, so we don't care if rng source isn't crypto grade
Client::new(
Config {
initial_reconnection_backoff: Duration::from_millis(10_000),
@@ -311,6 +329,11 @@ mod tests {
initial_connection_timeout: Duration::from_millis(1_500),
maximum_connection_buffer_size: 128,
},
NoiseConfig::new(
Arc::new(x25519::KeyPair::new(&mut rng)),
NoiseNetworkView::new_empty(),
Duration::from_millis(1_500),
),
Default::default(),
)
}
+106 -63
View File
@@ -25,7 +25,9 @@ use nym_api_requests::models::{
NymNodeDescription, RewardEstimationResponse, StakeSaturationResponse,
};
use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated};
use nym_api_requests::nym_nodes::{NodesByAddressesResponse, SkimmedNode};
use nym_api_requests::nym_nodes::{
NodesByAddressesResponse, SemiSkimmedNodesWithMetadata, SkimmedNode, SkimmedNodesWithMetadata,
};
use nym_coconut_dkg_common::types::EpochId;
use nym_http_api_client::UserAgent;
use nym_mixnet_contract_common::EpochRewardedSet;
@@ -46,6 +48,46 @@ use crate::rpc::http_client;
#[cfg(feature = "http-client")]
use crate::{DirectSigningHttpRpcValidatorClient, HttpRpcClient, QueryHttpRpcValidatorClient};
// a simple helper macro to define to repeatedly call a paged query until a full response is constructed
macro_rules! collect_paged_skimmed_v2 {
( $self:ident, $f: ident ) => {{
// unroll first loop iteration in order to obtain the metadata
let mut page = 0;
let res = $self
.nym_api
.$f(false, Some(page), None, $self.use_bincode)
.await?;
let mut nodes = res.nodes.data;
let metadata = res.metadata;
if res.nodes.pagination.total == nodes.len() {
return Ok(SkimmedNodesWithMetadata::new(nodes, metadata));
}
page += 1;
loop {
let mut res = $self
.nym_api
.$f(false, Some(page), None, $self.use_bincode)
.await?;
if metadata != res.metadata {
return Err(ValidatorClientError::InconsistentPagedMetadata);
}
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(SkimmedNodesWithMetadata::new(nodes, metadata))
}};
}
#[must_use]
#[derive(Debug, Clone)]
pub struct Config {
@@ -200,11 +242,11 @@ impl<C, S> Client<C, S> {
#[allow(deprecated)]
impl<C, S> Client<C, S> {
pub fn api_url(&self) -> &Url {
self.nym_api.current_url()
self.nym_api.current_url().as_ref()
}
pub fn change_nym_api(&mut self, new_endpoint: Url) {
self.nym_api.change_base_url(new_endpoint)
self.nym_api.change_base_urls(vec![new_endpoint.into()])
}
#[deprecated]
@@ -402,11 +444,11 @@ impl NymApiClient {
}
pub fn api_url(&self) -> &Url {
self.nym_api.current_url()
self.nym_api.current_url().as_ref()
}
pub fn change_nym_api(&mut self, new_endpoint: Url) {
self.nym_api.change_base_url(new_endpoint);
self.nym_api.change_base_urls(vec![new_endpoint.into()]);
}
#[deprecated(note = "use get_all_basic_active_mixing_assigned_nodes instead")]
@@ -425,92 +467,93 @@ impl NymApiClient {
/// retrieve basic information for nodes are capable of operating as an entry gateway
/// this includes legacy gateways and nym-nodes
#[deprecated(note = "use get_all_basic_entry_assigned_nodes_with_metadata instead")]
pub async fn get_all_basic_entry_assigned_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// 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 nodes = Vec::new();
self.get_all_basic_entry_assigned_nodes_v2()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_entry_assigned_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_entry_assigned_nodes_v2(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_entry_assigned_nodes_v2)
}
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_all_basic_active_mixing_assigned_nodes_with_metadata instead")]
pub async fn get_all_basic_active_mixing_assigned_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// 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 nodes = Vec::new();
self.get_all_basic_active_mixing_assigned_nodes_with_metadata()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_active_mixing_assigned_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_active_mixing_assigned_nodes_with_metadata(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_active_mixing_assigned_nodes_v2)
}
/// retrieve basic information for nodes are capable of operating as a mixnode
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_all_basic_mixing_capable_nodes_with_metadata instead")]
pub async fn get_all_basic_mixing_capable_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// 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 nodes = Vec::new();
self.get_all_basic_mixing_capable_nodes_with_metadata()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_mixing_capable_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_mixing_capable_nodes_with_metadata(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_mixing_capable_nodes_v2)
}
/// retrieve basic information for all bonded nodes on the network
#[deprecated(note = "use get_all_basic_nodes_with_metadata instead")]
pub async fn get_all_basic_nodes(&self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
self.get_all_basic_nodes_with_metadata()
.await
.map(|res| res.nodes)
}
pub async fn get_all_basic_nodes_with_metadata(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_nodes_v2)
}
/// retrieve expanded information for all bonded nodes on the network
pub async fn get_all_expanded_nodes(
&self,
) -> Result<SemiSkimmedNodesWithMetadata, ValidatorClientError> {
// Unroll the first iteration to get the metadata
let mut page = 0;
let mut nodes = Vec::new();
let res = self
.nym_api
.get_expanded_nodes(false, Some(page), None)
.await?;
let mut nodes = res.nodes.data;
let metadata = res.metadata;
if res.nodes.pagination.total == nodes.len() {
return Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata));
}
page += 1;
loop {
let mut res = self
.nym_api
.get_basic_nodes(false, Some(page), None, self.use_bincode)
.get_expanded_nodes(false, Some(page), None)
.await?;
nodes.append(&mut res.nodes.data);
@@ -521,7 +564,7 @@ impl NymApiClient {
}
}
Ok(nodes)
Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata))
}
pub async fn health(&self) -> Result<ApiHealthResponse, ValidatorClientError> {
@@ -22,6 +22,9 @@ pub enum ValidatorClientError {
#[error("nyxd request failed: {0}")]
NyxdError(#[from] crate::nyxd::error::NyxdError),
#[error("the response metadata has changed between pages")]
InconsistentPagedMetadata,
#[error("No validator API url has been provided")]
NoAPIUrlAvailable,
}
@@ -14,11 +14,12 @@ use nym_api_requests::ecash::models::{
use nym_api_requests::ecash::VerificationKeyResponse;
use nym_api_requests::models::{
AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainStatusResponse,
LegacyDescribedMixNode, NodePerformanceResponse, NodeRefreshBody, NymNodeDescription,
PerformanceHistoryResponse, RewardedSetResponse,
KeyRotationInfoResponse, LegacyDescribedMixNode, NodePerformanceResponse, NodeRefreshBody,
NymNodeDescription, PerformanceHistoryResponse, RewardedSetResponse,
};
use nym_api_requests::nym_nodes::{
NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponse,
NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponseV1,
PaginatedCachedNodesResponseV2,
};
use nym_api_requests::pagination::PaginatedResponse;
pub use nym_api_requests::{
@@ -34,7 +35,7 @@ pub use nym_api_requests::{
MixnodeStatusResponse, MixnodeUptimeHistoryResponse, RewardEstimationResponse,
StakeSaturationResponse, UptimeResponse,
},
nym_nodes::{CachedNodesResponse, SkimmedNode},
nym_nodes::{CachedNodesResponse, SemiSkimmedNode, SkimmedNode},
NymNetworkDetailsResponse,
};
use nym_contracts_common::IdentityKey;
@@ -62,7 +63,7 @@ pub trait NymApiClientExt: ApiClient {
async fn health(&self) -> Result<ApiHealthResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::API_STATUS_ROUTES,
routes::HEALTH,
],
@@ -75,7 +76,7 @@ pub trait NymApiClientExt: ApiClient {
async fn build_information(&self) -> Result<BinaryBuildInformationOwned, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::API_STATUS_ROUTES,
routes::BUILD_INFORMATION,
],
@@ -87,7 +88,7 @@ pub trait NymApiClientExt: ApiClient {
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
self.get_json(&[routes::API_VERSION, routes::MIXNODES], NO_PARAMS)
self.get_json(&[routes::V1_API_VERSION, routes::MIXNODES], NO_PARAMS)
.await
}
@@ -96,7 +97,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_mixnodes_detailed(&self) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::DETAILED,
@@ -111,7 +112,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_gateways_detailed(&self) -> Result<Vec<GatewayBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAYS,
routes::DETAILED,
@@ -128,7 +129,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<Vec<GatewayBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAYS,
routes::DETAILED_UNFILTERED,
@@ -145,7 +146,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::DETAILED_UNFILTERED,
@@ -158,7 +159,7 @@ pub trait NymApiClientExt: ApiClient {
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_gateways(&self) -> Result<Vec<GatewayBond>, NymAPIError> {
self.get_json(&[routes::API_VERSION, routes::GATEWAYS], NO_PARAMS)
self.get_json(&[routes::V1_API_VERSION, routes::GATEWAYS], NO_PARAMS)
.await
}
@@ -166,7 +167,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_gateways_described(&self) -> Result<Vec<LegacyDescribedGateway>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::GATEWAYS, routes::DESCRIBED],
&[routes::V1_API_VERSION, routes::GATEWAYS, routes::DESCRIBED],
NO_PARAMS,
)
.await
@@ -176,7 +177,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_mixnodes_described(&self) -> Result<Vec<LegacyDescribedMixNode>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::DESCRIBED],
&[routes::V1_API_VERSION, routes::MIXNODES, routes::DESCRIBED],
NO_PARAMS,
)
.await
@@ -201,7 +202,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_PERFORMANCE_HISTORY,
&*node_id.to_string(),
@@ -229,7 +230,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_DESCRIBED,
],
@@ -256,7 +257,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_BONDED,
],
@@ -270,7 +271,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_basic_mixnodes(&self) -> Result<CachedNodesResponse<SkimmedNode>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"mixnodes",
@@ -286,7 +287,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_basic_gateways(&self) -> Result<CachedNodesResponse<SkimmedNode>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"gateways",
@@ -301,7 +302,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_rewarded_set(&self) -> Result<RewardedSetResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_REWARDED_SET,
],
@@ -312,6 +313,7 @@ pub trait NymApiClientExt: ApiClient {
/// retrieve basic information for nodes are capable of operating as an entry gateway
/// this includes legacy gateways and nym-nodes
#[deprecated(note = "use get_basic_entry_assigned_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_entry_assigned_nodes(
&self,
@@ -319,7 +321,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -340,7 +342,49 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
"entry-gateways",
"all",
],
&params,
)
.await
}
/// retrieve basic information for nodes are capable of operating as an entry gateway
/// this includes legacy gateways and nym-nodes
#[instrument(level = "debug", skip(self))]
async fn get_basic_entry_assigned_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
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()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -354,6 +398,7 @@ pub trait NymApiClientExt: ApiClient {
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_basic_active_mixing_assigned_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_active_mixing_assigned_nodes(
&self,
@@ -361,7 +406,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -382,7 +427,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -397,13 +442,13 @@ pub trait NymApiClientExt: ApiClient {
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[instrument(level = "debug", skip(self))]
async fn get_basic_mixing_capable_nodes(
async fn get_basic_active_mixing_assigned_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -424,7 +469,50 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
"mixnodes",
"active",
],
&params,
)
.await
}
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_basic_mixing_capable_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_mixing_capable_nodes(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
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()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -436,14 +524,16 @@ pub trait NymApiClientExt: ApiClient {
.await
}
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[instrument(level = "debug", skip(self))]
async fn get_basic_nodes(
async fn get_basic_mixing_capable_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -464,10 +554,122 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
"mixnodes",
"all",
],
&params,
)
.await
}
#[deprecated(note = "use get_basic_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_nodes(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
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()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
],
&params,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_basic_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
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()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
],
&params,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_expanded_nodes(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
) -> Result<PaginatedCachedNodesResponseV2<SemiSkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
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,
"unstable",
routes::NYM_NODES_ROUTES,
"semi-skimmed",
],
&params,
)
@@ -478,7 +680,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_active_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::ACTIVE],
&[routes::V1_API_VERSION, routes::MIXNODES, routes::ACTIVE],
NO_PARAMS,
)
.await
@@ -489,7 +691,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_active_mixnodes_detailed(&self) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::ACTIVE,
@@ -504,7 +706,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_rewarded_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::REWARDED],
&[routes::V1_API_VERSION, routes::MIXNODES, routes::REWARDED],
NO_PARAMS,
)
.await
@@ -518,7 +720,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<MixnodeStatusReportResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODE,
&mix_id.to_string(),
@@ -537,7 +739,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<GatewayStatusReportResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAY,
identity,
@@ -556,7 +758,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<MixnodeUptimeHistoryResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODE,
&mix_id.to_string(),
@@ -575,7 +777,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<GatewayUptimeHistoryResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAY,
identity,
@@ -593,7 +795,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::REWARDED,
@@ -614,7 +816,7 @@ pub trait NymApiClientExt: ApiClient {
if let Some(since) = since {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::GATEWAY,
identity,
@@ -626,7 +828,7 @@ pub trait NymApiClientExt: ApiClient {
} else {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::GATEWAY,
identity,
@@ -647,7 +849,7 @@ pub trait NymApiClientExt: ApiClient {
if let Some(since) = since {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -659,7 +861,7 @@ pub trait NymApiClientExt: ApiClient {
} else {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -679,7 +881,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<MixnodeStatusResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -698,7 +900,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<RewardEstimationResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -718,7 +920,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<RewardEstimationResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -738,7 +940,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<StakeSaturationResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -758,7 +960,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<nym_api_requests::models::InclusionProbabilityResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -776,7 +978,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<NodePerformanceResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_PERFORMANCE,
&node_id.to_string(),
@@ -792,7 +994,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<AnnotationResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_ANNOTATION,
&node_id.to_string(),
@@ -806,7 +1008,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_mixnode_avg_uptime(&self, mix_id: NodeId) -> Result<UptimeResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -821,7 +1023,11 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_mixnodes_blacklisted(&self) -> Result<Vec<NodeId>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::BLACKLISTED],
&[
routes::V1_API_VERSION,
routes::MIXNODES,
routes::BLACKLISTED,
],
NO_PARAMS,
)
.await
@@ -831,7 +1037,11 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_gateways_blacklisted(&self) -> Result<Vec<IdentityKey>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::GATEWAYS, routes::BLACKLISTED],
&[
routes::V1_API_VERSION,
routes::GATEWAYS,
routes::BLACKLISTED,
],
NO_PARAMS,
)
.await
@@ -844,7 +1054,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<BlindedSignatureResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_BLIND_SIGN,
],
@@ -861,7 +1071,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<EcashTicketVerificationResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::VERIFY_ECASH_TICKET,
],
@@ -878,7 +1088,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<EcashBatchTicketRedemptionResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::BATCH_REDEEM_ECASH_TICKETS,
],
@@ -903,7 +1113,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::PARTIAL_EXPIRATION_DATE_SIGNATURES,
],
@@ -924,7 +1134,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::PARTIAL_COIN_INDICES_SIGNATURES,
],
@@ -948,7 +1158,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::GLOBAL_EXPIRATION_DATE_SIGNATURES,
],
@@ -969,7 +1179,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::GLOBAL_COIN_INDICES_SIGNATURES,
],
@@ -989,7 +1199,7 @@ pub trait NymApiClientExt: ApiClient {
};
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
ecash::MASTER_VERIFICATION_KEY,
],
@@ -1005,7 +1215,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<(), NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_REFRESH_DESCRIBED,
],
@@ -1022,7 +1232,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksForResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_FOR,
&expiration_date.to_string(),
@@ -1039,7 +1249,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksForCountResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_FOR_COUNT,
&expiration_date.to_string(),
@@ -1056,7 +1266,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksChallengeCommitmentResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_CHALLENGE_COMMITMENT,
],
@@ -1073,7 +1283,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksDataResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_DATA,
],
@@ -1089,7 +1299,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<NodesByAddressesResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
routes::nym_nodes::BY_ADDRESSES,
@@ -1103,7 +1313,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_network_details(&self) -> Result<NymNetworkDetailsResponse, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::NETWORK, routes::DETAILS],
&[routes::V1_API_VERSION, routes::NETWORK, routes::DETAILS],
NO_PARAMS,
)
.await
@@ -1112,7 +1322,24 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_chain_status(&self) -> Result<ChainStatusResponse, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::NETWORK, routes::CHAIN_STATUS],
&[
routes::V1_API_VERSION,
routes::NETWORK,
routes::CHAIN_STATUS,
],
NO_PARAMS,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_key_rotation_info(&self) -> Result<KeyRotationInfoResponse, NymAPIError> {
self.get_json(
&[
routes::V1_API_VERSION,
routes::EPOCH,
routes::KEY_ROTATION_INFO,
],
NO_PARAMS,
)
.await
@@ -1,9 +1,8 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_network_defaults::NYM_API_VERSION;
pub const API_VERSION: &str = NYM_API_VERSION;
pub const V1_API_VERSION: &str = "v1";
pub const V2_API_VERSION: &str = "v2";
pub const MIXNODES: &str = "mixnodes";
pub const GATEWAYS: &str = "gateways";
pub const DESCRIBED: &str = "described";
@@ -79,3 +78,11 @@ pub const SERVICE_PROVIDERS: &str = "services";
pub const DETAILS: &str = "details";
pub const CHAIN_STATUS: &str = "chain-status";
pub const NETWORK: &str = "network";
pub const EPOCH: &str = "epoch";
pub use epoch_routes::*;
pub mod epoch_routes {
pub const CURRENT: &str = "current";
pub const KEY_ROTATION_INFO: &str = "key-rotation-info";
}
@@ -12,8 +12,8 @@ use nym_mixnet_contract_common::gateway::{PreassignedGatewayIdsResponse, Preassi
use nym_mixnet_contract_common::nym_node::{
EpochAssignmentResponse, NodeDetailsByIdentityResponse, NodeDetailsResponse,
NodeOwnershipResponse, NodeRewardingDetailsResponse, PagedNymNodeBondsResponse,
PagedNymNodeDetailsResponse, PagedUnbondedNymNodesResponse, Role, RolesMetadataResponse,
StakeSaturationResponse, UnbondedNodeResponse, UnbondedNymNode,
PagedNymNodeDetailsResponse, PagedUnbondedNymNodesResponse, RewardedSetMetadata, Role,
RolesMetadataResponse, StakeSaturationResponse, UnbondedNodeResponse, UnbondedNymNode,
};
use nym_mixnet_contract_common::reward_params::WorkFactor;
use nym_mixnet_contract_common::{
@@ -28,12 +28,12 @@ use nym_mixnet_contract_common::{
ContractBuildInformation, ContractState, ContractStateParams, CurrentIntervalResponse,
CurrentNymNodeVersionResponse, Delegation, EpochEventId, EpochRewardedSet, EpochStatus,
GatewayBond, GatewayBondResponse, GatewayOwnershipResponse, HistoricalNymNodeVersionEntry,
IdentityKey, IdentityKeyRef, IntervalEventId, MixNodeBond, MixNodeDetails,
MixOwnershipResponse, MixnodeDetailsByIdentityResponse, MixnodeDetailsResponse, NodeId,
NumberOfPendingEventsResponse, NymNodeBond, NymNodeDetails, NymNodeVersionHistoryResponse,
PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse, PagedGatewayResponse,
PagedMixnodeBondsResponse, PagedNodeDelegationsResponse, PendingEpochEvent,
PendingEpochEventResponse, PendingEpochEventsResponse, PendingIntervalEvent,
IdentityKey, IdentityKeyRef, IntervalEventId, KeyRotationIdResponse, KeyRotationState,
MixNodeBond, MixNodeDetails, MixOwnershipResponse, MixnodeDetailsByIdentityResponse,
MixnodeDetailsResponse, NodeId, NumberOfPendingEventsResponse, NymNodeBond, NymNodeDetails,
NymNodeVersionHistoryResponse, PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse,
PagedGatewayResponse, PagedMixnodeBondsResponse, PagedNodeDelegationsResponse,
PendingEpochEvent, PendingEpochEventResponse, PendingEpochEventsResponse, PendingIntervalEvent,
PendingIntervalEventResponse, PendingIntervalEventsResponse, QueryMsg as MixnetQueryMsg,
RewardedSet, UnbondedMixnode,
};
@@ -546,6 +546,16 @@ pub trait MixnetQueryClient {
})
.await
}
async fn get_key_rotation_state(&self) -> Result<KeyRotationState, NyxdError> {
self.query_mixnet_contract(MixnetQueryMsg::GetKeyRotationState {})
.await
}
async fn get_key_rotation_id(&self) -> Result<KeyRotationIdResponse, NyxdError> {
self.query_mixnet_contract(MixnetQueryMsg::GetKeyRotationId {})
.await
}
}
// extension trait to the query client to deal with the paged queries
@@ -673,12 +683,20 @@ pub trait MixnetQueryClientExt: MixnetQueryClient {
async fn get_rewarded_set(&self) -> Result<EpochRewardedSet, NyxdError> {
let error_response = |message| Err(NyxdError::extension_query_failure("mixnet", message));
// bypass for catch 22 for fresh contracts. we can't refresh cache because there's no rewarded set,
// but we can't set the rewarded set because we didn't refresh the cache
let metadata = self.get_rewarded_set_metadata().await?;
if !metadata.metadata.fully_assigned {
let is_default = metadata.metadata == RewardedSetMetadata::default();
if !metadata.metadata.fully_assigned && !is_default {
return error_response("the rewarded set hasn't been fully assigned for this epoch");
}
let expected_epoch_id = metadata.metadata.epoch_id;
if is_default {
return Ok(Default::default());
}
// if we have to query those things more frequently, we could do it concurrently,
// but as it stands now, it happens so infrequently it might as well be sequential
let entry = self.get_role_assignment(Role::EntryGateway).await?;
@@ -955,6 +973,8 @@ mod tests {
QueryMsg::GetNymNodeVersionHistory { limit, start_after } => client
.get_nym_node_version_history_paged(start_after, limit)
.ignore(),
QueryMsg::GetKeyRotationState {} => client.get_key_rotation_state().ignore(),
QueryMsg::GetKeyRotationId {} => client.get_key_rotation_id().ignore(),
}
}
}
@@ -138,6 +138,14 @@ impl NyxdClient<HttpClient> {
config,
})
}
pub fn connect_to_default_env<U>(endpoint: U) -> Result<QueryHttpRpcNyxdClient, NyxdError>
where
U: TryInto<HttpClientUrl, Error = TendermintRpcError>,
{
let config = Config::try_from_nym_network_details(&NymNetworkDetails::new_from_env())?;
Self::connect(config, endpoint)
}
}
impl NyxdClient<ReqwestRpcClient> {
@@ -157,6 +157,7 @@ pub async fn generate(args: Args) {
minimum: args.minimum_interval_operating_cost.amount.into(),
maximum: args.maximum_interval_operating_cost.amount.into(),
},
key_validity_in_epochs: None,
};
debug!("instantiate_msg: {:?}", instantiate_msg);
@@ -40,3 +40,6 @@ contract-testing = []
utoipa = ["dep:utoipa"]
schema = ["cw2"]
generate-ts = ['ts-rs']
[lints]
workspace = true
@@ -193,6 +193,9 @@ pub enum MixnetContractError {
#[error("attempted to perform the operation with 0 coins. This is not allowed")]
ZeroCoinAmount,
#[error("key rotation validity below minimum value")]
TooShortRotationInterval,
#[error("this validator ({current_validator}) is not the one responsible for advancing this epoch. It's responsibility of {chosen_validator}.")]
RewardingValidatorMismatch {
current_validator: Addr,
@@ -358,7 +358,7 @@ impl Interval {
self.total_elapsed_epochs
}
pub const fn current_epoch_absolute_id(&self) -> u32 {
pub const fn current_epoch_absolute_id(&self) -> EpochId {
// since we count epochs starting from 0, if n epochs have elapsed, the current one has absolute id of n
self.total_elapsed_epochs
}
@@ -0,0 +1,155 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::EpochId;
use cosmwasm_schema::cw_serde;
pub type KeyRotationId = u32;
#[cw_serde]
#[derive(Copy)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct KeyRotationState {
/// Defines how long each key rotation is valid for (in terms of epochs)
pub validity_epochs: u32,
/// Records the initial epoch_id when the key rotation has been introduced (0 for fresh contracts).
/// It is used for determining when rotation is meant to advance.
#[cfg_attr(feature = "utoipa", schema(value_type = u32))]
pub initial_epoch_id: EpochId,
}
impl KeyRotationState {
pub fn key_rotation_id(&self, current_epoch_id: EpochId) -> KeyRotationId {
let diff = current_epoch_id.saturating_sub(self.initial_epoch_id);
diff / self.validity_epochs
}
pub fn next_rotation_starting_epoch_id(&self, current_epoch_id: EpochId) -> EpochId {
self.current_rotation_starting_epoch_id(current_epoch_id) + self.validity_epochs
}
pub fn current_rotation_starting_epoch_id(&self, current_epoch_id: EpochId) -> EpochId {
let current_rotation_id = self.key_rotation_id(current_epoch_id);
self.initial_epoch_id + self.validity_epochs * current_rotation_id
}
}
#[cw_serde]
pub struct KeyRotationIdResponse {
pub rotation_id: KeyRotationId,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_rotation_id() {
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 0,
};
assert_eq!(0, state.key_rotation_id(0));
assert_eq!(0, state.key_rotation_id(23));
assert_eq!(1, state.key_rotation_id(24));
assert_eq!(1, state.key_rotation_id(47));
assert_eq!(2, state.key_rotation_id(48));
let state = KeyRotationState {
validity_epochs: 12,
initial_epoch_id: 0,
};
assert_eq!(0, state.key_rotation_id(0));
assert_eq!(0, state.key_rotation_id(11));
assert_eq!(1, state.key_rotation_id(12));
assert_eq!(1, state.key_rotation_id(23));
assert_eq!(2, state.key_rotation_id(24));
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 10000,
};
assert_eq!(0, state.key_rotation_id(123));
assert_eq!(0, state.key_rotation_id(10000));
assert_eq!(0, state.key_rotation_id(10001));
assert_eq!(0, state.key_rotation_id(10023));
assert_eq!(1, state.key_rotation_id(10024));
assert_eq!(1, state.key_rotation_id(10047));
assert_eq!(2, state.key_rotation_id(10048));
assert_eq!(2, state.key_rotation_id(10060));
}
#[test]
fn next_rotation_starting_epoch_id() {
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 0,
};
assert_eq!(24, state.next_rotation_starting_epoch_id(0));
assert_eq!(24, state.next_rotation_starting_epoch_id(23));
assert_eq!(48, state.next_rotation_starting_epoch_id(24));
assert_eq!(48, state.next_rotation_starting_epoch_id(47));
assert_eq!(72, state.next_rotation_starting_epoch_id(48));
let state = KeyRotationState {
validity_epochs: 12,
initial_epoch_id: 0,
};
assert_eq!(12, state.next_rotation_starting_epoch_id(0));
assert_eq!(12, state.next_rotation_starting_epoch_id(11));
assert_eq!(24, state.next_rotation_starting_epoch_id(12));
assert_eq!(24, state.next_rotation_starting_epoch_id(23));
assert_eq!(36, state.next_rotation_starting_epoch_id(24));
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 10000,
};
assert_eq!(10024, state.next_rotation_starting_epoch_id(123));
assert_eq!(10024, state.next_rotation_starting_epoch_id(10000));
assert_eq!(10024, state.next_rotation_starting_epoch_id(10001));
assert_eq!(10024, state.next_rotation_starting_epoch_id(10023));
assert_eq!(10048, state.next_rotation_starting_epoch_id(10024));
assert_eq!(10048, state.next_rotation_starting_epoch_id(10047));
assert_eq!(10072, state.next_rotation_starting_epoch_id(10048));
assert_eq!(10072, state.next_rotation_starting_epoch_id(10060));
}
#[test]
fn current_rotation_starting_epoch_id() {
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 0,
};
assert_eq!(0, state.current_rotation_starting_epoch_id(0));
assert_eq!(0, state.current_rotation_starting_epoch_id(23));
assert_eq!(24, state.current_rotation_starting_epoch_id(24));
assert_eq!(24, state.current_rotation_starting_epoch_id(47));
assert_eq!(48, state.current_rotation_starting_epoch_id(48));
let state = KeyRotationState {
validity_epochs: 12,
initial_epoch_id: 0,
};
assert_eq!(0, state.current_rotation_starting_epoch_id(0));
assert_eq!(0, state.current_rotation_starting_epoch_id(11));
assert_eq!(12, state.current_rotation_starting_epoch_id(12));
assert_eq!(12, state.current_rotation_starting_epoch_id(23));
assert_eq!(24, state.current_rotation_starting_epoch_id(24));
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 10000,
};
assert_eq!(10000, state.current_rotation_starting_epoch_id(123));
assert_eq!(10000, state.current_rotation_starting_epoch_id(10000));
assert_eq!(10000, state.current_rotation_starting_epoch_id(10001));
assert_eq!(10000, state.current_rotation_starting_epoch_id(10023));
assert_eq!(10024, state.current_rotation_starting_epoch_id(10024));
assert_eq!(10024, state.current_rotation_starting_epoch_id(10047));
assert_eq!(10048, state.current_rotation_starting_epoch_id(10048));
assert_eq!(10048, state.current_rotation_starting_epoch_id(10060));
}
}
@@ -1,10 +1,6 @@
// Copyright 2021-2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#![warn(clippy::expect_used)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::todo)]
mod config_score;
pub mod constants;
pub mod delegation;
@@ -13,6 +9,7 @@ pub mod events;
pub mod gateway;
pub mod helpers;
pub mod interval;
pub mod key_rotation;
pub mod mixnode;
pub mod msg;
pub mod nym_node;
@@ -37,6 +34,7 @@ pub use gateway::{
pub use interval::{
CurrentIntervalResponse, EpochId, EpochState, EpochStatus, Interval, IntervalId,
};
pub use key_rotation::*;
pub use mixnode::{
LegacyMixLayer, MixNode, MixNodeBond, MixNodeConfigUpdate, MixNodeDetails,
MixOwnershipResponse, MixnodeDetailsByIdentityResponse, MixnodeDetailsResponse, NodeCostParams,
@@ -170,6 +170,11 @@ impl NodeRewarding {
}
}
// we panic here as opposed to returning an error as this is undefined behaviour,
// because the pledge amount has decreased (i.e. slashing has occurred) which
// should not be possible under any situation. at this point we don't know how many other things
// might have failed so we have to bail
#[allow(clippy::panic)]
pub fn pending_detailed_operator_reward(&self, original_pledge: &Coin) -> StdResult<Decimal> {
let initial_dec = original_pledge.amount.into_base_decimal()?;
if initial_dec > self.operator {
@@ -189,6 +194,11 @@ impl NodeRewarding {
Ok(truncate_reward(delegator_reward, &delegation.amount.denom))
}
// we panic here as opposed to returning an error as this is undefined behaviour,
// because the pledge amount has decreased (i.e. slashing has occurred) which
// should not be possible under any situation. at this point we don't know how many other things
// might have failed so we have to bail
#[allow(clippy::panic)]
pub fn withdraw_operator_reward(
&mut self,
original_pledge: &Coin,
@@ -35,6 +35,7 @@ use crate::{
PreassignedGatewayIdsResponse,
},
interval::{CurrentIntervalResponse, EpochStatus},
key_rotation::{KeyRotationIdResponse, KeyRotationState},
mixnode::{
MixOwnershipResponse, MixStakeSaturationResponse, MixnodeDetailsByIdentityResponse,
MixnodeDetailsResponse, MixnodeRewardingDetailsResponse, PagedMixnodeBondsResponse,
@@ -81,6 +82,18 @@ pub struct InstantiateMsg {
#[serde(default)]
pub interval_operating_cost: OperatingCostRange,
#[serde(default)]
pub key_validity_in_epochs: Option<u32>,
}
impl InstantiateMsg {
// needs to give us enough time to pre-announce key for following epoch
// and have an overlap with the preceding epoch
pub const MIN_KEY_ROTATION_VALIDITY: u32 = 3;
pub fn key_validity_in_epochs(&self) -> u32 {
self.key_validity_in_epochs.unwrap_or(24)
}
}
#[cw_serde]
@@ -857,6 +870,15 @@ pub enum QueryMsg {
/// Cosmos address used for the query of the signing nonce.
address: String,
},
// sphinx key rotation-related
#[cfg_attr(feature = "schema", returns(KeyRotationState))]
/// Gets the current state config of the key rotation (i.e. starting epoch id and validity duration)
GetKeyRotationState {},
/// Gets the current key rotation id
#[cfg_attr(feature = "schema", returns(KeyRotationIdResponse))]
GetKeyRotationId {},
}
#[cw_serde]
@@ -151,6 +151,9 @@ impl Simulator {
}
}
// this code is not meant to be used in production systems, only in tests
// so a panic due to inconsistent arguments is fine
#[allow(clippy::panic)]
pub fn simulate_epoch(
&mut self,
node_params: &BTreeMap<NodeId, NodeRewardingParameters>,
@@ -0,0 +1,27 @@
[package]
name = "nym-pool-contract-common"
version = "0.1.0"
description = "Common library for the Nym Pool contract"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
thiserror = { workspace = true }
serde = { workspace = true }
schemars = { workspace = true }
cosmwasm-std = { workspace = true }
cosmwasm-schema = { workspace = true }
cw-controllers = { workspace = true }
[dev-dependencies]
time = { workspace = true, features = ["macros"] }
[features]
schema = []
@@ -0,0 +1,11 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod storage_keys {
pub const CONTRACT_ADMIN: &str = "contract-admin";
pub const POOL_DENOMINATION: &str = "pool_denom";
pub const GRANTERS: &str = "granters";
pub const GRANTS: &str = "grants";
pub const TOTAL_LOCKED: &str = "total_locked";
pub const LOCKED_GRANTEES: &str = "locked_grantees";
}
@@ -0,0 +1,98 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use cosmwasm_std::{Coin, Uint128};
use cw_controllers::AdminError;
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum NymPoolContractError {
#[error("could not perform contract migration: {comment}")]
FailedMigration { comment: String },
#[error(transparent)]
Admin(#[from] AdminError),
#[error(transparent)]
StdErr(#[from] cosmwasm_std::StdError),
#[error("this sender is not authorised to revoke this grant. its neither the admin or the original (and still whitelisted) granter")]
UnauthorizedGrantRevocation,
#[error("the specified address is already a whitelisted granter")]
AlreadyAGranter,
#[error("{addr} is not a permitted granter")]
InvalidGranter { addr: String },
#[error("invalid coin denomination. got {got}, but expected {expected}")]
InvalidDenom { expected: String, got: String },
#[error("there already exists an active grant for {grantee}. it was granted by {granter} at block height {created_at_height}")]
GrantAlreadyExist {
granter: String,
grantee: String,
created_at_height: u64,
},
#[error("could not find any active grants for {grantee}")]
GrantNotFound { grantee: String },
#[error("the provided timestamp value ({timestamp}) is set in the past. the current block timestamp is {current_block_timestamp}")]
TimestampInThePast {
timestamp: u64,
current_block_timestamp: u64,
},
#[error("there are not enough tokens to process this request. {available} are available, but {required} is needed.")]
InsufficientTokens { available: Coin, required: Coin },
#[error("the period length can't be zero")]
ZeroAllowancePeriod,
#[error("the provided coin value is zero")]
ZeroAmount,
#[error("the periodic spend limit of {periodic} was set to be higher than the total spend limit {total_limit}")]
PeriodicGrantOverSpendLimit { periodic: Coin, total_limit: Coin },
#[error("the accumulation spend limit of {accumulation} was set to be lower than the periodic grant amount of {periodic_grant}")]
AccumulationBelowGrantAmount {
accumulation: Coin,
periodic_grant: Coin,
},
#[error("the accumulation spend limit of {accumulation} was set to be higher than the total spend limit of {total_limit}")]
AccumulationOverSpendLimit {
accumulation: Coin,
total_limit: Coin,
},
#[error("the specified delayed allowance would never be available. it would become active at {available_timestamp} yet it expires at {expiration_timestamp}")]
UnattainableDelayedAllowance {
expiration_timestamp: u64,
available_timestamp: u64,
},
#[error("could not unlock {requested} tokens from {grantee}. it only has {locked} locked")]
InsufficientLockedTokens {
grantee: String,
locked: Uint128,
requested: Uint128,
},
#[error("attempted to spend more tokens than permitted by the current allowance")]
SpendingAboveAllowance,
#[error("attempted to send an empty allowance usage request")]
EmptyUsageRequest,
#[error("the associated grant has already expired")]
GrantExpired,
#[error("the associated grant hasn't expired yet")]
GrantNotExpired,
#[error("this grant is not available yet. it will become usable at {available_at_timestamp}")]
GrantNotYetAvailable { available_at_timestamp: u64 },
}
@@ -0,0 +1,12 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod constants;
pub mod error;
pub mod msg;
pub mod types;
mod utils;
pub use error::*;
pub use msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg};
pub use types::*;
@@ -0,0 +1,125 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{Allowance, TransferRecipient};
use cosmwasm_schema::cw_serde;
use cosmwasm_std::Coin;
use std::collections::HashMap;
#[cfg(feature = "schema")]
use crate::types::{
AvailableTokensResponse, GrantResponse, GranterResponse, GrantersPagedResponse,
GrantsPagedResponse, LockedTokensPagedResponse, LockedTokensResponse,
TotalLockedTokensResponse,
};
#[cw_serde]
pub struct InstantiateMsg {
pub pool_denomination: String,
/// Initial map of grants to be created at instantiation
pub grants: HashMap<String, Allowance>,
}
#[cw_serde]
pub enum ExecuteMsg {
/// Change the admin
UpdateAdmin {
admin: String,
// flag to determine whether old admin should be removed from the granter set
// and new one should be included instead
// the reason it's provided as an option is to make it possible to skip this field
// when creating transaction directly with nyxd
update_granter_set: Option<bool>,
},
/// Attempt to grant new allowance to the specified grantee
GrantAllowance {
grantee: String,
allowance: Box<Allowance>,
},
/// Attempt to revoke previously granted allowance
RevokeAllowance { grantee: String },
/// Attempt to use allowance
UseAllowance { recipients: Vec<TransferRecipient> },
/// Attempt to withdraw the specified amount into the grantee's account
WithdrawAllowance { amount: Coin },
/// Attempt to lock part of existing allowance for future use
LockAllowance { amount: Coin },
/// Attempt to unlock previously locked allowance
UnlockAllowance { amount: Coin },
/// Attempt to use part of the locked allowance
UseLockedAllowance { recipients: Vec<TransferRecipient> },
/// Attempt to withdraw the specified amount of locked tokens into the grantee's account
WithdrawLockedAllowance { amount: Coin },
/// Attempt to add a new account to the permitted set of grant granters
AddNewGranter { granter: String },
/// Revoke the provided account from the permitted set of granters
RevokeGranter { granter: String },
/// Attempt to remove expired grant from the storage and unlock (if any) locked tokens
RemoveExpiredGrant { grantee: String },
}
#[cw_serde]
#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))]
pub enum QueryMsg {
#[cfg_attr(feature = "schema", returns(cw_controllers::AdminResponse))]
Admin {},
#[cfg_attr(feature = "schema", returns(AvailableTokensResponse))]
GetAvailableTokens {},
#[cfg_attr(feature = "schema", returns(TotalLockedTokensResponse))]
GetTotalLockedTokens {},
#[cfg_attr(feature = "schema", returns(LockedTokensResponse))]
GetLockedTokens { grantee: String },
#[cfg_attr(feature = "schema", returns(GrantResponse))]
GetGrant { grantee: String },
#[cfg_attr(feature = "schema", returns(GranterResponse))]
GetGranter { granter: String },
#[cfg_attr(feature = "schema", returns(LockedTokensPagedResponse))]
GetLockedTokensPaged {
/// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.
limit: Option<u32>,
/// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.
start_after: Option<String>,
},
#[cfg_attr(feature = "schema", returns(GrantersPagedResponse))]
GetGrantersPaged {
/// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.
limit: Option<u32>,
/// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.
start_after: Option<String>,
},
#[cfg_attr(feature = "schema", returns(GrantsPagedResponse))]
GetGrantsPaged {
/// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.
limit: Option<u32>,
/// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.
start_after: Option<String>,
},
}
#[cw_serde]
pub struct MigrateMsg {
//
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::NymPoolContractError;
use cosmwasm_std::Env;
pub fn ensure_unix_timestamp_not_in_the_past(
unix_timestamp: u64,
env: &Env,
) -> Result<(), NymPoolContractError> {
if unix_timestamp < env.block.time.seconds() {
return Err(NymPoolContractError::TimestampInThePast {
timestamp: unix_timestamp,
current_block_timestamp: env.block.time.seconds(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::mock_env;
use cosmwasm_std::Timestamp;
use time::macros::datetime;
#[test]
fn ensuring_unix_timestamp_not_in_the_past() {
let unix_epoch = 0;
let date_in_the_past = datetime!(1984-01-02 3:45 UTC);
let sane_block_time = datetime!(2025-01-28 12:15 UTC);
let before_block = datetime!(2025-01-28 12:00 UTC);
let after_block = datetime!(2025-01-28 12:30 UTC);
let mut env = mock_env();
env.block.time = Timestamp::from_seconds(sane_block_time.unix_timestamp() as u64);
let res = ensure_unix_timestamp_not_in_the_past(unix_epoch, &env).unwrap_err();
assert_eq!(
NymPoolContractError::TimestampInThePast {
timestamp: unix_epoch,
current_block_timestamp: env.block.time.seconds(),
},
res
);
let res =
ensure_unix_timestamp_not_in_the_past(date_in_the_past.unix_timestamp() as u64, &env)
.unwrap_err();
assert_eq!(
NymPoolContractError::TimestampInThePast {
timestamp: date_in_the_past.unix_timestamp() as u64,
current_block_timestamp: env.block.time.seconds(),
},
res
);
let res = ensure_unix_timestamp_not_in_the_past(before_block.unix_timestamp() as u64, &env)
.unwrap_err();
assert_eq!(
NymPoolContractError::TimestampInThePast {
timestamp: before_block.unix_timestamp() as u64,
current_block_timestamp: env.block.time.seconds(),
},
res
);
let res =
ensure_unix_timestamp_not_in_the_past(sane_block_time.unix_timestamp() as u64, &env);
assert!(res.is_ok());
let res = ensure_unix_timestamp_not_in_the_past(after_block.unix_timestamp() as u64, &env);
assert!(res.is_ok());
}
}
@@ -218,6 +218,12 @@ impl From<PublicKey> for x25519_dalek::PublicKey {
}
}
impl AsRef<[u8]> for PublicKey {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct PrivateKey(x25519_dalek::StaticSecret);
@@ -248,6 +254,10 @@ impl PrivateKey {
PrivateKey(x25519_secret)
}
pub fn inner(&self) -> &x25519_dalek::StaticSecret {
&self.0
}
pub fn public_key(&self) -> PublicKey {
self.into()
}
@@ -256,6 +266,10 @@ impl PrivateKey {
self.0.to_bytes()
}
pub fn as_bytes(&self) -> &[u8; PRIVATE_KEY_SIZE] {
self.0.as_bytes()
}
pub fn from_bytes(b: &[u8]) -> Result<Self, KeyRecoveryError> {
if b.len() != PRIVATE_KEY_SIZE {
return Err(KeyRecoveryError::InvalidSizePrivateKey {
@@ -335,6 +349,12 @@ impl AsRef<x25519_dalek::StaticSecret> for PrivateKey {
}
}
impl AsRef<[u8]> for PrivateKey {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
+9 -1
View File
@@ -19,7 +19,7 @@ pub use shared_key::{
SharedGatewayKey, SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey,
};
pub const CURRENT_PROTOCOL_VERSION: u8 = AUTHENTICATE_V2_PROTOCOL_VERSION;
pub const CURRENT_PROTOCOL_VERSION: u8 = EMBEDDED_KEY_ROTATION_INFO_VERSION;
/// Defines the current version of the communication protocol between gateway and clients.
/// It has to be incremented for any breaking change.
@@ -28,10 +28,12 @@ pub const CURRENT_PROTOCOL_VERSION: u8 = AUTHENTICATE_V2_PROTOCOL_VERSION;
// 2 - changes to client credentials structure
// 3 - change to AES-GCM-SIV and non-zero IVs
// 4 - introduction of v2 authentication protocol to prevent reply attacks
// 5 - add key rotation information to the serialised mix packet
pub const INITIAL_PROTOCOL_VERSION: u8 = 1;
pub const CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION: u8 = 2;
pub const AES_GCM_SIV_PROTOCOL_VERSION: u8 = 3;
pub const AUTHENTICATE_V2_PROTOCOL_VERSION: u8 = 4;
pub const EMBEDDED_KEY_ROTATION_INFO_VERSION: u8 = 5;
// TODO: could using `Mac` trait here for OutputSize backfire?
// Should hmac itself be exposed, imported and used instead?
@@ -40,6 +42,7 @@ pub type LegacyGatewayMacSize = <GatewayIntegrityHmacAlgorithm as OutputSizeUser
pub trait GatewayProtocolVersionExt {
fn supports_aes256_gcm_siv(&self) -> bool;
fn supports_authenticate_v2(&self) -> bool;
fn supports_key_rotation_packet(&self) -> bool;
}
impl GatewayProtocolVersionExt for Option<u8> {
@@ -52,4 +55,9 @@ impl GatewayProtocolVersionExt for Option<u8> {
let Some(protocol) = *self else { return false };
protocol >= AUTHENTICATE_V2_PROTOCOL_VERSION
}
fn supports_key_rotation_packet(&self) -> bool {
let Some(protocol) = *self else { return false };
protocol >= EMBEDDED_KEY_ROTATION_INFO_VERSION
}
}
@@ -11,6 +11,9 @@ use tungstenite::Message;
#[non_exhaustive]
pub enum BinaryRequest {
ForwardSphinx { packet: MixPacket },
// identical to `ForwardSphinx`, but also contains information about sphinx key rotation used
ForwardSphinxV2 { packet: MixPacket },
}
#[repr(u8)]
@@ -18,6 +21,9 @@ pub enum BinaryRequest {
#[non_exhaustive]
pub enum BinaryRequestKind {
ForwardSphinx = 1,
// identical to `ForwardSphinx`, but also contains information about sphinx key rotation used
ForwardSphinxV2 = 2,
}
// Right now the only valid `BinaryRequest` is a request to forward a sphinx packet.
@@ -29,6 +35,7 @@ impl BinaryRequest {
pub fn kind(&self) -> BinaryRequestKind {
match self {
BinaryRequest::ForwardSphinx { .. } => BinaryRequestKind::ForwardSphinx,
BinaryRequest::ForwardSphinxV2 { .. } => BinaryRequestKind::ForwardSphinxV2,
}
}
@@ -38,9 +45,13 @@ impl BinaryRequest {
) -> Result<Self, GatewayRequestsError> {
match kind {
BinaryRequestKind::ForwardSphinx => {
let packet = MixPacket::try_from_bytes(plaintext)?;
let packet = MixPacket::try_from_v1_bytes(plaintext)?;
Ok(BinaryRequest::ForwardSphinx { packet })
}
BinaryRequestKind::ForwardSphinxV2 => {
let packet = MixPacket::try_from_v2_bytes(plaintext)?;
Ok(BinaryRequest::ForwardSphinxV2 { packet })
}
}
}
@@ -58,7 +69,8 @@ impl BinaryRequest {
let kind = self.kind();
let plaintext = match self {
BinaryRequest::ForwardSphinx { packet } => packet.into_bytes()?,
BinaryRequest::ForwardSphinx { packet } => packet.into_v1_bytes()?,
BinaryRequest::ForwardSphinxV2 { packet } => packet.into_v2_bytes()?,
};
BinaryData::make_encrypted_blob(kind as u8, &plaintext, shared_key)
@@ -70,7 +82,9 @@ impl BinaryRequest {
) -> Result<Message, GatewayRequestsError> {
// all variants are currently encrypted
let blob = match self {
BinaryRequest::ForwardSphinx { .. } => self.into_encrypted_tagged_bytes(shared_key)?,
BinaryRequest::ForwardSphinx { .. } | BinaryRequest::ForwardSphinxV2 { .. } => {
self.into_encrypted_tagged_bytes(shared_key)?
}
};
Ok(Message::Binary(blob))
+1 -1
View File
@@ -193,7 +193,7 @@ impl PersistentStatsStorage {
pub async fn get_started_sessions_count(
&self,
start_date: Date,
) -> Result<i32, StatsStorageError> {
) -> Result<i64, StatsStorageError> {
Ok(self
.session_manager
.get_started_sessions_count(start_date)
+1 -1
View File
@@ -160,7 +160,7 @@ impl SessionManager {
.await
}
pub(crate) async fn get_started_sessions_count(&self, start_date: Date) -> Result<i32> {
pub(crate) async fn get_started_sessions_count(&self, start_date: Date) -> Result<i64> {
Ok(sqlx::query!(
"SELECT COUNT(*) as count FROM sessions_active WHERE date(start_time) = ?",
start_date
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT \n id as \"id!\",\n client_address_bs58 as \"client_address_bs58!\",\n content as \"content!\" \n FROM message_store \n WHERE client_address_bs58 = ? AND id > ?\n ORDER BY id ASC\n LIMIT ?;\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "client_address_bs58!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "content!",
"ordinal": 2,
"type_info": "Blob"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false
]
},
"hash": "03fe56298a6d60cdd5304a2953811a533d59b4f1f0e4efecd32c09256b657e24"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM message_store WHERE timestamp < ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "071fbde5277c0806ed47fa15a9c6288609379049828a94008f854b3daeed21d1"
}
@@ -16,7 +16,7 @@
{
"name": "protocol_version",
"ordinal": 2,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "endpoint",
@@ -31,17 +31,17 @@
{
"name": "tx_bytes",
"ordinal": 5,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "rx_bytes",
"ordinal": 6,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "persistent_keepalive_interval",
"ordinal": 7,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "allowed_ips",
@@ -51,7 +51,7 @@
{
"name": "client_id",
"ordinal": 9,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
@@ -6,7 +6,7 @@
{
"name": "signer_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE shared_keys SET last_used_authentication = ? WHERE client_id = ?;",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "451158af8f5602e30445986a8de22d2e065c4e9dce4e7463a875ca8c21ac97c1"
}
@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM shared_keys WHERE client_address_bs58 = ?",
"describe": {
"columns": [
{
"name": "client_id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "client_address_bs58",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "derived_aes128_ctr_blake3_hmac_keys_bs58",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "derived_aes256_gcm_siv_key",
"ordinal": 3,
"type_info": "Blob"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "564c7da81081fab34754b76eeeedd48f3bc18842c03ef5a5c331bbee4c41c71c"
}
@@ -6,7 +6,7 @@
{
"name": "client_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
@@ -6,7 +6,7 @@
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "client_type: ClientType",
@@ -6,7 +6,7 @@
{
"name": "ticket_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "data!",
@@ -6,14 +6,14 @@
{
"name": "exists",
"ordinal": 0,
"type_info": "Int"
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
null
false
]
},
"hash": "7f8af0799d7ae5f751b9964e9566589bf768e7079079f584beb0c1ba16d43a5c"
@@ -6,7 +6,7 @@
{
"name": "signer_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
@@ -6,7 +6,7 @@
{
"name": "available",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
@@ -6,7 +6,7 @@
{
"name": "ticket_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "serial_number",
@@ -6,7 +6,7 @@
{
"name": "ticket_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "serial_number",
@@ -16,7 +16,7 @@
{
"name": "protocol_version",
"ordinal": 2,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "endpoint",
@@ -31,17 +31,17 @@
{
"name": "tx_bytes",
"ordinal": 5,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "rx_bytes",
"ordinal": 6,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "persistent_keepalive_interval",
"ordinal": 7,
"type_info": "Int64"
"type_info": "Integer"
},
{
"name": "allowed_ips",
@@ -51,7 +51,7 @@
{
"name": "client_id",
"ordinal": 9,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT \n id as \"id!\",\n client_address_bs58 as \"client_address_bs58!\",\n content as \"content!\"\n FROM message_store\n WHERE client_address_bs58 = ?\n ORDER BY id ASC\n LIMIT ?;\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "client_address_bs58!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "content!",
"ordinal": 2,
"type_info": "Blob"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false
]
},
"hash": "e3860c0c31ca03cc0b22ca34cef5f535a94c78d3491d44d7c8bf1b34a840839d"
}
@@ -6,7 +6,7 @@
{
"name": "proposal_id",
"ordinal": 0,
"type_info": "Int64"
"type_info": "Integer"
}
],
"parameters": {
+5 -22
View File
@@ -1,6 +1,8 @@
// Copyright 2021-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use std::time::SystemTime;
use crate::error::GatewayStorageError;
use nym_credentials_interface::{AvailableBandwidth, ClientTicket, CredentialSpendingData};
use nym_gateway_requests::shared_key::{LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey};
@@ -113,7 +115,7 @@ pub struct WireguardPeer {
pub preshared_key: Option<String>,
pub protocol_version: Option<i64>,
pub endpoint: Option<String>,
pub last_handshake: Option<sqlx::types::chrono::NaiveDateTime>,
pub last_handshake: Option<OffsetDateTime>,
pub tx_bytes: i64,
pub rx_bytes: i64,
pub persistent_keepalive_interval: Option<i64>,
@@ -128,18 +130,7 @@ impl From<defguard_wireguard_rs::host::Peer> for WireguardPeer {
preshared_key: value.preshared_key.as_ref().map(|k| k.to_string()),
protocol_version: value.protocol_version.map(|v| v as i64),
endpoint: value.endpoint.map(|e| e.to_string()),
last_handshake: value.last_handshake.and_then(|t| {
if let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) {
if let Ok(millis) = d.as_millis().try_into() {
sqlx::types::chrono::DateTime::from_timestamp_millis(millis)
.map(|d| d.naive_utc())
} else {
None
}
} else {
None
}
}),
last_handshake: value.last_handshake.map(OffsetDateTime::from),
tx_bytes: value.tx_bytes as i64,
rx_bytes: value.rx_bytes as i64,
persistent_keepalive_interval: value.persistent_keepalive_interval.map(|v| v as i64),
@@ -180,15 +171,7 @@ impl TryFrom<WireguardPeer> for defguard_wireguard_rs::host::Peer {
.map(|e| e.parse())
.transpose()
.map_err(|e| Self::Error::TypeConversion(format!("endpoint {e}")))?,
last_handshake: value.last_handshake.and_then(|t| {
let unix_time = std::time::UNIX_EPOCH;
if let Ok(millis) = t.and_utc().timestamp_millis().try_into() {
let duration = std::time::Duration::from_millis(millis);
unix_time.checked_add(duration)
} else {
None
}
}),
last_handshake: value.last_handshake.map(SystemTime::from),
tx_bytes: value
.tx_bytes
.try_into()
+1 -1
View File
@@ -92,7 +92,7 @@ impl TicketStorageManager {
)
.fetch_one(&self.connection_pool)
.await
.map(|result| result.exists == Some(1))
.map(|result| result.exists == 1)
}
pub(crate) async fn remove_binary_ticket_data(
+6 -1
View File
@@ -10,10 +10,14 @@ license.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
default=["tunneling"]
tunneling=[]
[dependencies]
async-trait = { workspace = true }
bincode = { workspace = true }
reqwest = { workspace = true, features = ["json", "gzip", "deflate", "brotli", "zstd"] }
reqwest = { workspace = true, features = ["json", "gzip", "deflate", "brotli", "zstd", "rustls-tls"] }
http.workspace = true
url = { workspace = true }
once_cell = { workspace = true }
@@ -21,6 +25,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
itertools = { workspace = true }
# used for decoding text responses (they were already implicitly included)
bytes = { workspace = true }
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Utilities for and implementation of request tunneling
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::warn;
use crate::ClientBuilder;
// #[cfg(feature = "tunneling")]
#[derive(Debug)]
pub(crate) struct Front {
pub(crate) policy: FrontPolicy,
enabled: AtomicBool,
}
impl Clone for Front {
fn clone(&self) -> Self {
Self {
policy: self.policy.clone(),
enabled: AtomicBool::new(self.enabled.load(Ordering::Relaxed)),
}
}
}
impl Front {
pub(crate) fn new(policy: FrontPolicy) -> Self {
Self {
enabled: AtomicBool::new(policy == FrontPolicy::Always),
policy,
}
}
pub(crate) fn is_enabled(&self) -> bool {
match self.policy {
FrontPolicy::Off => false,
FrontPolicy::OnRetry => self.enabled.load(Ordering::Relaxed),
FrontPolicy::Always => true,
}
}
// Used to indicate that the client hit an error that should trigger the retry policy
// to enable fronting.
pub(crate) fn retry_enable(&self) {
if self.is_enabled() {
return;
}
if matches!(self.policy, FrontPolicy::OnRetry) {
self.enabled.store(true, Ordering::Relaxed);
}
}
}
#[derive(Debug, Default, PartialEq, Clone)]
#[cfg(feature = "tunneling")]
pub enum FrontPolicy {
Always,
OnRetry,
#[default]
Off,
}
impl ClientBuilder {
/// Enable and configure request tunneling for API requests.
#[cfg(feature = "tunneling")]
pub fn with_fronting(mut self, policy: FrontPolicy) -> Self {
let front = Front::new(policy);
// Check if any of the supplied urls even support fronting
if !self.urls.iter().any(|url| url.has_front()) {
warn!("fronting is enabled, but none of the supplied urls have configured fronting domains");
}
self.front = Some(front);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ApiClientCore, Url, NO_PARAMS};
#[tokio::test]
async fn nym_api_works() {
let url1 = Url::new(
"https://validator.global.ssl.fastly.net",
Some(vec!["https://yelp.global.ssl.fastly.net"]),
)
.unwrap(); // fastly
// let url2 = Url::new(
// "https://validator.nymtech.net",
// Some(vec!["https://cdn77.com"]),
// ).unwrap(); // cdn77
let client = ClientBuilder::new::<_, &str>(url1)
.expect("bad url")
.with_fronting(FrontPolicy::Always)
.build::<&str>()
.expect("failed to build client");
let response = client
.send_request::<_, (), &str, &str, &str>(
reqwest::Method::GET,
&["api", "v1", "network", "details"],
NO_PARAMS,
None,
)
.await
.expect("failed get request");
// println!("{response:?}");
assert_eq!(response.status(), 200);
}
}
+427 -304
View File
@@ -136,29 +136,33 @@
//! ```
#![warn(missing_docs)]
pub use reqwest::{IntoUrl, StatusCode};
pub use reqwest::StatusCode;
use crate::path::RequestPath;
use async_trait::async_trait;
use bytes::Bytes;
use http::header::CONTENT_TYPE;
use http::HeaderMap;
use itertools::Itertools;
use mime::Mime;
use reqwest::header::HeaderValue;
use reqwest::{RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use thiserror::Error;
use tracing::{debug, instrument, warn};
use url::Url;
#[cfg(not(target_arch = "wasm32"))]
use std::net::SocketAddr;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
#[cfg(feature = "tunneling")]
mod fronted;
mod url;
pub use url::{IntoUrl, Url};
mod user_agent;
pub use user_agent::UserAgent;
@@ -247,207 +251,6 @@ impl HttpClientError {
}
}
/// A `ClientBuilder` can be used to create a [`Client`] with custom configuration applied consistently
/// and state tracked across subsequent requests.
pub struct ClientBuilder {
url: Url,
timeout: Option<Duration>,
custom_user_agent: bool,
reqwest_client_builder: reqwest::ClientBuilder,
#[allow(dead_code)] // not dead code, just unused in wasm
use_secure_dns: bool,
}
impl ClientBuilder {
/// Constructs a new `ClientBuilder`.
///
/// This is the same as `Client::builder()`.
pub fn new<U, E>(url: U) -> Result<Self, HttpClientError<E>>
where
U: IntoUrl,
E: Display,
{
let str_url = url.as_str();
// a naive check: if the provided URL does not start with http(s), add that scheme
if !str_url.starts_with("http") {
let alt = format!("http://{str_url}");
warn!("the provided url ('{str_url}') does not contain scheme information. Changing it to '{alt}' ...");
// TODO: or should we maybe default to https?
Self::new(alt)
} else {
Ok(Self::new_with_url(url.into_url()?))
}
}
/// Constructs a new http `ClientBuilder` from a valid url.
pub fn new_with_url(url: Url) -> Self {
if !url.scheme().starts_with("http") {
warn!("the provided url ('{url}') does not use HTTP / HTTPS scheme");
}
#[cfg(target_arch = "wasm32")]
let reqwest_client_builder = reqwest::ClientBuilder::new();
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client_builder = {
// Note: I believe the manual enable calls for the compression methods are extra
// as the various compression features for `reqwest` crate should be enabled
// just by including the feature which:
// `"Enable[s] auto decompression by checking the Content-Encoding response header."`
//
// I am going to leave these here anyways so that removing a decompression method
// from the features list will throw an error if it is not also removed here.
reqwest::ClientBuilder::new()
.gzip(true)
.deflate(true)
.brotli(true)
.zstd(true)
};
ClientBuilder {
url,
timeout: None,
custom_user_agent: false,
reqwest_client_builder,
use_secure_dns: true,
}
}
/// Enables a total request timeout other than the default.
///
/// The timeout is applied from when the request starts connecting until the response body has finished. Also considered a total deadline.
///
/// Default is [`DEFAULT_TIMEOUT`].
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Provide a pre-configured [`reqwest::ClientBuilder`]
pub fn with_reqwest_builder(mut self, reqwest_builder: reqwest::ClientBuilder) -> Self {
self.reqwest_client_builder = reqwest_builder;
self
}
/// Sets the `User-Agent` header to be used by this client.
pub fn with_user_agent<V>(mut self, value: V) -> Self
where
V: TryInto<HeaderValue>,
V::Error: Into<http::Error>,
{
self.custom_user_agent = true;
self.reqwest_client_builder = self.reqwest_client_builder.user_agent(value);
self
}
/// Override DNS resolution for specific domains to particular IP addresses.
///
/// Set the port to `0` to use the conventional port for the given scheme (e.g. 80 for http).
/// Ports in the URL itself will always be used instead of the port in the overridden addr.
#[cfg(not(target_arch = "wasm32"))]
pub fn resolve_to_addrs(mut self, domain: &str, addrs: &[SocketAddr]) -> ClientBuilder {
self.reqwest_client_builder = self.reqwest_client_builder.resolve_to_addrs(domain, addrs);
self
}
/// Returns a Client that uses this ClientBuilder configuration.
pub fn build<E>(self) -> Result<Client, HttpClientError<E>>
where
E: Display,
{
#[cfg(target_arch = "wasm32")]
let reqwest_client = self.reqwest_client_builder.build()?;
// TODO: we should probably be propagating the error rather than panicking,
// but that'd break bunch of things due to type changes
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client = {
let mut builder = self
.reqwest_client_builder
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT));
// if no custom user agent was set, use a default
if !self.custom_user_agent {
builder =
builder.user_agent(format!("nym-http-api-client/{}", env!("CARGO_PKG_VERSION")))
}
// unless explicitly disabled use the DoT/DoH enabled resolver
if self.use_secure_dns {
builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::default()));
}
builder.build()?
};
Ok(Client {
base_url: self.url,
reqwest_client,
#[cfg(target_arch = "wasm32")]
request_timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
})
}
}
/// A simple extendable client wrapper for http request with extra url sanitization.
#[derive(Debug, Clone)]
pub struct Client {
base_url: Url,
reqwest_client: reqwest::Client,
#[cfg(target_arch = "wasm32")]
request_timeout: Duration,
}
impl Client {
/// Create a new http `Client`
// no timeout until https://github.com/seanmonstar/reqwest/issues/1135 is fixed
//
// In order to prevent interference in API requests at the DNS phase we default to a resolver
// that uses DoT and DoH.
pub fn new(base_url: Url, timeout: Option<Duration>) -> Self {
Self::new_url::<_, String>(base_url, timeout).expect(
"we provided valid url and we were unwrapping previous construction errors anyway",
)
}
/// Attempt to create a new http client from a something that can be converted to a URL
pub fn new_url<U, E>(url: U, timeout: Option<Duration>) -> Result<Self, HttpClientError<E>>
where
U: IntoUrl,
E: Display,
{
let builder = Self::builder(url)?;
match timeout {
Some(timeout) => builder.with_timeout(timeout).build(),
None => builder.build(),
}
}
/// Creates a [`ClientBuilder`] to configure a [`Client`].
///
/// This is the same as [`ClientBuilder::new()`].
pub fn builder<U, E>(url: U) -> Result<ClientBuilder, HttpClientError<E>>
where
U: IntoUrl,
E: Display,
{
ClientBuilder::new(url)
}
/// Update the host that this client uses when sending API requests.
pub fn change_base_url(&mut self, new_url: Url) {
self.base_url = new_url
}
/// Get the currently configured host that this client uses when sending API requests.
pub fn current_url(&self) -> &Url {
&self.base_url
}
}
/// Core functionality required for types acting as API clients.
///
/// This trait defines the "skinny waist" of behaviors that are required by an API client. More
@@ -548,6 +351,366 @@ pub trait ApiClientCore {
}
}
/// A `ClientBuilder` can be used to create a [`Client`] with custom configuration applied consistently
/// and state tracked across subsequent requests.
pub struct ClientBuilder {
urls: Vec<Url>,
timeout: Option<Duration>,
custom_user_agent: bool,
reqwest_client_builder: reqwest::ClientBuilder,
#[allow(dead_code)] // not dead code, just unused in wasm
use_secure_dns: bool,
#[cfg(feature = "tunneling")]
front: Option<fronted::Front>,
retry_limit: usize,
}
impl ClientBuilder {
/// Constructs a new `ClientBuilder`.
///
/// This is the same as `Client::builder()`.
pub fn new<U, E>(url: U) -> Result<Self, HttpClientError<E>>
where
U: IntoUrl,
E: Display,
{
let str_url = url.as_str();
// a naive check: if the provided URL does not start with http(s), add that scheme
if !str_url.starts_with("http") {
let alt = format!("http://{str_url}");
warn!("the provided url ('{str_url}') does not contain scheme information. Changing it to '{alt}' ...");
// TODO: or should we maybe default to https?
Self::new(alt)
} else {
let url = url.to_url()?;
Ok(Self::new_with_urls(vec![url]))
}
}
/// Constructs a new http `ClientBuilder` from a valid url.
pub fn new_with_urls(urls: Vec<Url>) -> Self {
let urls = Self::check_urls(urls);
#[cfg(target_arch = "wasm32")]
let reqwest_client_builder = reqwest::ClientBuilder::new();
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client_builder = {
// Note: I believe the manual enable calls for the compression methods are extra
// as the various compression features for `reqwest` crate should be enabled
// just by including the feature which:
// `"Enable[s] auto decompression by checking the Content-Encoding response header."`
//
// I am going to leave these here anyways so that removing a decompression method
// from the features list will throw an error if it is not also removed here.
reqwest::ClientBuilder::new()
.gzip(true)
.deflate(true)
.brotli(true)
.zstd(true)
};
ClientBuilder {
urls,
timeout: None,
custom_user_agent: false,
reqwest_client_builder,
use_secure_dns: true,
#[cfg(feature = "tunneling")]
front: None,
retry_limit: 0,
}
}
/// Add an additional URL to the set usable by this constructed `Client`
pub fn add_url(mut self, url: Url) -> Self {
self.urls.push(url);
self
}
fn check_urls(mut urls: Vec<Url>) -> Vec<Url> {
// remove any duplicate URLs
urls = urls.into_iter().unique().collect();
// warn about any invalid URLs
urls.iter()
.filter(|url| !url.scheme().contains("http") && !url.scheme().contains("https"))
.for_each(|url| {
warn!("the provided url ('{url}') does not use HTTP / HTTPS scheme");
});
urls
}
/// Enables a total request timeout other than the default.
///
/// The timeout is applied from when the request starts connecting until the response body has finished. Also considered a total deadline.
///
/// Default is [`DEFAULT_TIMEOUT`].
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Sets the maximum number of retries for a request. This defaults to 0, indicating no retries.
///
/// Note that setting a retry limit of 3 (for example) will result in 4 attempts to send the
/// request in the case that all are unsuccessful.
///
/// If multiple urls (or fronting configurations if enabled) are available, retried requests
/// will be sent to the next URL in the list.
pub fn with_retries(mut self, retry_limit: usize) -> Self {
self.retry_limit = retry_limit;
self
}
/// Provide a pre-configured [`reqwest::ClientBuilder`]
pub fn with_reqwest_builder(mut self, reqwest_builder: reqwest::ClientBuilder) -> Self {
self.reqwest_client_builder = reqwest_builder;
self
}
/// Sets the `User-Agent` header to be used by this client.
pub fn with_user_agent<V>(mut self, value: V) -> Self
where
V: TryInto<HeaderValue>,
V::Error: Into<http::Error>,
{
self.custom_user_agent = true;
self.reqwest_client_builder = self.reqwest_client_builder.user_agent(value);
self
}
/// Override DNS resolution for specific domains to particular IP addresses.
///
/// Set the port to `0` to use the conventional port for the given scheme (e.g. 80 for http).
/// Ports in the URL itself will always be used instead of the port in the overridden addr.
#[cfg(not(target_arch = "wasm32"))]
pub fn resolve_to_addrs(mut self, domain: &str, addrs: &[SocketAddr]) -> ClientBuilder {
self.reqwest_client_builder = self.reqwest_client_builder.resolve_to_addrs(domain, addrs);
self
}
/// Returns a Client that uses this ClientBuilder configuration.
pub fn build<E>(self) -> Result<Client, HttpClientError<E>>
where
E: Display,
{
#[cfg(target_arch = "wasm32")]
let reqwest_client = self.reqwest_client_builder.build()?;
// TODO: we should probably be propagating the error rather than panicking,
// but that'd break bunch of things due to type changes
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client = {
let mut builder = self
.reqwest_client_builder
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT));
// if no custom user agent was set, use a default
if !self.custom_user_agent {
builder =
builder.user_agent(format!("nym-http-api-client/{}", env!("CARGO_PKG_VERSION")))
}
// unless explicitly disabled use the DoT/DoH enabled resolver
if self.use_secure_dns {
builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::default()));
}
builder.build()?
};
let client = Client {
base_urls: self.urls,
current_idx: Arc::new(AtomicUsize::new(0)),
reqwest_client,
#[cfg(feature = "tunneling")]
front: self.front,
#[cfg(target_arch = "wasm32")]
request_timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
retry_limit: self.retry_limit,
};
Ok(client)
}
}
/// A simple extendable client wrapper for http request with extra url sanitization.
#[derive(Debug, Clone)]
pub struct Client {
base_urls: Vec<Url>,
current_idx: Arc<AtomicUsize>,
reqwest_client: reqwest::Client,
#[cfg(feature = "tunneling")]
front: Option<fronted::Front>,
#[cfg(target_arch = "wasm32")]
request_timeout: Duration,
retry_limit: usize,
}
impl Client {
/// Create a new http `Client`
// no timeout until https://github.com/seanmonstar/reqwest/issues/1135 is fixed
//
// In order to prevent interference in API requests at the DNS phase we default to a resolver
// that uses DoT and DoH.
pub fn new(base_url: ::url::Url, timeout: Option<Duration>) -> Self {
Self::new_url::<_, String>(base_url, timeout).expect(
"we provided valid url and we were unwrapping previous construction errors anyway",
)
}
/// Attempt to create a new http client from a something that can be converted to a URL
pub fn new_url<U, E>(url: U, timeout: Option<Duration>) -> Result<Self, HttpClientError<E>>
where
U: IntoUrl,
E: Display,
{
let builder = Self::builder(url)?;
match timeout {
Some(timeout) => builder.with_timeout(timeout).build(),
None => builder.build(),
}
}
/// Creates a [`ClientBuilder`] to configure a [`Client`].
///
/// This is the same as [`ClientBuilder::new()`].
pub fn builder<U, E>(url: U) -> Result<ClientBuilder, HttpClientError<E>>
where
U: IntoUrl,
E: Display,
{
ClientBuilder::new(url)
}
/// Update the set of hosts that this client uses when sending API requests.
pub fn change_base_urls(&mut self, new_urls: Vec<Url>) {
self.current_idx.store(0, Ordering::Relaxed);
self.base_urls = new_urls
}
/// Create new instance of `Client` using the provided base url and existing client config
pub fn clone_with_new_url(&self, new_url: Url) -> Self {
Client {
base_urls: vec![new_url],
current_idx: Arc::new(Default::default()),
reqwest_client: self.reqwest_client.clone(),
front: self.front.clone(),
retry_limit: self.retry_limit,
#[cfg(target_arch = "wasm32")]
request_timeout: self.request_timeout,
}
}
/// Get the currently configured host that this client uses when sending API requests.
pub fn current_url(&self) -> &Url {
&self.base_urls[self.current_idx.load(std::sync::atomic::Ordering::Relaxed)]
}
/// Get the currently configured host that this client uses when sending API requests.
pub fn base_urls(&self) -> &[Url] {
&self.base_urls
}
/// Get a mutable reference to the hosts that this client uses when sending API requests.
pub fn base_urls_mut(&mut self) -> &mut [Url] {
&mut self.base_urls
}
/// Change the currently configured limit on the number of retries for a request.
pub fn change_retry_limit(&mut self, limit: usize) {
self.retry_limit = limit;
}
/// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
fn update_host(&self) {
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
if front.is_enabled() {
// if we are using fronting, try updating to the next front
let url = self.current_url();
// try to update the current host to use a next front, if one is available, otherwise
// we move on and try the next base url (if one is available)
if url.has_front() && !url.update() {
// we swapped to the next front for the current host
return;
}
}
}
if self.base_urls.len() > 1 {
let orig = self.current_idx.load(Ordering::Relaxed);
let mut next = (orig + 1) % self.base_urls.len();
// if fronting is enabled we want to update to a host that has fronts configured
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
if front.is_enabled() {
while next != orig {
if self.base_urls[next].has_front() {
// we have a front for the next host, so we can use it
break;
}
next = (next + 1) % self.base_urls.len();
}
}
}
self.current_idx.store(next, Ordering::Relaxed);
}
}
/// Make modifications to the request to apply the current state of this client i.e. the
/// currently configured host. This is required as a caller may use this client to create a
/// request, but then have the state of the client change before the caller uses the client to
/// send their request.
///
/// This enures that the outgoing requests benefit from the configured fallback mechanisms, even
/// for requests that were created before the state of the client changed.
///
/// This method assumes that any updates to the state of the client are made before the call to
/// this method. For example, if the client is configured to rotate hosts after each error, this
/// method should be called after the host has been updated -- i.e. as part of the subsequent
/// send.
fn apply_hosts_to_req(&self, r: &mut reqwest::Request) {
let url = self.current_url();
r.url_mut().set_host(url.host_str()).unwrap();
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
if front.is_enabled() {
// this should never fail as we are transplanting the host from one url to another
r.url_mut().set_host(url.front_str()).unwrap();
let actual_host: HeaderValue = url
.host_str()
.unwrap_or("")
.parse()
.unwrap_or(HeaderValue::from_static(""));
// If the map did have this key present, the new value is associated with the key
// and all previous values are removed. (reqwest HeaderMap docs)
_ = r.headers_mut().insert(reqwest::header::HOST, actual_host);
}
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl ApiClientCore for Client {
@@ -565,33 +728,77 @@ impl ApiClientCore for Client {
K: AsRef<str>,
V: AsRef<str>,
{
let url = sanitize_url(&self.base_url, path, params);
let url = self.current_url();
let url = sanitize_url(url, path, params);
let mut request = self.reqwest_client.request(method.clone(), url);
let mut req = reqwest::Request::new(method, url.into());
self.apply_hosts_to_req(&mut req);
let mut rb = RequestBuilder::from_parts(self.reqwest_client.clone(), req);
if let Some(body) = json_body {
request = request.json(body);
rb = rb.json(body);
}
request
rb
}
async fn send<E>(&self, request: RequestBuilder) -> Result<Response, HttpClientError<E>>
where
E: Display,
{
#[cfg(target_arch = "wasm32")]
{
Ok(
wasmtimer::tokio::timeout(self.request_timeout, request.send())
.await
.map_err(|_timeout| HttpClientError::RequestTimeout)??,
)
}
let mut attempts = 0;
loop {
// try_clone may fail if the body is a stream in which case using retries is not advised.
let r = request
.try_clone()
.ok_or(HttpClientError::GenericRequestFailure(
"failed to send request".to_string(),
))?;
#[cfg(not(target_arch = "wasm32"))]
{
Ok(request.send().await?)
// apply any changes based on the current state of the client wrt. hosts,
// fronting domains, etc.
let mut req = r.build()?;
self.apply_hosts_to_req(&mut req);
#[cfg(target_arch = "wasm32")]
let response: Result<Response, HttpClientError<E>> = {
Ok(wasmtimer::tokio::timeout(
self.request_timeout,
self.reqwest_client.execute(req),
)
.await
.map_err(|_timeout| HttpClientError::RequestTimeout)??)
};
#[cfg(not(target_arch = "wasm32"))]
let response = self.reqwest_client.execute(req).await;
match response {
Ok(resp) => return Ok(resp),
Err(e) => {
// if we have multiple urls, update to the next
self.update_host();
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
// If fronting is set to be enabled on error, enable domain fronting as we
// have encountered an error.
front.retry_enable();
}
if attempts < self.retry_limit {
warn!("Retrying request due to http error: {}", e);
attempts += 1;
continue;
}
// if we have exhausted our attempts, return the error
#[allow(clippy::useless_conversion)] // conversion considered useless in wasm
return Err(e.into());
}
}
}
}
}
@@ -1049,88 +1256,4 @@ fn try_get_mime_type(headers: &HeaderMap) -> Option<Mime> {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizing_urls() {
let base_url: Url = "http://foomp.com".parse().unwrap();
// works with a full string
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, "/foo//bar/", NO_PARAMS).as_str()
);
// (and leading slash doesn't matter)
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, "foo//bar/", NO_PARAMS).as_str()
);
// works with 1 segment
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["foo"], NO_PARAMS).as_str()
);
// works with 2 segments
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo", "bar"], NO_PARAMS).as_str()
);
// works with leading slash
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["/foo"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["/foo", "bar"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo", "/bar"], NO_PARAMS).as_str()
);
// works with trailing slash
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["foo/"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo/", "bar"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo", "bar/"], NO_PARAMS).as_str()
);
// works with both leading and trailing slash
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["/foo/"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["/foo/", "/bar/"], NO_PARAMS).as_str()
);
// adds params
assert_eq!(
"http://foomp.com/foo/bar?foomp=baz",
sanitize_url(&base_url, &["foo", "bar"], &[("foomp", "baz")]).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar?arg1=val1&arg2=val2",
sanitize_url(
&base_url,
&["/foo/", "/bar/"],
&[("arg1", "val1"), ("arg2", "val2")]
)
.as_str()
);
}
}
mod tests;
+224
View File
@@ -0,0 +1,224 @@
use super::*;
#[test]
fn sanitizing_urls() {
let base_url: Url = "http://foomp.com".parse().unwrap();
// works with a full string
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, "/foo//bar/", NO_PARAMS).as_str()
);
// (and leading slash doesn't matter)
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, "foo//bar/", NO_PARAMS).as_str()
);
// works with 1 segment
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["foo"], NO_PARAMS).as_str()
);
// works with 2 segments
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo", "bar"], NO_PARAMS).as_str()
);
// works with leading slash
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["/foo"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["/foo", "bar"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo", "/bar"], NO_PARAMS).as_str()
);
// works with trailing slash
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["foo/"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo/", "bar"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["foo", "bar/"], NO_PARAMS).as_str()
);
// works with both leading and trailing slash
assert_eq!(
"http://foomp.com/foo",
sanitize_url(&base_url, &["/foo/"], NO_PARAMS).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar",
sanitize_url(&base_url, &["/foo/", "/bar/"], NO_PARAMS).as_str()
);
// adds params
assert_eq!(
"http://foomp.com/foo/bar?foomp=baz",
sanitize_url(&base_url, &["foo", "bar"], &[("foomp", "baz")]).as_str()
);
assert_eq!(
"http://foomp.com/foo/bar?arg1=val1&arg2=val2",
sanitize_url(
&base_url,
&["/foo/", "/bar/"],
&[("arg1", "val1"), ("arg2", "val2")]
)
.as_str()
);
}
// - Do the retries work
// - Do we use fallback urls on retry if multiple are provided
// - Do we use the next front on retry if multiple are provided
// - If we have more retries than urls, do we wrap back to the first one again
// - on error without retries is where we have multiple urls, is the url updated?
#[tokio::test]
async fn api_client_retry() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new_with_urls(vec![
"http://broken.nym.badurl".parse()?,
"http://example.com/".parse()?,
])
.with_retries(3)
.build::<HttpClientError>()?;
let req = client.create_get_request(&["/"], NO_PARAMS);
let resp = client.send::<HttpClientError>(req).await?;
assert_eq!(resp.status(), 200);
// check that the url was updated
assert_eq!(client.current_url().as_str(), "http://example.com/");
Ok(())
}
#[test]
fn host_updating() {
let url = Url::new("http://example.com", None).unwrap();
let mut client = ClientBuilder::new::<_, &str>(url)
.unwrap()
.build::<&str>()
.unwrap();
// check that the url is set correctly
let current_url = client.current_url();
assert_eq!(current_url.as_str(), "http://example.com/");
assert_eq!(current_url.front_str(), None);
// update the url
client.update_host();
// check that the url is still the same since there is one URL
assert_eq!(client.current_url().as_str(), "http://example.com/");
// =======================================
// we rotate through urls when available
let new_urls = vec![
Url::new("http://example.com", None).unwrap(),
Url::new("http://example.org", None).unwrap(),
];
client.change_base_urls(new_urls);
assert_eq!(client.current_url().as_str(), "http://example.com/");
client.update_host();
// check that the url got updated now that there are multiple URLs
assert_eq!(client.current_url().as_str(), "http://example.org/");
assert_eq!(client.current_url().front_str(), None);
client.update_host();
assert_eq!(client.current_url().as_str(), "http://example.com/");
// =======================================
// we rotate through urls when available if fronting is disabled
let new_urls = vec![
Url::new(
"http://example.com",
Some(vec!["http://front1.com", "http://front2.com"]),
)
.unwrap(),
Url::new("http://example.org", None).unwrap(),
];
client.change_base_urls(new_urls);
assert_eq!(client.current_url().as_str(), "http://example.com/");
client.update_host();
// check that the url got updated now that there are multiple URLs
assert_eq!(client.current_url().as_str(), "http://example.org/");
}
#[test]
#[cfg(feature = "tunneling")]
fn fronted_host_updating() {
let url = Url::new("http://example.com", Some(vec!["http://front1.com"])).unwrap();
let mut client = ClientBuilder::new::<_, &str>(url)
.unwrap()
.with_fronting(crate::fronted::FrontPolicy::Always)
.build::<&str>()
.unwrap();
// check that the url is set correctly
let current_url = client.current_url();
assert_eq!(current_url.as_str(), "http://example.com/");
assert_eq!(current_url.front_str(), Some("front1.com"));
// update the url
client.update_host();
// check that the url is still the same since there is one URL and one front
let current_url = client.current_url();
assert_eq!(current_url.as_str(), "http://example.com/");
assert_eq!(current_url.front_str(), Some("front1.com"));
// =======================================
// we rotate through front urls when available if fronting is enabled
let new_urls = vec![
Url::new(
"http://example.com",
Some(vec!["http://front1.com", "http://front2.com"]),
)
.unwrap(),
Url::new("http://example.org", None).unwrap(),
];
client.change_base_urls(new_urls);
let current_url = client.current_url();
assert_eq!(current_url.as_str(), "http://example.com/");
assert_eq!(current_url.front_str(), Some("front1.com"));
// update the url - this should keep the same host but change the front
client.update_host();
let current_url = client.current_url();
// check that the url is still the same since there is one URL
assert_eq!(current_url.as_str(), "http://example.com/");
assert_eq!(current_url.front_str(), Some("front2.com"));
// update the url - this should wrap around to the first front as the second url is not fronted
client.update_host();
let current_url = client.current_url();
assert_eq!(current_url.as_str(), "http://example.com/");
assert_eq!(current_url.front_str(), Some("front1.com"));
}
+291
View File
@@ -0,0 +1,291 @@
//! Url handling for the HTTP API client.
//!
//! This module provides a `Url` struct that wraps around the `url::Url` type and adds
//! functionality for handling front domains, which are used for reverse proxying.
use std::fmt::Display;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use itertools::Itertools;
use url::form_urlencoded;
pub use url::ParseError;
/// A trait to try to convert some type into a `Url`.
pub trait IntoUrl {
/// Parse as a valid `Url`
fn to_url(self) -> Result<Url, ParseError>;
/// Returns the string representation of the URL.
fn as_str(&self) -> &str;
}
impl IntoUrl for &str {
fn to_url(self) -> Result<Url, ParseError> {
let url = url::Url::parse(self)?;
Ok(url.into())
}
fn as_str(&self) -> &str {
self
}
}
impl IntoUrl for String {
fn to_url(self) -> Result<Url, ParseError> {
let url = url::Url::parse(&self)?;
Ok(url.into())
}
fn as_str(&self) -> &str {
self
}
}
impl IntoUrl for reqwest::Url {
fn to_url(self) -> Result<Url, ParseError> {
Ok(self.into())
}
fn as_str(&self) -> &str {
self.as_str()
}
}
/// When configuring fronting, some configurations will require a specific backend host
/// to be used for the request to be properly reverse proxied.
#[derive(Debug, Clone)]
pub struct Url {
url: url::Url,
fronts: Option<Vec<url::Url>>,
current_front: Arc<AtomicUsize>,
}
impl IntoUrl for Url {
fn to_url(self) -> Result<Url, ParseError> {
Ok(self)
}
fn as_str(&self) -> &str {
self.url.as_str()
}
}
impl PartialEq for Url {
fn eq(&self, other: &Self) -> bool {
let current = self.current_front.load(Ordering::Relaxed);
let other_current = other.current_front.load(Ordering::Relaxed);
self.fronts == other.fronts && self.url == other.url && current == other_current
}
}
impl Eq for Url {}
impl std::hash::Hash for Url {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let current = self.current_front.load(Ordering::Relaxed);
self.fronts.hash(state);
self.url.hash(state);
current.hash(state);
}
}
impl Display for Url {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.fronts {
Some(ref fronts) => {
let current = self.current_front.load(Ordering::Relaxed);
if let Some(front) = fronts.get(current) {
write!(f, "{front}=>{}", self.url)
} else {
write!(f, "{}", self.url)
}
}
None => write!(f, "{}", self.url),
}
}
}
impl From<Url> for url::Url {
fn from(val: Url) -> Self {
val.url
}
}
impl From<reqwest::Url> for Url {
fn from(url: url::Url) -> Self {
Self {
url,
fronts: None,
current_front: Arc::new(AtomicUsize::new(0)),
}
}
}
impl AsRef<url::Url> for Url {
fn as_ref(&self) -> &url::Url {
&self.url
}
}
impl AsMut<url::Url> for Url {
fn as_mut(&mut self) -> &mut url::Url {
&mut self.url
}
}
impl std::str::FromStr for Url {
type Err = url::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = url::Url::parse(s)?;
Ok(Self {
url,
fronts: None,
current_front: Arc::new(AtomicUsize::new(0)),
})
}
}
impl Url {
/// Create a new `Url` instance with the given something that can be parsed as a URL and
/// optional tunneling domains
pub fn new<U: reqwest::IntoUrl>(
url: U,
fronts: Option<Vec<U>>,
) -> Result<Self, reqwest::Error> {
let mut url = Self {
url: url.into_url()?,
fronts: None,
current_front: Arc::new(AtomicUsize::new(0)),
};
// ensure that the provided URLs are valid
if let Some(front_domains) = fronts {
let f: Vec<reqwest::Url> = front_domains
.into_iter()
.map(|front| front.into_url())
.try_collect()?;
url.fronts = Some(f);
}
Ok(url)
}
/// Parse an absolute URL from a string.
pub fn parse(s: &str) -> Result<Self, ParseError> {
let url = url::Url::parse(s)?;
Ok(Self {
url,
fronts: None,
current_front: Arc::new(AtomicUsize::new(0)),
})
}
/// Returns true if the URL has a front domain set
pub fn has_front(&self) -> bool {
if let Some(fronts) = &self.fronts {
return !fronts.is_empty();
}
false
}
/// Return the string representation of the current front host (domain or IP address) for this
/// URL, if any.
pub fn front_str(&self) -> Option<&str> {
let current = self.current_front.load(Ordering::Relaxed);
self.fronts
.as_ref()
.and_then(|fronts| fronts.get(current))
.and_then(|url| url.host_str())
}
/// Return the string representation of the host (domain or IP address) for this URL, if any.
pub fn host_str(&self) -> Option<&str> {
self.url.host_str()
}
/// Return the serialization of this URL.
///
/// This is fast since that serialization is already stored in the inner url::Url struct.
pub fn as_str(&self) -> &str {
self.url.as_str()
}
/// Returns true if updating the front wraps back to the first front, or if no fronts are set
pub fn update(&self) -> bool {
if let Some(fronts) = &self.fronts {
if fronts.len() > 1 {
let current = self.current_front.load(Ordering::Relaxed);
let next = (current + 1) % fronts.len();
self.current_front.store(next, Ordering::Relaxed);
return next == 0;
}
}
true
}
/// Return the scheme of this URL, lower-cased, as an ASCII string without the : delimiter.
pub fn scheme(&self) -> &str {
self.url.scheme()
}
/// Parse the URLs query string, if any, as application/x-www-form-urlencoded and return an
/// iterator of (key, value) pairs.
pub fn query_pairs(&self) -> form_urlencoded::Parse<'_> {
self.url.query_pairs()
}
/// Manipulate this URLs query string, viewed as a sequence of name/value pairs in
/// application/x-www-form-urlencoded syntax.
pub fn query_pairs_mut(&mut self) -> form_urlencoded::Serializer<'_, ::url::UrlQuery<'_>> {
self.url.query_pairs_mut()
}
/// Change this URLs query string. If `query` is `None`, this URLs query string will be cleared.
pub fn set_query(&mut self, query: Option<&str>) {
self.url.set_query(query);
}
/// Change this URLs path.
pub fn set_path(&mut self, path: &str) {
self.url.set_path(path);
}
/// Change this URLs scheme.
pub fn set_scheme(&mut self, scheme: &str) {
self.url.set_scheme(scheme).unwrap();
}
/// Change this URLs host.
///
/// Removing the host (calling this with None) will also remove any username, password, and port number.
pub fn set_host(&mut self, host: &str) {
self.url.set_host(Some(host)).unwrap();
}
/// Change this URLs port number.
///
/// Note that default port numbers are not reflected in the serialization.
///
/// If this URL is cannot-be-a-base, does not have a host, or has the `file` scheme; do nothing and return `Err`.
pub fn set_port(&mut self, port: u16) {
self.url.set_port(Some(port)).unwrap();
}
/// Return an object with methods to manipulate this URLs path segments.
///
/// Return Err(()) if this URL is cannot-be-a-base.
pub fn path_segments(&self) -> Option<std::str::Split<'_, char>> {
self.url.path_segments()
}
/// Return an object with methods to manipulate this URLs path segments.
///
/// Return Err(()) if this URL is cannot-be-a-base.
#[allow(clippy::result_unit_err)]
pub fn path_segments_mut(&mut self) -> Result<::url::PathSegmentsMut<'_>, ()> {
self.url.path_segments_mut()
}
}
@@ -30,6 +30,10 @@ impl<T> Bincode<T> {
self.0.headers.insert(name, value.into());
self
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Bincode<U> {
Bincode(self.0.map(op))
}
}
impl<T> IntoResponse for Bincode<T>
@@ -32,6 +32,10 @@ impl<T> Json<T> {
self.0.headers.insert(name, value.into());
self
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Json<U> {
Json(self.0.map(op))
}
}
impl<T> IntoResponse for Json<T>
+16 -2
View File
@@ -14,11 +14,10 @@ pub mod bincode;
pub mod json;
pub mod yaml;
pub use bincode::Bincode;
pub use json::Json;
pub use yaml::Yaml;
pub use bincode::Bincode;
#[derive(Debug, Clone, Default)]
pub(crate) struct ResponseWrapper<T> {
data: T,
@@ -33,6 +32,13 @@ impl<T> ResponseWrapper<T> {
}
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> ResponseWrapper<U> {
ResponseWrapper {
data: op(self.data),
headers: self.headers,
}
}
#[must_use]
pub(crate) fn with_header(
mut self,
@@ -60,6 +66,14 @@ impl<T> FormattedResponse<T> {
}
}
pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> FormattedResponse<U> {
match self {
FormattedResponse::Json(inner) => FormattedResponse::Json(inner.map(op)),
FormattedResponse::Yaml(inner) => FormattedResponse::Yaml(inner.map(op)),
FormattedResponse::Bincode(inner) => FormattedResponse::Bincode(inner.map(op)),
}
}
#[must_use]
pub fn with_header(
self,
@@ -30,6 +30,10 @@ impl<T> Yaml<T> {
self.0.headers.insert(name, value.into());
self
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Yaml<U> {
Yaml(self.0.map(op))
}
}
impl<T> IntoResponse for Yaml<T>
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "nym-noise"
version = "0.1.0"
authors = ["Simon Wicky <simon@nymtech.net>"]
edition = "2021"
license.workspace = true
[dependencies]
arc-swap = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
tracing = { workspace = true }
pin-project = { workspace = true }
sha2 = { workspace = true }
snow = { workspace = true }
strum = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util", "time"] }
tokio-util = { workspace = true, features = ["codec"] }
# internal
nym-crypto = { path = "../crypto" }
nym-noise-keys = { path = "keys" }
[dev-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["full"] }
rand_chacha = { workspace = true }
nym-crypto = { path = "../crypto", features = ["rand"] }
[lints]
workspace = true
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "nym-noise-keys"
version = "0.1.0"
authors = ["Simon Wicky <simon@nymtech.net>"]
edition = "2021"
license.workspace = true
[dependencies]
schemars = { workspace = true, features = ["preserve_order"] }
serde = { workspace = true, features = ["derive"] }
utoipa = { workspace = true }
# internal
nym-crypto = { path = "../../crypto", features = ["asymmetric", "serde"] }
[lints]
workspace = true
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_crypto::asymmetric::x25519;
use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey;
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(from = "u8", into = "u8")]
pub enum NoiseVersion {
V1,
Unknown(u8), //Implies a newer version we don't know
}
impl From<u8> for NoiseVersion {
fn from(value: u8) -> Self {
match value {
1 => NoiseVersion::V1,
other => NoiseVersion::Unknown(other),
}
}
}
impl From<NoiseVersion> for u8 {
fn from(version: NoiseVersion) -> Self {
match version {
NoiseVersion::V1 => 1,
NoiseVersion::Unknown(other) => other,
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
pub struct VersionedNoiseKey {
#[schemars(with = "u8")]
#[schema(value_type = u8)]
pub supported_version: NoiseVersion,
#[schemars(with = "String")]
#[serde(with = "bs58_x25519_pubkey")]
#[schema(value_type = String)]
pub x25519_pubkey: x25519::PublicKey,
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::{
collections::HashMap,
net::{IpAddr, SocketAddr},
sync::Arc,
time::Duration,
};
use arc_swap::ArcSwap;
use nym_crypto::asymmetric::x25519;
use nym_noise_keys::{NoiseVersion, VersionedNoiseKey};
use snow::params::NoiseParams;
use strum::{EnumIter, FromRepr};
#[derive(Default, Debug, Clone, Copy, EnumIter, FromRepr, Eq, PartialEq)]
#[repr(u8)]
#[non_exhaustive]
pub enum NoisePattern {
#[default]
XKpsk3 = 1,
IKpsk2 = 2,
}
impl NoisePattern {
pub(crate) const fn as_str(&self) -> &'static str {
match self {
Self::XKpsk3 => "Noise_XKpsk3_25519_AESGCM_SHA256",
Self::IKpsk2 => "Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s", //Wireguard handshake (not exactly though)
}
}
// SAFETY: we have tests to ensure that hardcoded pattern are correct
#[allow(clippy::unwrap_used)]
pub(crate) fn psk_position(&self) -> u8 {
//automatic parsing, works for correct pattern, more convenient
match self.as_str().find("psk") {
Some(n) => {
let psk_index = n + 3;
let psk_char = self.as_str().chars().nth(psk_index).unwrap();
psk_char.to_string().parse().unwrap()
}
None => 0,
}
}
// SAFETY : we have tests to ensure that hardcoded pattern are correct
#[allow(clippy::unwrap_used)]
pub(crate) fn as_noise_params(&self) -> NoiseParams {
self.as_str().parse().unwrap()
}
}
#[derive(Debug, Default)]
struct SocketAddrToKey {
inner: ArcSwap<HashMap<SocketAddr, VersionedNoiseKey>>,
}
// SW NOTE : Only for phased upgrade. To remove once we decide all nodes have to support Noise
#[derive(Debug, Default)]
struct IpAddrToVersion {
inner: ArcSwap<HashMap<IpAddr, NoiseVersion>>,
}
#[derive(Debug, Clone, Default)]
pub struct NoiseNetworkView {
keys: Arc<SocketAddrToKey>,
support: Arc<IpAddrToVersion>,
}
impl NoiseNetworkView {
pub fn new_empty() -> Self {
NoiseNetworkView {
keys: Default::default(),
support: Default::default(),
}
}
pub fn swap_view(&self, new: HashMap<SocketAddr, VersionedNoiseKey>) {
let noise_support = new
.iter()
.map(|(s_addr, key)| (s_addr.ip(), key.supported_version))
.collect::<HashMap<_, _>>();
self.keys.inner.store(Arc::new(new));
self.support.inner.store(Arc::new(noise_support));
}
}
#[derive(Clone)]
pub struct NoiseConfig {
network: NoiseNetworkView,
pub(crate) local_key: Arc<x25519::KeyPair>,
pub(crate) pattern: NoisePattern,
pub(crate) timeout: Duration,
pub(crate) unsafe_disabled: bool, // allows for nodes to not attempt to do a noise handshake, VERY UNSAFE, FOR DEBUG PURPOSE ONLY
}
impl NoiseConfig {
pub fn new(
noise_key: Arc<x25519::KeyPair>,
network: NoiseNetworkView,
timeout: Duration,
) -> Self {
NoiseConfig {
network,
local_key: noise_key,
pattern: Default::default(),
timeout,
unsafe_disabled: false,
}
}
#[must_use]
pub fn with_noise_pattern(mut self, pattern: NoisePattern) -> Self {
self.pattern = pattern;
self
}
#[must_use]
pub fn with_unsafe_disabled(mut self, disabled: bool) -> Self {
self.unsafe_disabled = disabled;
self
}
pub(crate) fn get_noise_key(&self, s_address: &SocketAddr) -> Option<VersionedNoiseKey> {
self.network.keys.inner.load().get(s_address).copied()
}
// Only for phased update
//SW This can lead to some troubles if two nodes shares the same IP and one support Noise but not the other. This in only for the progressive update though and there is no workaround
pub(crate) fn get_noise_support(&self, ip_addr: IpAddr) -> Option<NoiseVersion> {
let plain_ip_support = self.network.support.inner.load().get(&ip_addr).copied();
// SW default bind address being [::]:1789, it can happen that a responder sees the ipv6-mapped address of the initiator, this check for that
let canonical_ip = &ip_addr.to_canonical();
let canonical_ip_support = self.network.support.inner.load().get(canonical_ip).copied();
plain_ip_support.or(canonical_ip_support)
}
}
#[cfg(test)]
mod tests {
use snow::params::NoiseParams;
use super::NoisePattern;
use std::str::FromStr;
use strum::IntoEnumIterator;
// The goal of these is to make sure every NoisePatterns are correct and unwrap can be used on them
#[test]
fn noise_patterns_are_valid() {
for pattern in NoisePattern::iter() {
assert!(NoiseParams::from_str(pattern.as_str()).is_ok())
}
}
#[test]
fn noise_patterns_psk_position_is_valid() {
for pattern in NoisePattern::iter() {
match pattern {
NoisePattern::XKpsk3 => assert_eq!(pattern.psk_position(), 3),
NoisePattern::IKpsk2 => assert_eq!(pattern.psk_position(), 2),
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::io;
use pin_project::pin_project;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::stream::NoiseStream;
//SW once plain TCP support is dropped, this whole enum can be dropped, and we can only propagate NoiseStream
#[pin_project(project = ConnectionProj)]
pub enum Connection<C> {
Raw(#[pin] C),
Noise(#[pin] Box<NoiseStream<C>>),
}
impl<C> AsyncRead for Connection<C>
where
C: AsyncRead + AsyncWrite + Unpin,
{
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_read(cx, buf),
ConnectionProj::Raw(stream) => stream.poll_read(cx, buf),
}
}
}
impl<C> AsyncWrite for Connection<C>
where
C: AsyncWrite + AsyncRead + Unpin,
{
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<Result<usize, io::Error>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_write(cx, buf),
ConnectionProj::Raw(stream) => stream.poll_write(cx, buf),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), io::Error>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_flush(cx),
ConnectionProj::Raw(stream) => stream.poll_flush(cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), io::Error>> {
match self.project() {
ConnectionProj::Noise(stream) => stream.poll_shutdown(cx),
ConnectionProj::Raw(stream) => stream.poll_shutdown(cx),
}
}
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_noise_keys::NoiseVersion;
use snow::Error;
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NoiseError {
#[error("encountered a Noise decryption error")]
DecryptionError,
#[error("encountered a Noise Protocol error: {0}")]
ProtocolError(Error),
#[error("encountered an IO error: {0}")]
IoError(#[from] io::Error),
#[error("Incorrect state")]
IncorrectStateError,
#[error("Handshake did not complete")]
HandshakeError,
#[error("unknown noise version (encoded value: {encoded})")]
UnknownVersion { encoded: u8 },
#[error("unknown noise pattern (encoded value: {encoded})")]
UnknownPattern { encoded: u8 },
#[error("unknown noise message type (encoded value: {encoded})")]
UnknownMessageType { encoded: u8 },
#[error("failed to generate psk for requested version {noise_version}")]
PskGenerationFailure { noise_version: u8 },
#[error("noise initiator attempted to use version v{noise_version} of the protocol - we don't know how to handle it")]
UnknownVersionHandshake { noise_version: u8 },
#[error("noise initiator attempted to use an unexpected noise pattern. we're configured for {configured} while it requested {received}")]
UnexpectedNoisePattern {
configured: &'static str,
received: &'static str,
},
#[error("handshake version has unexpectedly changed. initial was {initial:?} and received {received:?}")]
UnexpectedHandshakeVersion {
initial: NoiseVersion,
received: NoiseVersion,
},
#[error("data packet version has unexpectedly changed. initial was {initial:?} and received {received:?}")]
UnexpectedDataVersion {
initial: NoiseVersion,
received: NoiseVersion,
},
#[error("received a non-handshake message during noise handshake")]
NonHandshakeMessageReceived,
#[error("received a non-data message post noise handshake")]
NonDataMessageReceived,
#[error("handshake message exceeded maximum size (got {size} bytes)")]
HandshakeTooBig { size: usize },
#[error("noise message exceeded maximum size (got {size} bytes)")]
DataTooBig { size: usize },
#[error("Handshake timeout")]
HandshakeTimeout(#[from] tokio::time::error::Elapsed),
}
impl NoiseError {
pub(crate) fn naive_to_io_error(self) -> std::io::Error {
match self {
NoiseError::IoError(err) => err,
other => std::io::Error::other(other),
}
}
}
impl From<Error> for NoiseError {
fn from(err: Error) -> Self {
match err {
Error::Decrypt => NoiseError::DecryptionError,
err => NoiseError::ProtocolError(err),
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_noise_keys::NoiseVersion;
use snow::error::Prerequisite;
use snow::Error;
use tokio::net::TcpStream;
use tracing::{error, warn};
pub mod config;
pub mod connection;
pub mod error;
pub mod stream;
use crate::config::NoiseConfig;
use crate::connection::Connection;
use crate::error::NoiseError;
use crate::stream::NoiseStreamBuilder;
const NOISE_PSK_PREFIX: &[u8] = b"NYMTECH_NOISE_dQw4w9WgXcQ";
pub const LATEST_NOISE_VERSION: NoiseVersion = NoiseVersion::V1;
// TODO: this should be behind some trait because presumably, depending on the version,
// other arguments would be needed
mod psk_gen {
use crate::error::NoiseError;
use crate::stream::Psk;
use crate::NOISE_PSK_PREFIX;
use nym_crypto::asymmetric::x25519;
use nym_noise_keys::NoiseVersion;
use sha2::{Digest, Sha256};
pub(crate) fn generate_psk(
responder_pub_key: x25519::PublicKey,
version: NoiseVersion,
) -> Result<Psk, NoiseError> {
match version {
NoiseVersion::V1 => Ok(generate_psk_v1(responder_pub_key)),
NoiseVersion::Unknown(noise_version) => {
Err(NoiseError::PskGenerationFailure { noise_version })
}
}
}
fn generate_psk_v1(responder_pub_key: x25519::PublicKey) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(NOISE_PSK_PREFIX);
hasher.update(responder_pub_key.to_bytes());
hasher.finalize().into()
}
}
pub async fn upgrade_noise_initiator(
conn: TcpStream,
config: &NoiseConfig,
) -> Result<Connection<TcpStream>, NoiseError> {
if config.unsafe_disabled {
warn!("Noise is disabled in the config. Not attempting any handshake");
return Ok(Connection::Raw(conn));
}
//Get init material
let responder_addr = conn.peer_addr().map_err(|err| {
error!("Unable to extract peer address from connection - {err}");
Error::Prereq(Prerequisite::RemotePublicKey)
})?;
let Some(key) = config.get_noise_key(&responder_addr) else {
warn!("{responder_addr} can't speak Noise yet, falling back to TCP");
return Ok(Connection::Raw(conn));
};
let handshake_version = match key.supported_version {
NoiseVersion::V1 => NoiseVersion::V1,
// We're talking to a more recent node, but we can't adapt. Let's try to do our best and if it fails, it fails.
// If that node sees we're older, it will try to adapt too.
NoiseVersion::Unknown(version) => {
warn!("{responder_addr} is announcing an v{version} version of Noise that we don't know how to parse, we will attempt to downgrade to our current highest supported version");
LATEST_NOISE_VERSION
}
};
NoiseStreamBuilder::new(conn)
.perform_initiator_handshake(config, handshake_version, key.x25519_pubkey)
.await
.map(|stream| Connection::Noise(Box::new(stream)))
}
pub async fn upgrade_noise_responder(
conn: TcpStream,
config: &NoiseConfig,
) -> Result<Connection<TcpStream>, NoiseError> {
if config.unsafe_disabled {
warn!("Noise is disabled in the config. Not attempting any handshake");
return Ok(Connection::Raw(conn));
}
//Get init material
let initiator_addr = match conn.peer_addr() {
Ok(addr) => addr,
Err(err) => {
error!("Unable to extract peer address from connection - {err}");
return Err(Error::Prereq(Prerequisite::RemotePublicKey).into());
}
};
// if responder doesn't announce noise support, we fallback to tcp
if config.get_noise_support(initiator_addr.ip()).is_none() {
warn!("{initiator_addr} can't speak Noise yet, falling back to TCP",);
return Ok(Connection::Raw(conn));
};
NoiseStreamBuilder::new(conn)
.perform_responder_handshake(config)
.await
.map(|stream| Connection::Noise(Box::new(stream)))
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::NoiseError;
use crate::stream::framing::{NymNoiseFrame, NymNoiseHeader};
use bytes::{BufMut, BytesMut};
use tokio_util::codec::{Decoder, Encoder};
#[derive(Debug, Clone, Copy)]
enum DecodeState {
Header,
Payload(NymNoiseHeader),
}
pub struct NymNoiseCodec {
state: DecodeState,
}
impl NymNoiseCodec {
pub fn new() -> Self {
NymNoiseCodec {
state: DecodeState::Header,
}
}
fn decode_header(&self, src: &mut BytesMut) -> Result<Option<NymNoiseHeader>, NoiseError> {
if src.len() < NymNoiseHeader::SIZE {
// Not enough data
return Ok(None);
}
// note: successful call to 'decode' advances the buffer by NymNoiseHeader::SIZE
let Some(header) = NymNoiseHeader::decode(src)? else {
return Ok(None);
};
Ok(Some(header))
}
fn decode_data(&self, n: usize, src: &mut BytesMut) -> Option<BytesMut> {
// At this point, the buffer has already had the required capacity
// reserved. All there is to do is read.
if src.len() < n {
return None;
}
Some(src.split_to(n))
}
}
impl Decoder for NymNoiseCodec {
type Item = NymNoiseFrame;
type Error = NoiseError;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
let header = match self.state {
DecodeState::Header => match self.decode_header(src)? {
None => return Ok(None),
Some(header) => {
self.state = DecodeState::Payload(header);
header
}
},
DecodeState::Payload(header) => header,
};
let Some(data) = self.decode_data(header.data_len as usize, src) else {
return Ok(None);
};
// Update the decode state
self.state = DecodeState::Header;
// make sure the buffer has enough space to read the next header
src.reserve(NymNoiseHeader::SIZE);
Ok(Some(NymNoiseFrame {
header,
data: data.freeze(),
}))
}
}
impl Encoder<NymNoiseFrame> for NymNoiseCodec {
type Error = NoiseError;
fn encode(&mut self, frame: NymNoiseFrame, dst: &mut BytesMut) -> Result<(), Self::Error> {
frame.header.encode(dst);
dst.put_slice(frame.data.as_ref());
Ok(())
}
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::config::NoisePattern;
use crate::error::NoiseError;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use nym_noise_keys::NoiseVersion;
use strum::FromRepr;
#[derive(Debug)]
pub struct NymNoiseFrame {
pub header: NymNoiseHeader,
pub data: Bytes,
}
impl NymNoiseFrame {
pub fn new_handshake_frame(
data: Bytes,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<Self, NoiseError> {
if data.len() > u16::MAX as usize {
return Err(NoiseError::HandshakeTooBig { size: data.len() });
}
Ok(NymNoiseFrame {
header: NymNoiseHeader {
version,
noise_pattern: pattern,
message_type: NymNoiseMessageType::Handshake,
data_len: data.len() as u16,
},
data,
})
}
pub fn new_data_frame(
data: Bytes,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<Self, NoiseError> {
if data.len() > u16::MAX as usize {
return Err(NoiseError::HandshakeTooBig { size: data.len() });
}
Ok(NymNoiseFrame {
header: NymNoiseHeader {
version,
noise_pattern: pattern,
message_type: NymNoiseMessageType::Data,
data_len: data.len() as u16,
},
data,
})
}
pub fn version(&self) -> NoiseVersion {
self.header.version
}
pub fn is_handshake_message(&self) -> bool {
self.header.is_handshake_message()
}
pub fn is_data_message(&self) -> bool {
self.header.is_data_message()
}
pub fn noise_pattern(&self) -> NoisePattern {
self.header.noise_pattern
}
}
#[derive(Debug, Copy, Clone, FromRepr)]
#[repr(u8)]
#[non_exhaustive]
pub enum NymNoiseMessageType {
Handshake = 0,
Data = 1,
}
#[derive(Debug, Clone, Copy)]
pub struct NymNoiseHeader {
pub version: NoiseVersion,
pub noise_pattern: NoisePattern,
pub message_type: NymNoiseMessageType,
pub data_len: u16,
}
impl NymNoiseHeader {
pub(crate) const SIZE: usize = 8;
pub fn is_handshake_message(&self) -> bool {
matches!(self.message_type, NymNoiseMessageType::Handshake)
}
pub fn is_data_message(&self) -> bool {
matches!(self.message_type, NymNoiseMessageType::Data)
}
// 0 1 2 3 4 5 6 7 8
// +-+-+-+-+-+-+-+-+
// |V|P|T|Len| Res.|
// +-+-+-+-+-+-+-+-+
pub(crate) fn encode(&self, dst: &mut BytesMut) {
dst.reserve(Self::SIZE);
// byte 0
dst.put_u8(self.version.into());
// byte 1
dst.put_u8(self.noise_pattern as u8);
// byte 2
dst.put_u8(self.message_type as u8);
// byte 3-4
dst.put_u16(self.data_len);
// byte 5-7 (RESERVED):
dst.extend_from_slice(&[0u8; 3])
}
pub(crate) fn decode(src: &mut BytesMut) -> Result<Option<Self>, NoiseError> {
if src.len() < Self::SIZE {
// can't do anything if we don't have enough bytes - but reserve enough for the next call
src.reserve(Self::SIZE);
return Ok(None);
}
let version = src.get_u8();
let pattern = src.get_u8();
let message_type = src.get_u8();
let data_len = src.get_u16();
// reserved
src.advance(3);
let version = NoiseVersion::from(version);
// here, based on versions, we could do vary the further parsing
// match version {
// NoiseVersion::V1 => {}
// NoiseVersion::Unknown(_) => {}
// }
let noise_pattern = NoisePattern::from_repr(pattern)
.ok_or(NoiseError::UnknownPattern { encoded: pattern })?;
let message_type =
NymNoiseMessageType::from_repr(message_type).ok_or(NoiseError::UnknownMessageType {
encoded: message_type,
})?;
Ok(Some(NymNoiseHeader {
version,
noise_pattern,
message_type,
data_len,
}))
}
}
+583
View File
@@ -0,0 +1,583 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::config::{NoiseConfig, NoisePattern};
use crate::error::NoiseError;
use crate::psk_gen::generate_psk;
use crate::stream::codec::NymNoiseCodec;
use crate::stream::framing::NymNoiseFrame;
use bytes::{Bytes, BytesMut};
use futures::{Sink, SinkExt, Stream, StreamExt};
use nym_crypto::asymmetric::x25519;
use nym_noise_keys::NoiseVersion;
use snow::{Builder, HandshakeState, TransportState};
use std::io;
use std::pin::Pin;
use std::task::Poll;
use std::{cmp::min, task::ready};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_util::codec::Framed;
mod codec;
mod framing;
const TAGLEN: usize = 16;
const HANDSHAKE_MAX_LEN: usize = 1024; // using this constant to limit the handshake's buffer size
pub(crate) type Psk = [u8; 32];
pub(crate) struct NoiseStreamBuilder<C> {
inner_stream: Framed<C, NymNoiseCodec>,
}
impl<C> NoiseStreamBuilder<C> {
pub(crate) fn new(inner_stream: C) -> Self
where
C: AsyncRead + AsyncWrite,
{
NoiseStreamBuilder {
inner_stream: Framed::new(inner_stream, NymNoiseCodec::new()),
}
}
async fn perform_initiator_handshake_inner(
self,
pattern: NoisePattern,
local_private_key: impl AsRef<[u8]>,
remote_pub_key: impl AsRef<[u8]>,
psk: Psk,
version: NoiseVersion,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let handshake = Builder::new(pattern.as_noise_params())
.local_private_key(local_private_key.as_ref())
.remote_public_key(remote_pub_key.as_ref())
.psk(pattern.psk_position(), &psk)
.build_initiator()?;
self.perform_handshake(handshake, version, pattern).await
}
pub(crate) async fn perform_initiator_handshake(
self,
config: &NoiseConfig,
version: NoiseVersion,
remote_pub_key: x25519::PublicKey,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let psk = generate_psk(remote_pub_key, version)?;
let timeout = config.timeout;
tokio::time::timeout(
timeout,
self.perform_initiator_handshake_inner(
config.pattern,
config.local_key.private_key(),
remote_pub_key,
psk,
version,
),
)
.await?
}
async fn perform_responder_handshake_inner(
mut self,
noise_pattern: NoisePattern,
local_private_key: impl AsRef<[u8]>,
local_pub_key: x25519::PublicKey,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
// 1. we read the first message from the initiator to establish noise version and pattern
// and determine if we can continue with the handshake
let initial_frame = self
.inner_stream
.next()
.await
.ok_or(NoiseError::IoError(io::ErrorKind::BrokenPipe.into()))??;
if !initial_frame.is_handshake_message() {
return Err(NoiseError::NonHandshakeMessageReceived);
}
let pattern = initial_frame.noise_pattern();
// I can imagine we should be able to handle multiple patterns here, but I guess there's a reason a value is set in the config
// but refactoring this shouldn't be too difficult
if pattern != noise_pattern {
return Err(NoiseError::UnexpectedNoisePattern {
configured: noise_pattern.as_str(),
received: pattern.as_str(),
});
}
// 2. generate psk and handshake state
let psk = generate_psk(local_pub_key, initial_frame.header.version)?;
let mut handshake = Builder::new(pattern.as_noise_params())
.local_private_key(local_private_key.as_ref())
.psk(pattern.psk_position(), &psk)
.build_responder()?;
// update handshake state with initial frame
let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN);
handshake.read_message(&initial_frame.data, &mut buf)?;
// 3. run handshake to completion
self.perform_handshake(handshake, initial_frame.version(), pattern)
.await
}
pub(crate) async fn perform_responder_handshake(
self,
config: &NoiseConfig,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let timeout = config.timeout;
tokio::time::timeout(
timeout,
self.perform_responder_handshake_inner(
config.pattern,
config.local_key.private_key(),
*config.local_key.public_key(),
),
)
.await?
}
async fn send_handshake_msg(
&mut self,
handshake: &mut HandshakeState,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<(), NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); // we're in the handshake, we can afford a smaller buffer
let len = handshake.write_message(&[], &mut buf)?;
buf.truncate(len);
let frame = NymNoiseFrame::new_handshake_frame(buf.freeze(), version, pattern)?;
self.inner_stream.send(frame).await?;
Ok(())
}
async fn recv_handshake_msg(
&mut self,
handshake: &mut HandshakeState,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<(), NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
match self.inner_stream.next().await {
Some(Ok(frame)) => {
// validate the frame
if !frame.is_handshake_message() {
return Err(NoiseError::NonHandshakeMessageReceived);
}
if frame.version() != version {
return Err(NoiseError::UnexpectedHandshakeVersion {
initial: version,
received: frame.version(),
});
}
if frame.noise_pattern() != pattern {
return Err(NoiseError::UnexpectedNoisePattern {
configured: pattern.as_str(),
received: frame.noise_pattern().as_str(),
});
}
let mut buf = BytesMut::zeroed(HANDSHAKE_MAX_LEN); // we're in the handshake, we can afford a smaller buffer
handshake.read_message(&frame.data, &mut buf)?;
Ok(())
}
Some(Err(err)) => Err(err),
None => Err(NoiseError::HandshakeError),
}
}
async fn perform_handshake(
mut self,
mut handshake_state: HandshakeState,
version: NoiseVersion,
pattern: NoisePattern,
) -> Result<NoiseStream<C>, NoiseError>
where
C: AsyncRead + AsyncWrite + Unpin,
{
while !handshake_state.is_handshake_finished() {
if handshake_state.is_my_turn() {
self.send_handshake_msg(&mut handshake_state, version, pattern)
.await?;
} else {
self.recv_handshake_msg(&mut handshake_state, version, pattern)
.await?;
}
}
let transport = handshake_state.into_transport_mode()?;
Ok(NoiseStream {
inner_stream: self.inner_stream,
negotiated_pattern: pattern,
negotiated_version: version,
transport,
dec_buffer: Default::default(),
})
}
}
/// Wrapper around a TcpStream
pub struct NoiseStream<C> {
inner_stream: Framed<C, NymNoiseCodec>,
negotiated_pattern: NoisePattern,
negotiated_version: NoiseVersion,
transport: TransportState,
dec_buffer: BytesMut,
}
impl<C> NoiseStream<C> {
fn validate_data_frame(&self, frame: NymNoiseFrame) -> Result<Bytes, NoiseError> {
if !frame.is_data_message() {
return Err(NoiseError::NonDataMessageReceived);
}
// validate the frame
if !frame.is_data_message() {
return Err(NoiseError::NonDataMessageReceived);
}
if frame.version() != self.negotiated_version {
return Err(NoiseError::UnexpectedDataVersion {
initial: self.negotiated_version,
received: frame.version(),
});
}
if frame.noise_pattern() != self.negotiated_pattern {
return Err(NoiseError::UnexpectedNoisePattern {
configured: self.negotiated_pattern.as_str(),
received: frame.noise_pattern().as_str(),
});
};
Ok(frame.data)
}
fn poll_data_frame(
&mut self,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<io::Result<Bytes>>>
where
C: AsyncRead + AsyncWrite + Unpin,
{
match ready!(Pin::new(&mut self.inner_stream).poll_next(cx)) {
None => Poll::Ready(None),
Some(Err(err)) => Poll::Ready(Some(Err(err.naive_to_io_error()))),
Some(Ok(frame)) => match self.validate_data_frame(frame) {
Err(err) => Poll::Ready(Some(Err(err.naive_to_io_error()))),
Ok(data) => Poll::Ready(Some(Ok(data))),
},
}
}
}
impl<C> AsyncRead for NoiseStream<C>
where
C: AsyncRead + AsyncWrite + Unpin,
{
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let pending = match self.poll_data_frame(cx) {
Poll::Pending => {
//no new data, a return value of Poll::Pending means the waking is already scheduled
//Nothing new to decrypt, only check if we can return something from dec_storage, happens after
true
}
Poll::Ready(Some(Ok(noise_msg))) => {
// We have a new noise msg
let mut dec_msg = BytesMut::zeroed(noise_msg.len() - TAGLEN);
let len = match self.transport.read_message(&noise_msg, &mut dec_msg) {
Ok(len) => len,
Err(_) => return Poll::Ready(Err(io::ErrorKind::InvalidInput.into())),
};
self.dec_buffer.extend(&dec_msg[..len]);
false
}
Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(err)),
Poll::Ready(None) => {
//Stream is done, we might still have data in the buffer though, happens afterward
false
}
};
// Checking if there is something to return from the buffer
let read_len = min(buf.remaining(), self.dec_buffer.len());
if read_len > 0 {
buf.put_slice(&self.dec_buffer.split_to(read_len));
return Poll::Ready(Ok(()));
}
// buf.remaining == 0 or nothing in the buffer, we must return the value we had from the inner_stream
if pending {
//If we end up here, it means the previous poll_next was pending as well, hence waking is already scheduled
Poll::Pending
} else {
Poll::Ready(Ok(()))
}
}
}
impl<C> AsyncWrite for NoiseStream<C>
where
C: AsyncWrite + Unpin,
{
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, std::io::Error>> {
// returns on Poll::Pending and Poll:Ready(Err)
ready!(Pin::new(&mut self.inner_stream).poll_ready(cx))
.map_err(|err| err.naive_to_io_error())?;
// we can send at most u16::MAX bytes in a frame, but we also have to include the tag when encoding
let msg_len = min(u16::MAX as usize - TAGLEN, buf.len());
// Ready to send, encrypting message
let mut noise_buf = BytesMut::zeroed(msg_len + TAGLEN);
let Ok(len) = self
.transport
.write_message(&buf[..msg_len], &mut noise_buf)
else {
return Poll::Ready(Err(io::ErrorKind::InvalidInput.into()));
};
noise_buf.truncate(len);
let frame = NymNoiseFrame::new_data_frame(
noise_buf.freeze(),
self.negotiated_version,
self.negotiated_pattern,
)
.map_err(|err| err.naive_to_io_error())?;
// Tokio uses the same `start_send ` in their SinkWriter implementation. https://docs.rs/tokio-util/latest/src/tokio_util/io/sink_writer.rs.html#104
match Pin::new(&mut self.inner_stream).start_send(frame) {
Ok(()) => Poll::Ready(Ok(msg_len)),
Err(e) => Poll::Ready(Err(e.naive_to_io_error())),
}
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner_stream)
.poll_flush(cx)
.map_err(|err| err.naive_to_io_error())
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner_stream)
.poll_close(cx)
.map_err(|err| err.naive_to_io_error())
}
}
#[cfg(test)]
mod tests {
use super::*;
use nym_crypto::asymmetric::x25519;
use rand_chacha::rand_core::SeedableRng;
use std::io::Error;
use std::mem;
use std::sync::Arc;
use std::task::{Context, Waker};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::join;
use tokio::sync::Mutex;
use tokio::time::timeout;
fn mock_streams() -> (MockStream, MockStream) {
let ch1 = Arc::new(Mutex::new(Default::default()));
let ch2 = Arc::new(Mutex::new(Default::default()));
(
MockStream {
inner: MockStreamInner {
tx: ch1.clone(),
rx: ch2.clone(),
},
},
MockStream {
inner: MockStreamInner { tx: ch2, rx: ch1 },
},
)
}
struct MockStream {
inner: MockStreamInner,
}
#[allow(dead_code)]
impl MockStream {
fn unchecked_tx_data(&self) -> Vec<u8> {
self.inner.tx.try_lock().unwrap().data.clone()
}
fn unchecked_rx_data(&self) -> Vec<u8> {
self.inner.rx.try_lock().unwrap().data.clone()
}
}
struct MockStreamInner {
tx: Arc<Mutex<DataWrapper>>,
rx: Arc<Mutex<DataWrapper>>,
}
#[derive(Default)]
struct DataWrapper {
data: Vec<u8>,
waker: Option<Waker>,
}
impl AsyncRead for MockStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let mut inner = self.inner.rx.try_lock().unwrap();
let data = mem::take(&mut inner.data);
if data.is_empty() {
inner.waker = Some(cx.waker().clone());
return Poll::Pending;
}
if let Some(waker) = inner.waker.take() {
waker.wake();
}
buf.put_slice(&data);
Poll::Ready(Ok(()))
}
}
impl AsyncWrite for MockStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, Error>> {
let mut inner = self.inner.tx.try_lock().unwrap();
let len = buf.len();
if !inner.data.is_empty() {
assert!(inner.waker.is_none());
inner.waker = Some(cx.waker().clone());
return Poll::Pending;
}
inner.data.extend_from_slice(buf);
if let Some(waker) = inner.waker.take() {
waker.wake();
}
Poll::Ready(Ok(len))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn noise_handshake() -> anyhow::Result<()> {
let dummy_seed = [42u8; 32];
let mut rng = rand_chacha::ChaCha20Rng::from_seed(dummy_seed);
let initiator_keys = Arc::new(x25519::KeyPair::new(&mut rng));
let responder_keys = Arc::new(x25519::KeyPair::new(&mut rng));
let (initiator_stream, responder_stream) = mock_streams();
let psk = generate_psk(*responder_keys.public_key(), NoiseVersion::V1)?;
let pattern = NoisePattern::default();
let stream_initiator = NoiseStreamBuilder::new(initiator_stream)
.perform_initiator_handshake_inner(
pattern,
initiator_keys.private_key().to_bytes(),
responder_keys.public_key().to_bytes(),
psk,
NoiseVersion::V1,
);
let stream_responder = NoiseStreamBuilder::new(responder_stream)
.perform_responder_handshake_inner(
pattern,
responder_keys.private_key().to_bytes(),
*responder_keys.public_key(),
);
let initiator_fut =
tokio::spawn(
async move { timeout(Duration::from_millis(200), stream_initiator).await },
);
let responder_fut =
tokio::spawn(
async move { timeout(Duration::from_millis(200), stream_responder).await },
);
let (initiator, responder) = join!(initiator_fut, responder_fut);
let mut initiator = initiator???;
let mut responder = responder???;
let msg = b"hello there";
// if noise was successful we should be able to write a proper message across
timeout(Duration::from_millis(200), initiator.write_all(msg)).await??;
initiator.inner_stream.flush().await?;
let inner_buf = initiator.inner_stream.get_ref().unchecked_tx_data();
let mut buf = [0u8; 11];
timeout(Duration::from_millis(200), responder.read(&mut buf)).await??;
assert_eq!(&buf[..], msg);
// the inner content is different from the actual msg since it was encrypted
assert_ne!(inner_buf, buf);
assert_ne!(inner_buf.len(), msg.len());
Ok(())
}
}
@@ -10,7 +10,6 @@ repository = { workspace = true }
[dependencies]
rand = { workspace = true }
bs58 = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
@@ -22,7 +21,7 @@ nym-sphinx-types = { path = "../types" }
nym-topology = { path = "../../topology" }
[target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen]
version = "0.2.95"
workspace = true
[dev-dependencies]
rand_chacha = { workspace = true }
@@ -6,4 +6,4 @@ pub mod reply_surb;
pub mod requests;
pub use encryption_key::{SurbEncryptionKey, SurbEncryptionKeySize};
pub use reply_surb::{ReplySurb, ReplySurbError};
pub use reply_surb::{ReplySurb, ReplySurbError, ReplySurbWithKeyRotation};
@@ -8,16 +8,13 @@ use nym_sphinx_addressing::nodes::{
NymNodeRoutingAddress, NymNodeRoutingAddressError, MAX_NODE_ADDRESS_UNPADDED_LEN,
};
use nym_sphinx_params::packet_sizes::PacketSize;
use nym_sphinx_params::{PacketType, ReplySurbKeyDigestAlgorithm};
use nym_sphinx_params::{PacketType, ReplySurbKeyDigestAlgorithm, SphinxKeyRotation};
use nym_sphinx_types::{
NymPacket, SURBMaterial, SphinxError, HEADER_SIZE, NODE_ADDRESS_LENGTH, SURB,
X25519_WITH_EXPLICIT_PAYLOAD_KEYS_VERSION,
};
use nym_topology::{NymRouteProvider, NymTopologyError};
use rand::{CryptoRng, RngCore};
use serde::de::{Error as SerdeError, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::{self, Formatter};
use std::time::Duration;
use thiserror::Error;
@@ -48,44 +45,6 @@ pub struct ReplySurb {
pub(crate) encryption_key: SurbEncryptionKey,
}
// Serialize + Deserialize is not really used anymore (it was for a CBOR experiment)
// however, if we decided we needed it again, it's already here
impl Serialize for ReplySurb {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(&self.to_bytes())
}
}
impl<'de> Deserialize<'de> for ReplySurb {
fn deserialize<D>(deserializer: D) -> Result<Self, <D as Deserializer<'de>>::Error>
where
D: Deserializer<'de>,
{
struct ReplySurbVisitor;
impl Visitor<'_> for ReplySurbVisitor {
type Value = ReplySurb;
fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
write!(formatter, "A replySURB must contain a valid symmetric encryption key and a correctly formed sphinx header")
}
fn visit_bytes<E>(self, bytes: &[u8]) -> Result<Self::Value, E>
where
E: SerdeError,
{
ReplySurb::from_bytes(bytes)
.map_err(|_| SerdeError::invalid_length(bytes.len(), &self))
}
}
deserializer.deserialize_bytes(ReplySurbVisitor)
}
}
impl ReplySurb {
/// base overhead of a reply surb that exists regardless of type or number of key materials.
pub(crate) const BASE_OVERHEAD: usize =
@@ -123,6 +82,7 @@ impl ReplySurb {
Ok(ReplySurb {
surb: surb_material.construct_SURB().unwrap(),
encryption_key: SurbEncryptionKey::new(rng),
// used_key_rotation: SphinxKeyRotation::from(topology.current_key_rotation()),
})
}
@@ -198,8 +158,75 @@ impl ReplySurb {
.use_surb(message_bytes, packet_size.payload_size())
.expect("this error indicates inconsistent message length checking - it shouldn't have happened!");
let first_hop_address = NymNodeRoutingAddress::try_from(first_hop).unwrap();
let first_hop_address = NymNodeRoutingAddress::try_from(first_hop)?;
Ok((NymPacket::Sphinx(packet), first_hop_address))
}
pub fn to_legacy(self) -> ReplySurbWithKeyRotation {
self.with_key_rotation(SphinxKeyRotation::Unknown)
}
pub fn with_key_rotation(self, key_rotation: SphinxKeyRotation) -> ReplySurbWithKeyRotation {
ReplySurbWithKeyRotation {
inner: self,
key_rotation,
}
}
}
#[derive(Debug)]
pub struct ReplySurbWithKeyRotation {
pub(crate) inner: ReplySurb,
pub(crate) key_rotation: SphinxKeyRotation,
}
impl ReplySurbWithKeyRotation {
pub fn encryption_key(&self) -> &SurbEncryptionKey {
self.inner.encryption_key()
}
pub fn inner_reply_surb(&self) -> &ReplySurb {
&self.inner
}
pub fn key_rotation(&self) -> SphinxKeyRotation {
self.key_rotation
}
pub fn apply_surb<M: AsRef<[u8]>>(
self,
message: M,
packet_size: PacketSize,
_packet_type: PacketType,
) -> Result<AppliedReplySurb, ReplySurbError> {
let (packet, first_hop_address) =
self.inner.apply_surb(message, packet_size, _packet_type)?;
Ok(AppliedReplySurb {
packet,
first_hop_address,
key_rotation: self.key_rotation,
})
}
}
pub struct AppliedReplySurb {
pub(crate) packet: NymPacket,
pub(crate) first_hop_address: NymNodeRoutingAddress,
pub(crate) key_rotation: SphinxKeyRotation,
}
impl AppliedReplySurb {
pub fn first_hop_address(&self) -> NymNodeRoutingAddress {
self.first_hop_address
}
pub fn key_rotation(&self) -> SphinxKeyRotation {
self.key_rotation
}
pub fn into_packet(self) -> NymPacket {
self.packet
}
}
@@ -1,16 +1,16 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{ReplySurb, ReplySurbError};
use crate::requests::v1::{AdditionalSurbsV1, DataV1, HeartbeatV1};
use crate::requests::v2::{AdditionalSurbsV2, DataV2, HeartbeatV2};
use crate::{ReplySurbError, ReplySurbWithKeyRotation};
use nym_sphinx_addressing::clients::{Recipient, RecipientFormattingError};
use nym_sphinx_params::key_rotation::InvalidSphinxKeyRotation;
use rand::{CryptoRng, RngCore};
use std::fmt::{Display, Formatter};
use std::mem;
use thiserror::Error;
use crate::requests::v1::{AdditionalSurbsV1, DataV1, HeartbeatV1};
use crate::requests::v2::{AdditionalSurbsV2, DataV2, HeartbeatV2};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
@@ -84,7 +84,7 @@ impl AnonymousSenderTag {
#[derive(Debug, Error)]
pub enum InvalidReplyRequestError {
#[error("Did not provide sufficient number of bytes to deserialize a valid request")]
#[error("Did not provide sufficient number of bytes to deserialise a valid request")]
RequestTooShortToDeserialize,
#[error("{received} is not a valid content tag for a repliable message")]
@@ -93,10 +93,13 @@ pub enum InvalidReplyRequestError {
#[error("{received} is not a valid content tag for a reply message")]
InvalidReplyContentTag { received: u8 },
#[error("failed to deserialize recipient information - {0}")]
#[error("failed to deserialise sphinx key rotation details: {0}")]
MalformedSphinxKeyRotation(#[from] InvalidSphinxKeyRotation),
#[error("failed to deserialise recipient information: {0}")]
MalformedRecipient(#[from] RecipientFormattingError),
#[error("failed to deserialize replySURB - {0}")]
#[error("failed to deserialise replySURB: {0}")]
MalformedReplySurb(#[from] ReplySurbError),
}
@@ -136,7 +139,7 @@ impl RepliableMessage {
use_legacy_surb_format: bool,
data: Vec<u8>,
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Self {
let content = if use_legacy_surb_format {
RepliableMessageContent::Data(DataV1 {
@@ -159,7 +162,7 @@ impl RepliableMessage {
pub fn new_additional_surbs(
use_legacy_surb_format: bool,
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Self {
let content = if use_legacy_surb_format {
RepliableMessageContent::AdditionalSurbs(AdditionalSurbsV1 { reply_surbs })
@@ -484,9 +487,10 @@ mod tests {
use crate::requests::v1::{AdditionalSurbsV1, DataV1, HeartbeatV1};
use crate::requests::v2::{AdditionalSurbsV2, DataV2, HeartbeatV2};
use crate::requests::{AnonymousSenderTag, RepliableMessageContent, ReplyMessageContent};
use crate::{ReplySurb, SurbEncryptionKey};
use crate::{ReplySurb, ReplySurbWithKeyRotation, SurbEncryptionKey};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_sphinx_addressing::clients::Recipient;
use nym_sphinx_params::SphinxKeyRotation;
use nym_sphinx_types::{
Delay, Destination, DestinationAddressBytes, Node, NodeAddressBytes, PrivateKey,
SURBMaterial, NODE_ADDRESS_LENGTH, X25519_WITH_EXPLICIT_PAYLOAD_KEYS_VERSION,
@@ -571,10 +575,12 @@ mod tests {
n: usize,
legacy: bool,
hops: u8,
) -> Vec<ReplySurb> {
) -> Vec<ReplySurbWithKeyRotation> {
let mut surbs = Vec::with_capacity(n);
for _ in 0..n {
surbs.push(reply_surb(rng, legacy, hops))
surbs.push(
reply_surb(rng, legacy, hops).with_key_rotation(SphinxKeyRotation::Unknown),
)
}
surbs
}
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::requests::InvalidReplyRequestError;
use crate::ReplySurb;
use crate::{ReplySurb, ReplySurbWithKeyRotation};
use nym_sphinx_types::PAYLOAD_KEY_SIZE;
use std::fmt::Display;
use std::mem;
@@ -14,10 +14,10 @@ const fn v1_reply_surb_serialised_len() -> usize {
ReplySurb::BASE_OVERHEAD + 4 * PAYLOAD_KEY_SIZE
}
fn v1_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
fn v1_reply_surbs_serialised_len(surbs: &[ReplySurbWithKeyRotation]) -> usize {
// sanity checks; this should probably be removed later on
if let Some(reply_surb) = surbs.first() {
if reply_surb.surb.uses_key_seeds() {
if reply_surb.inner.surb.uses_key_seeds() {
error!("using v1 surbs encoding with updated structure - the surbs will be unusable")
}
}
@@ -30,7 +30,7 @@ fn v1_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
// NUM_SURBS (u32) || SURB_DATA
fn recover_reply_surbs_v1(
bytes: &[u8],
) -> Result<(Vec<ReplySurb>, usize), InvalidReplyRequestError> {
) -> Result<(Vec<ReplySurbWithKeyRotation>, usize), InvalidReplyRequestError> {
let mut consumed = mem::size_of::<u32>();
if bytes.len() < consumed {
return Err(InvalidReplyRequestError::RequestTooShortToDeserialize);
@@ -45,7 +45,7 @@ fn recover_reply_surbs_v1(
let mut reply_surbs = Vec::with_capacity(num_surbs as usize);
for _ in 0..num_surbs as usize {
let surb_bytes = &bytes[consumed..consumed + surb_size];
let reply_surb = ReplySurb::from_bytes(surb_bytes)?;
let reply_surb = ReplySurb::from_bytes(surb_bytes)?.to_legacy();
reply_surbs.push(reply_surb);
consumed += surb_size;
@@ -55,19 +55,21 @@ fn recover_reply_surbs_v1(
}
// length (u32) prefixed reply surbs with legacy serialisation of 4 hops and full payload keys attached
fn reply_surbs_bytes_v1(reply_surbs: &[ReplySurb]) -> impl Iterator<Item = u8> + use<'_> {
fn reply_surbs_bytes_v1(
reply_surbs: &[ReplySurbWithKeyRotation],
) -> impl Iterator<Item = u8> + use<'_> {
let num_surbs = reply_surbs.len() as u32;
num_surbs
.to_be_bytes()
.into_iter()
.chain(reply_surbs.iter().flat_map(|s| s.to_bytes()))
.chain(reply_surbs.iter().flat_map(|s| s.inner.to_bytes()))
}
#[derive(Debug)]
pub struct DataV1 {
pub message: Vec<u8>,
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for DataV1 {
@@ -83,7 +85,7 @@ impl Display for DataV1 {
#[derive(Debug)]
pub struct AdditionalSurbsV1 {
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for AdditionalSurbsV1 {
@@ -98,7 +100,7 @@ impl Display for AdditionalSurbsV1 {
#[derive(Debug)]
pub struct HeartbeatV1 {
pub additional_reply_surbs: Vec<ReplySurb>,
pub additional_reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for HeartbeatV1 {
@@ -2,7 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
use crate::requests::InvalidReplyRequestError;
use crate::ReplySurb;
use crate::{ReplySurb, ReplySurbWithKeyRotation};
use nym_sphinx_params::SphinxKeyRotation;
use nym_sphinx_types::constants::PAYLOAD_KEY_SEED_SIZE;
use std::fmt::Display;
use std::iter::once;
@@ -13,21 +14,29 @@ const fn v2_reply_surb_serialised_len(num_hops: u8) -> usize {
}
// sphinx doesn't support more than 5 hops (so cast to u8 is safe)
// ASSUMPTION: all surbs are generated with the same parameters (if they're not, then the client is hurting itself)
fn reply_surbs_hops(reply_surbs: &[ReplySurb]) -> u8 {
// ASSUMPTION: all surbs are generated with the same parameters (if they're not, then the client is hurting itself),
// which includes the same number of hops and the same underlying sphinx key rotation
fn reply_surbs_hops(reply_surbs: &[ReplySurbWithKeyRotation]) -> u8 {
reply_surbs
.first()
.map(|reply_surb| reply_surb.surb.materials_count() as u8)
.map(|reply_surb| reply_surb.inner.surb.materials_count() as u8)
.unwrap_or_default()
}
fn v2_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
fn key_rotation(reply_surbs: &[ReplySurbWithKeyRotation]) -> SphinxKeyRotation {
reply_surbs
.first()
.map(|reply_surb| reply_surb.key_rotation)
.unwrap_or_default()
}
fn v2_reply_surbs_serialised_len(surbs: &[ReplySurbWithKeyRotation]) -> usize {
let num_surbs = surbs.len();
let num_hops = reply_surbs_hops(surbs);
// sanity checks; this should probably be removed later on
if let Some(reply_surb) = surbs.first() {
if !reply_surb.surb.uses_key_seeds() {
if !reply_surb.inner.surb.uses_key_seeds() {
error!("using v2 surbs encoding with legacy structure - the surbs will be unusable")
}
}
@@ -35,14 +44,14 @@ fn v2_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
// when serialising surbs are always prepended with:
// - u16-encoded count,
// - u8-encoded number of hops
// - u8 reserved value
// - u8-encoded sphinx key rotation (or unused for 'old' variant)
4 + num_surbs * v2_reply_surb_serialised_len(num_hops)
}
// NUM_SURBS (u16) || HOPS (u8) || RESERVED (u8) || SURB_DATA
// NUM_SURBS (u16) || HOPS (u8) || KEY ROTATION (u8) || SURB_DATA
fn recover_reply_surbs_v2(
bytes: &[u8],
) -> Result<(Vec<ReplySurb>, usize), InvalidReplyRequestError> {
) -> Result<(Vec<ReplySurbWithKeyRotation>, usize), InvalidReplyRequestError> {
if bytes.len() < 4 {
return Err(InvalidReplyRequestError::RequestTooShortToDeserialize);
}
@@ -50,7 +59,7 @@ fn recover_reply_surbs_v2(
// we're not attaching more than 65k surbs...
let num_surbs = u16::from_be_bytes([bytes[0], bytes[1]]);
let num_hops = bytes[2];
let _reserved = bytes[3];
let key_rotation = SphinxKeyRotation::try_from(bytes[3])?;
let mut consumed = 4;
let surb_size = v2_reply_surb_serialised_len(num_hops);
@@ -61,7 +70,7 @@ fn recover_reply_surbs_v2(
let mut reply_surbs = Vec::with_capacity(num_surbs as usize);
for _ in 0..num_surbs as usize {
let surb_bytes = &bytes[consumed..consumed + surb_size];
let reply_surb = ReplySurb::from_bytes(surb_bytes)?;
let reply_surb = ReplySurb::from_bytes(surb_bytes)?.with_key_rotation(key_rotation);
reply_surbs.push(reply_surb);
consumed += surb_size;
@@ -70,23 +79,25 @@ fn recover_reply_surbs_v2(
Ok((reply_surbs, consumed))
}
fn reply_surbs_bytes_v2(reply_surbs: &[ReplySurb]) -> impl Iterator<Item = u8> + use<'_> {
fn reply_surbs_bytes_v2(
reply_surbs: &[ReplySurbWithKeyRotation],
) -> impl Iterator<Item = u8> + use<'_> {
let num_surbs = reply_surbs.len() as u16;
let num_hops = reply_surbs_hops(reply_surbs);
let reserved = 0;
let key_rotation = key_rotation(reply_surbs) as u8;
num_surbs
.to_be_bytes()
.into_iter()
.chain(once(num_hops))
.chain(once(reserved))
.chain(reply_surbs.iter().flat_map(|surb| surb.to_bytes()))
.chain(once(key_rotation))
.chain(reply_surbs.iter().flat_map(|surb| surb.inner.to_bytes()))
}
#[derive(Debug)]
pub struct DataV2 {
pub message: Vec<u8>,
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for DataV2 {
@@ -102,7 +113,7 @@ impl Display for DataV2 {
#[derive(Debug)]
pub struct AdditionalSurbsV2 {
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for AdditionalSurbsV2 {
@@ -117,7 +128,7 @@ impl Display for AdditionalSurbsV2 {
#[derive(Debug)]
pub struct HeartbeatV2 {
pub additional_reply_surbs: Vec<ReplySurb>,
pub additional_reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for HeartbeatV2 {
+12 -2
View File
@@ -10,7 +10,9 @@ use nym_sphinx_addressing::nodes::NymNodeRoutingAddress;
use nym_sphinx_chunking::fragment::COVER_FRAG_ID;
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_params::packet_sizes::PacketSize;
use nym_sphinx_params::{PacketEncryptionAlgorithm, PacketHkdfAlgorithm, PacketType};
use nym_sphinx_params::{
PacketEncryptionAlgorithm, PacketHkdfAlgorithm, PacketType, SphinxKeyRotation,
};
use nym_sphinx_types::NymPacket;
use nym_topology::{NymRouteProvider, NymTopologyError};
use rand::{CryptoRng, RngCore};
@@ -125,6 +127,9 @@ where
let delays = nym_sphinx_routing::generate_hop_delays(average_packet_delay, route.len());
let destination = full_address.as_sphinx_destination();
let rotation_id = topology.current_key_rotation();
let sphinx_key_rotation = SphinxKeyRotation::from(rotation_id);
let first_hop_address =
NymNodeRoutingAddress::try_from(route.first().unwrap().address).unwrap();
@@ -146,7 +151,12 @@ where
)?,
};
Ok(MixPacket::new(first_hop_address, packet, packet_type))
Ok(MixPacket::new(
first_hop_address,
packet,
packet_type,
sphinx_key_rotation,
))
}
/// Helper function used to determine if given message represents a loop cover message.
+1 -1
View File
@@ -11,5 +11,5 @@ repository = { workspace = true }
nym-sphinx-addressing = { path = "../addressing" }
nym-sphinx-params = { path = "../params" }
nym-sphinx-types = { path = "../types", features = ["sphinx", "outfox"] }
nym-outfox = { path = "../../../nym-outfox" }
nym-sphinx-anonymous-replies = { path = "../anonymous-replies" }
thiserror = { workspace = true }

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