Compare commits

...

14 Commits

Author SHA1 Message Date
Jack Wampler b976613254 Additional nym api default URL (#6367) 2026-01-27 09:30:13 -07:00
Simon Wicky 99e2798791 serialize gateway data (#6314) 2026-01-14 15:16:22 +00:00
Simon Wicky 2ec8b93fac [bugfix] Sqlite transaction escalation was causing errors (#6299)
* well that was an easy fix

* change fn name because somebody wasn't happy about my one line fix
2026-01-14 15:15:57 +00:00
Jack Wampler 860429a1e1 add pre-resolve stage that returns addrs if we have used static table previously (#6297) 2026-01-14 15:15:48 +00:00
Andrej Mihajlov 81206542a3 Add Copy+Clone to nym_client_core::client::topology_control::nym_api_provider::Config (#6296) 2026-01-14 15:15:40 +00:00
Jack Wampler cfe18217b6 remove jit resolve in http client & slight increase to dns timeouts (#6283) 2026-01-14 15:15:08 +00:00
Andrej Mihajlov b183b7ee13 DNS: reduce number of attempts (#6278)
* Define configure_and_build_resolver as infallible

* Use ResolverOpts to build builder

* Set retry attempts to 0
2026-01-14 15:15:01 +00:00
Jack Wampler 81dd2c88ca fix issues with using the http client using default-features=false (#6281) 2026-01-14 15:14:41 +00:00
Jack Wampler bce4f220e9 DNS resilience patch (#6267) (#6279)
* shared resolver static init, ipv4 only by default, nameserver list

* add fn to run a trial resolution with each nameserver and log results
2026-01-14 15:14:30 +00:00
Drazen Urch a142918bbd Inline closures, no randomness for http-client-macro (#6273)
* Inline closures, no randomness

* Fix cfg usage
2026-01-14 15:14:26 +00:00
Simon Wicky 3685e9687b use proper mixing delay instead of poisson delay in cover traffic (#6269) 2026-01-14 15:14:22 +00:00
Simon Wicky 1829354a8c [Stats API] Active device endpoint and exit country code (#6265)
* active_device endpoint and exit_cc in report

* bump stats API version

* stats API version in lockflie

* migration changes
2026-01-14 15:14:03 +00:00
Simon Wicky 3cfde92034 [Feature] Fallback gateway listener and remove legacy key support (#6249)
* one commit to rule them all

* remove too aggressive copy pasting

* update details when outdated

* typo and serde alias

* no hostname option and fixes

* fix wasm client?

* non fallback fixed

* improve gateway details update

* better ws addresses

* PR review fixes

* improve type safety on update_gateway_published_data

* fix client gateway storage migration
2026-01-14 15:13:01 +00:00
Nicolas Constantinides 5e67cdf56b Initial changes to support extra configurable parameters and to print… #6237
Improved code quality

Improve logic for mixnet traffic parameters

apply formatting (cargo fmt)

Refactor: improve code readability

Modified MixnetClientConfig and some associated functions to support some new parameters
2026-01-14 15:11:28 +00:00
98 changed files with 1998 additions and 2420 deletions
Generated
+3 -2
View File
@@ -5257,8 +5257,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"cosmrs",
"nym-crypto",
"nym-gateway-client",
"nym-gateway-requests",
"serde",
"sqlx",
@@ -6825,6 +6825,7 @@ dependencies = [
"nym-crypto",
"nym-ip-packet-requests",
"nym-sphinx",
"serde",
"tokio-util",
]
@@ -7206,7 +7207,7 @@ dependencies = [
[[package]]
name = "nym-statistics-api"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -265,7 +265,7 @@ generic-array = "0.14.7"
getrandom = "0.2.10"
handlebars = "3.5.5"
hex = "0.4.3"
hickory-resolver = "0.25"
hickory-resolver = "0.25.2"
hkdf = "0.12.3"
hmac = "0.12.1"
http = "1"
@@ -6,14 +6,14 @@
{
"name": "exists",
"ordinal": 0,
"type_info": "Int"
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
null
false
]
},
"hash": "06e743d143fcc4be20ca2af5e99b19f15d22fff72490473587a14cdc046fda32"
@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM remote_gateway_details WHERE gateway_id_bs58 = ?",
"describe": {
"columns": [
{
"name": "gateway_id_bs58",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "gateway_owner_address",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "gateway_listener",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "derived_aes128_ctr_blake3_hmac_keys_bs58",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "derived_aes256_gcm_siv_key",
"ordinal": 4,
"type_info": "Blob"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
true
]
},
"hash": "0e85ec18da67cf4e3df04ad80136571f6e920eb2290f20b1b8c5b0ab4b489985"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE remote_gateway_details\n SET\n derived_aes128_ctr_blake3_hmac_keys_bs58 = ?,\n derived_aes256_gcm_siv_key = ?\n WHERE gateway_id_bs58 = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "0f1dfb89f1eb39f4a58787af0f53a7a93afb7e4d2e54e2d38fd79d31c8575a54"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO custom_gateway_details(gateway_id_bs58, data) \n VALUES (?, ?)\n ",
"query": "\n INSERT INTO custom_gateway_details(gateway_id_bs58, data)\n VALUES (?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "b059bc3688b6b7f83f47048db9897720fd4e6f3211bf74030a9638f7bf6738e4"
"hash": "2c113b37864f9fec7e64c0f8fdd38edcdf149acfd38c56a4db3bbf97bdb13210"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT\n rgd.gateway_id_bs58,\n derived_aes256_gcm_siv_key,\n gateway_listener,\n fallback_listener\n FROM\n remote_gateway_details AS rgd\n INNER JOIN\n remote_gateway_shared_keys AS rgsk\n ON\n rgd.gateway_id_bs58 = rgsk.gateway_id_bs58\n WHERE\n rgd.gateway_id_bs58 = ?",
"describe": {
"columns": [
{
"name": "gateway_id_bs58",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "derived_aes256_gcm_siv_key",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "gateway_listener",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "fallback_listener",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "4b739e12ea8d917cb17580337caeabb05f0e3ddbec04fdfa111d0fc86ba75505"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO remote_gateway_shared_keys(gateway_id_bs58, derived_aes256_gcm_siv_key)\n VALUES (?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "700a75acbcd90c74baa7823c40739a8ff8a26400c1d2bd45a689970bf1ba0e66"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO registered_gateway(gateway_id_bs58, registration_timestamp, gateway_type) \n VALUES (?, ?, ?)\n ",
"query": "\n INSERT INTO registered_gateway(gateway_id_bs58, registration_timestamp, gateway_type)\n VALUES (?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "8909fd329e7e5fb16c4989b15b3d3a12bba1569520e01f6f074178e23d6ee89e"
"hash": "727598e516090da6d26e36d09062b60ccb76d6468f359891428c0bfb96ddd7ef"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO remote_gateway_details(gateway_id_bs58, gateway_listener, fallback_listener)\n VALUES (?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "a64a557ba87d4b2c7457857afa7ebc7d4f895fc4991da18ec02c9e250bea0fe0"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO remote_gateway_details(gateway_id_bs58, derived_aes128_ctr_blake3_hmac_keys_bs58, derived_aes256_gcm_siv_key, gateway_owner_address, gateway_listener)\n VALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "a6939bea03b10cde810a9a099bd597b4f51092e30a41c4085a8f8668f039f7c0"
}
@@ -9,7 +9,6 @@ rust-version.workspace = true
[dependencies]
async-trait.workspace = true
cosmrs.workspace = true
serde = { workspace = true, features = ["derive"] }
thiserror.workspace = true
time.workspace = true
@@ -20,6 +19,7 @@ zeroize = { workspace = true, features = ["zeroize_derive"] }
nym-crypto = { path = "../../crypto", features = ["asymmetric"] }
nym-gateway-requests = { path = "../../gateway-requests" }
nym-gateway-client = { path = "../../client-libs/gateway-client" }
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.sqlx]
workspace = true
@@ -0,0 +1,22 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: Apache-2.0
*/
CREATE TABLE remote_gateway_details_temp
(
gateway_id_bs58 TEXT NOT NULL UNIQUE PRIMARY KEY REFERENCES registered_gateway (gateway_id_bs58),
derived_aes256_gcm_siv_key BLOB NOT NULL,
gateway_listener TEXT NOT NULL,
fallback_listener TEXT,
expiration_timestamp DATETIME NOT NULL
);
-- keep only registrations with a non null aes256 key
INSERT INTO remote_gateway_details_temp SELECT gateway_id_bs58, derived_aes256_gcm_siv_key, gateway_listener, NULL, datetime(0, 'unixepoch') FROM remote_gateway_details WHERE derived_aes256_gcm_siv_key IS NOT NULL;
DROP TABLE remote_gateway_details;
ALTER TABLE remote_gateway_details_temp RENAME TO remote_gateway_details;
-- delete registrations with no key
DELETE FROM registered_gateway WHERE gateway_id_bs58 NOT IN ( SELECT gateway_id_bs58 FROM remote_gateway_details);
@@ -6,6 +6,7 @@ use crate::{
types::{
RawActiveGateway, RawCustomGatewayDetails, RawRegisteredGateway, RawRemoteGatewayDetails,
},
RawGatewayPublishedData,
};
use sqlx::{
sqlite::{SqliteAutoVacuum, SqliteSynchronous},
@@ -144,13 +145,11 @@ impl StorageManager {
&self,
gateway_id: &str,
) -> Result<RawRemoteGatewayDetails, sqlx::Error> {
sqlx::query_as!(
RawRemoteGatewayDetails,
"SELECT * FROM remote_gateway_details WHERE gateway_id_bs58 = ?",
gateway_id
)
.fetch_one(&self.connection_pool)
.await
// query_as! macro doesn't use fromRow
sqlx::query_as("SELECT * FROM remote_gateway_details WHERE gateway_id_bs58 = ?")
.bind(gateway_id)
.fetch_one(&self.connection_pool)
.await
}
pub(crate) async fn set_remote_gateway_details(
@@ -159,41 +158,36 @@ impl StorageManager {
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO remote_gateway_details(gateway_id_bs58, derived_aes128_ctr_blake3_hmac_keys_bs58, derived_aes256_gcm_siv_key, gateway_owner_address, gateway_listener)
INSERT INTO remote_gateway_details(gateway_id_bs58, derived_aes256_gcm_siv_key, gateway_listener, fallback_listener, expiration_timestamp)
VALUES (?, ?, ?, ?, ?)
"#,
remote.gateway_id_bs58,
remote.derived_aes128_ctr_blake3_hmac_keys_bs58,
remote.derived_aes256_gcm_siv_key,
remote.gateway_owner_address,
remote.gateway_listener,
remote.published_data.gateway_listener,
remote.published_data.fallback_listener,
remote.published_data.expiration_timestamp
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn update_remote_gateway_key(
pub(crate) async fn update_remote_gateway_published_data(
&self,
gateway_id_bs58: &str,
derived_aes128_ctr_blake3_hmac_keys_bs58: Option<&str>,
derived_aes256_gcm_siv_key: Option<&[u8]>,
published_data: &RawGatewayPublishedData,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE remote_gateway_details
SET
derived_aes128_ctr_blake3_hmac_keys_bs58 = ?,
derived_aes256_gcm_siv_key = ?
WHERE gateway_id_bs58 = ?
UPDATE remote_gateway_details SET gateway_listener = ?, fallback_listener = ?, expiration_timestamp = ? WHERE gateway_id_bs58 = ?
"#,
derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key,
published_data.gateway_listener,
published_data.fallback_listener,
published_data.expiration_timestamp,
gateway_id_bs58
)
.execute(&self.connection_pool)
.await?;
.execute(&self.connection_pool)
.await?;
Ok(())
}
@@ -2,18 +2,16 @@
// SPDX-License-Identifier: Apache-2.0
use crate::{
ActiveGateway, BadGateway, GatewayDetails, GatewayRegistration, GatewayType,
GatewaysDetailsStore, StorageError,
ActiveGateway, BadGateway, GatewayDetails, GatewayPublishedData, GatewayRegistration,
GatewayType, GatewaysDetailsStore, StorageError,
};
use async_trait::async_trait;
use manager::StorageManager;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::SharedSymmetricKey;
use std::path::Path;
pub mod error;
mod manager;
mod models;
#[derive(Clone)]
pub struct OnDiskGatewaysDetails {
@@ -134,16 +132,15 @@ impl GatewaysDetailsStore for OnDiskGatewaysDetails {
Ok(())
}
async fn upgrade_stored_remote_gateway_key(
async fn update_gateway_published_data(
&self,
gateway_id: ed25519::PublicKey,
updated_key: &SharedSymmetricKey,
gateway_id: &ed25519::PublicKey,
published_data: &GatewayPublishedData,
) -> Result<(), Self::StorageError> {
self.manager
.update_remote_gateway_key(
.update_remote_gateway_published_data(
&gateway_id.to_base58_string(),
None,
Some(updated_key.as_bytes()),
&published_data.into(),
)
.await?;
Ok(())
@@ -1,2 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
@@ -2,10 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
use crate::types::{ActiveGateway, GatewayRegistration};
use crate::{BadGateway, GatewayDetails, GatewaysDetailsStore};
use crate::{BadGateway, GatewayDetails, GatewayPublishedData, GatewaysDetailsStore};
use async_trait::async_trait;
use nym_crypto::asymmetric::ed25519::PublicKey;
use nym_gateway_requests::{SharedGatewayKey, SharedSymmetricKey};
use nym_crypto::asymmetric::ed25519;
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;
@@ -96,26 +95,17 @@ impl GatewaysDetailsStore for InMemGatewaysDetails {
Ok(())
}
async fn upgrade_stored_remote_gateway_key(
async fn update_gateway_published_data(
&self,
gateway_id: PublicKey,
updated_key: &SharedSymmetricKey,
gateway_id: &ed25519::PublicKey,
published_data: &GatewayPublishedData,
) -> Result<(), Self::StorageError> {
let mut guard = self.inner.write().await;
#[allow(clippy::unwrap_used)]
if let Some(target) = guard.gateways.get_mut(&gateway_id.to_string()) {
let GatewayDetails::Remote(details) = &mut target.details else {
return Ok(());
};
assert_eq!(Arc::strong_count(&details.shared_key), 1);
// eh. that's nasty, but it's only ever used for ephemeral clients so should be fine for now...
details.shared_key = Arc::new(SharedGatewayKey::Current(
SharedSymmetricKey::try_from_bytes(updated_key.as_bytes()).unwrap(),
))
if let Some(gateway) = guard.gateways.get_mut(&gateway_id.to_base58_string()) {
if let GatewayDetails::Remote(ref mut remote_details) = gateway.details {
remote_details.published_data = published_data.clone();
}
}
Ok(())
}
@@ -18,16 +18,6 @@ pub enum BadGateway {
source: Ed25519RecoveryError,
},
#[error("the account owner of gateway {gateway_id} ({raw_owner}) is malformed: {source}")]
MalformedGatewayOwnerAccountAddress {
gateway_id: String,
raw_owner: String,
#[source]
source: cosmrs::ErrorReport,
},
#[error("the shared keys provided for gateway {gateway_id} are malformed: {source}")]
MalformedSharedKeys {
gateway_id: String,
@@ -50,4 +40,12 @@ pub enum BadGateway {
#[source]
source: url::ParseError,
},
#[error("the listening address ({raw_listener}) is malformed: {source}")]
MalformedListenerNoId {
raw_listener: String,
#[source]
source: url::ParseError,
},
}
@@ -6,7 +6,6 @@
use async_trait::async_trait;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::SharedSymmetricKey;
use std::error::Error;
pub mod backend;
@@ -60,10 +59,11 @@ pub trait GatewaysDetailsStore {
details: &GatewayRegistration,
) -> Result<(), Self::StorageError>;
async fn upgrade_stored_remote_gateway_key(
/// Update the gateway details
async fn update_gateway_published_data(
&self,
gateway_id: ed25519::PublicKey,
updated_key: &SharedSymmetricKey,
gateway_id: &ed25519::PublicKey,
published_data: &GatewayPublishedData,
) -> Result<(), Self::StorageError>;
/// Remove given gateway details from the underlying store.
@@ -2,20 +2,21 @@
// SPDX-License-Identifier: Apache-2.0
use crate::BadGateway;
use cosmrs::AccountId;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::shared_key::{LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey};
use nym_gateway_client::client::GatewayListeners;
use nym_gateway_requests::shared_key::SharedSymmetricKey;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
use time::Duration;
use time::OffsetDateTime;
use url::Url;
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const REMOTE_GATEWAY_TYPE: &str = "remote";
pub const CUSTOM_GATEWAY_TYPE: &str = "custom";
const GATEWAY_DETAILS_TTL: Duration = Duration::days(7);
#[derive(Debug, Clone, Default)]
pub struct ActiveGateway {
@@ -65,15 +66,13 @@ impl From<GatewayDetails> for GatewayRegistration {
impl GatewayDetails {
pub fn new_remote(
gateway_id: ed25519::PublicKey,
shared_key: Arc<SharedGatewayKey>,
gateway_owner_address: Option<AccountId>,
gateway_listener: Url,
shared_key: Arc<SharedSymmetricKey>,
published_data: GatewayPublishedData,
) -> Self {
GatewayDetails::Remote(RemoteGatewayDetails {
gateway_id,
shared_key,
gateway_owner_address,
gateway_listener,
published_data,
})
}
@@ -88,13 +87,20 @@ impl GatewayDetails {
}
}
pub fn shared_key(&self) -> Option<&SharedGatewayKey> {
pub fn shared_key(&self) -> Option<&SharedSymmetricKey> {
match self {
GatewayDetails::Remote(details) => Some(&details.shared_key),
GatewayDetails::Custom(_) => None,
}
}
pub fn details_exipration(&self) -> Option<OffsetDateTime> {
match self {
GatewayDetails::Remote(details) => Some(details.published_data.expiration_timestamp),
GatewayDetails::Custom(_) => None,
}
}
pub fn is_custom(&self) -> bool {
matches!(self, GatewayDetails::Custom(..))
}
@@ -164,14 +170,78 @@ pub struct RegisteredGateway {
pub gateway_type: GatewayType,
}
#[derive(Debug, Clone)]
pub struct GatewayPublishedData {
pub listeners: GatewayListeners,
pub expiration_timestamp: OffsetDateTime,
}
impl GatewayPublishedData {
pub fn new(listeners: GatewayListeners) -> GatewayPublishedData {
GatewayPublishedData {
listeners,
expiration_timestamp: OffsetDateTime::now_utc() + GATEWAY_DETAILS_TTL,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
pub struct RawGatewayPublishedData {
pub gateway_listener: String,
pub fallback_listener: Option<String>,
pub expiration_timestamp: OffsetDateTime,
}
impl<'a> From<&'a GatewayPublishedData> for RawGatewayPublishedData {
fn from(value: &'a GatewayPublishedData) -> Self {
Self {
gateway_listener: value.listeners.primary.to_string(),
fallback_listener: value.listeners.fallback.as_ref().map(|uri| uri.to_string()),
expiration_timestamp: value.expiration_timestamp,
}
}
}
impl TryFrom<RawGatewayPublishedData> for GatewayPublishedData {
type Error = BadGateway;
fn try_from(value: RawGatewayPublishedData) -> Result<Self, Self::Error> {
let gateway_listener: Url = Url::parse(&value.gateway_listener).map_err(|source| {
BadGateway::MalformedListenerNoId {
raw_listener: value.gateway_listener.clone(),
source,
}
})?;
let fallback_listener = value
.fallback_listener
.as_ref()
.map(|uri| {
Url::parse(uri).map_err(|source| BadGateway::MalformedListenerNoId {
raw_listener: uri.to_owned(),
source,
})
})
.transpose()?;
Ok(GatewayPublishedData {
listeners: GatewayListeners {
primary: gateway_listener,
fallback: fallback_listener,
},
expiration_timestamp: value.expiration_timestamp,
})
}
}
#[derive(Debug, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
pub struct RawRemoteGatewayDetails {
pub gateway_id_bs58: String,
pub derived_aes128_ctr_blake3_hmac_keys_bs58: Option<String>,
pub derived_aes256_gcm_siv_key: Option<Vec<u8>>,
pub gateway_owner_address: Option<String>,
pub gateway_listener: String,
pub derived_aes256_gcm_siv_key: Vec<u8>,
#[zeroize(skip)]
#[cfg_attr(feature = "sqlx", sqlx(flatten))]
pub published_data: RawGatewayPublishedData,
}
impl TryFrom<RawRemoteGatewayDetails> for RemoteGatewayDetails {
@@ -186,81 +256,26 @@ impl TryFrom<RawRemoteGatewayDetails> for RemoteGatewayDetails {
}
})?;
let shared_key =
match (
&value.derived_aes256_gcm_siv_key,
&value.derived_aes128_ctr_blake3_hmac_keys_bs58,
) {
(None, None) => {
return Err(BadGateway::MissingSharedKey {
gateway_id: value.gateway_id_bs58.clone(),
})
}
(Some(aes256gcm_siv), _) => {
let current_key =
SharedSymmetricKey::try_from_bytes(aes256gcm_siv).map_err(|source| {
BadGateway::MalformedSharedKeys {
gateway_id: value.gateway_id_bs58.clone(),
source,
}
})?;
SharedGatewayKey::Current(current_key)
}
(None, Some(aes128ctr_hmac)) => {
let legacy_key = LegacySharedKeys::try_from_base58_string(aes128ctr_hmac)
.map_err(|source| BadGateway::MalformedSharedKeys {
gateway_id: value.gateway_id_bs58.clone(),
source,
})?;
SharedGatewayKey::Legacy(legacy_key)
}
};
let gateway_owner_address = value
.gateway_owner_address
.as_ref()
.map(|raw_owner| {
AccountId::from_str(raw_owner).map_err(|source| {
BadGateway::MalformedGatewayOwnerAccountAddress {
gateway_id: value.gateway_id_bs58.clone(),
raw_owner: raw_owner.clone(),
source,
}
})
})
.transpose()?;
let gateway_listener = Url::parse(&value.gateway_listener).map_err(|source| {
BadGateway::MalformedListener {
let shared_key = SharedSymmetricKey::try_from_bytes(&value.derived_aes256_gcm_siv_key)
.map_err(|source| BadGateway::MalformedSharedKeys {
gateway_id: value.gateway_id_bs58.clone(),
raw_listener: value.gateway_listener.clone(),
source,
}
})?;
})?;
Ok(RemoteGatewayDetails {
gateway_id,
shared_key: Arc::new(shared_key),
gateway_owner_address,
gateway_listener,
published_data: value.published_data.clone().try_into()?,
})
}
}
impl<'a> From<&'a RemoteGatewayDetails> for RawRemoteGatewayDetails {
fn from(value: &'a RemoteGatewayDetails) -> Self {
let (derived_aes128_ctr_blake3_hmac_keys_bs58, derived_aes256_gcm_siv_key) =
match value.shared_key.deref() {
SharedGatewayKey::Current(key) => (None, Some(key.to_bytes())),
SharedGatewayKey::Legacy(key) => (Some(key.to_base58_string()), None),
};
RawRemoteGatewayDetails {
gateway_id_bs58: value.gateway_id.to_base58_string(),
derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key,
gateway_owner_address: value.gateway_owner_address.as_ref().map(|o| o.to_string()),
gateway_listener: value.gateway_listener.to_string(),
derived_aes256_gcm_siv_key: value.shared_key.to_bytes(),
published_data: (&value.published_data).into(),
}
}
}
@@ -269,11 +284,9 @@ impl<'a> From<&'a RemoteGatewayDetails> for RawRemoteGatewayDetails {
pub struct RemoteGatewayDetails {
pub gateway_id: ed25519::PublicKey,
pub shared_key: Arc<SharedGatewayKey>,
pub shared_key: Arc<SharedSymmetricKey>,
pub gateway_owner_address: Option<AccountId>,
pub gateway_listener: Url,
pub published_data: GatewayPublishedData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -87,6 +87,7 @@ where
user_chosen_gateway_id.map(|id| id.to_base58_string()),
Some(common_args.latency_based_selection),
common_args.force_tls_gateway,
false,
);
tracing::debug!("Gateway selection specification: {selection_spec:?}");
@@ -167,6 +168,7 @@ where
identity: gateway_details.gateway_id,
active: common_args.set_active,
typ: gateway_registration.details.typ().to_string(),
endpoint: Some(gateway_details.gateway_listener.clone()),
endpoint: Some(gateway_details.published_data.listeners.primary.clone()),
fallback_endpoint: gateway_details.published_data.listeners.fallback.clone(),
})
}
@@ -140,6 +140,7 @@ where
user_chosen_gateway_id.map(|id| id.to_base58_string()),
Some(common_args.latency_based_selection),
common_args.force_tls_gateway,
false,
);
tracing::debug!("Gateway selection specification: {selection_spec:?}");
@@ -56,7 +56,8 @@ where
identity: remote_details.gateway_id,
active: active_gateway == Some(remote_details.gateway_id),
typ: GatewayType::Remote.to_string(),
endpoint: Some(remote_details.gateway_listener),
endpoint: Some(remote_details.published_data.listeners.primary.clone()),
fallback_endpoint: remote_details.published_data.listeners.fallback.clone(),
}),
GatewayDetails::Custom(_) => info.push(GatewayInfo {
registration: gateway.registration_timestamp,
@@ -64,6 +65,7 @@ where
active: active_gateway == Some(gateway.details.gateway_id()),
typ: gateway.details.typ().to_string(),
endpoint: None,
fallback_endpoint: None,
}),
};
}
@@ -15,6 +15,7 @@ pub struct GatewayInfo {
pub typ: String,
pub endpoint: Option<Url>,
pub fallback_endpoint: Option<Url>,
}
impl Display for GatewayInfo {
@@ -30,6 +31,9 @@ impl Display for GatewayInfo {
if let Some(endpoint) = &self.endpoint {
write!(f, " endpoint: {endpoint}")?;
}
if let Some(fallback_endpoint) = &self.fallback_endpoint {
write!(f, " fallback: {fallback_endpoint}")?;
}
Ok(())
}
}
@@ -529,7 +529,6 @@ where
config: &Config,
initialisation_result: InitialisationResult,
bandwidth_controller: Option<BandwidthController<C, S::CredentialStore>>,
details_store: &S::GatewaysDetailsStore,
packet_router: PacketRouter,
stats_reporter: ClientStatsSender,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
@@ -555,14 +554,7 @@ where
shutdown_tracker.clone_shutdown_token(),
)
} else {
let cfg = GatewayConfig::new(
details.gateway_id,
details
.gateway_owner_address
.as_ref()
.map(|o| o.to_string()),
details.gateway_listener.to_string(),
);
let cfg = GatewayConfig::new(details.gateway_id, details.published_data.listeners);
GatewayClient::new(
GatewayClientConfig::new_default()
.with_disabled_credentials_mode(config.client.disabled_credentials_mode)
@@ -592,32 +584,13 @@ where
// the gateway client startup procedure is slightly more complicated now
// we need to:
// - perform handshake (reg or auth)
// - check for key upgrade
// - maybe perform another upgrade handshake
// - check for bandwidth
// - start background tasks
let auth_res = gateway_client
let _ = gateway_client
.perform_initial_authentication()
.await
.map_err(gateway_failure)?;
if auth_res.requires_key_upgrade {
// drop the shared_key arc because we don't need it and we can't hold it for the purposes of upgrade
drop(auth_res);
let updated_key = gateway_client
.upgrade_key_authenticated()
.await
.map_err(gateway_failure)?;
details_store
.upgrade_stored_remote_gateway_key(gateway_client.gateway_identity(), &updated_key)
.await.map_err(|err| {
tracing::error!("failed to store upgraded gateway key! this connection might be forever broken now: {err}");
ClientCoreError::GatewaysDetailsStoreError { source: Box::new(err) }
})?
}
gateway_client
.claim_initial_bandwidth()
.await
@@ -636,7 +609,6 @@ where
config: &Config,
initialisation_result: InitialisationResult,
bandwidth_controller: Option<BandwidthController<C, S::CredentialStore>>,
details_store: &S::GatewaysDetailsStore,
packet_router: PacketRouter,
stats_reporter: ClientStatsSender,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
@@ -667,7 +639,6 @@ where
config,
initialisation_result,
bandwidth_controller,
details_store,
packet_router,
stats_reporter,
#[cfg(unix)]
@@ -975,8 +946,7 @@ where
)
.await?;
let (reply_storage_backend, credential_store, details_store) =
self.client_store.into_runtime_stores();
let (reply_storage_backend, credential_store, _) = self.client_store.into_runtime_stores();
// channels for inter-component communication
// TODO: make the channels be internally created by the relevant components
@@ -1069,7 +1039,6 @@ where
&self.config,
init_res,
bandwidth_controller,
&details_store,
gateway_packet_router,
stats_reporter.clone(),
#[cfg(unix)]
@@ -4,7 +4,9 @@
use crate::client::key_manager::persistence::KeyStore;
use crate::client::key_manager::ClientKeys;
use crate::error::ClientCoreError;
use nym_client_core_gateways_storage::{ActiveGateway, GatewayRegistration, GatewaysDetailsStore};
use nym_client_core_gateways_storage::{
ActiveGateway, GatewayPublishedData, GatewayRegistration, GatewaysDetailsStore,
};
use nym_crypto::asymmetric::ed25519;
// helpers for error wrapping
@@ -85,6 +87,23 @@ where
})
}
pub async fn update_stored_published_data_gateway<D>(
details_store: &D,
gateway_id: &ed25519::PublicKey,
published_data: &GatewayPublishedData,
) -> Result<(), ClientCoreError>
where
D: GatewaysDetailsStore,
D::StorageError: Send + Sync + 'static,
{
details_store
.update_gateway_published_data(gateway_id, published_data)
.await
.map_err(|source| ClientCoreError::GatewaysDetailsStoreError {
source: Box::new(source),
})
}
pub async fn load_active_gateway_details<D>(
details_store: &D,
) -> Result<ActiveGateway, ClientCoreError>
@@ -2,210 +2,18 @@
// SPDX-License-Identifier: Apache-2.0
pub mod v1_1_33 {
use crate::client::base_client::{
non_wasm_helpers::setup_fs_gateways_storage,
storage::helpers::{set_active_gateway, store_gateway_details},
};
use crate::config::disk_persistence::old_v1_1_33::CommonClientPathsV1_1_33;
use crate::config::disk_persistence::CommonClientPaths;
use crate::config::old_config_v1_1_33::OldGatewayEndpointConfigV1_1_33;
use crate::error::ClientCoreError;
use nym_client_core_gateways_storage::{
CustomGatewayDetails, GatewayDetails, GatewayRegistration, RemoteGatewayDetails,
};
use nym_gateway_requests::shared_key::LegacySharedKeys;
use serde::{Deserialize, Serialize};
use sha2::{digest::Digest, Sha256};
use std::ops::Deref;
use std::path::Path;
use std::sync::Arc;
use zeroize::Zeroizing;
mod base64 {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<u8>, D::Error> {
let s = <String>::deserialize(deserializer)?;
STANDARD.decode(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum PersistedGatewayDetails {
/// Standard details of a remote gateway
Default(PersistedGatewayConfig),
/// Custom gateway setup, such as for a client embedded inside gateway itself
Custom(PersistedCustomGatewayDetails),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct PersistedGatewayConfig {
/// The hash of the shared keys to ensure the correct ones are used with those gateway details.
#[serde(with = "base64")]
key_hash: Vec<u8>,
/// Actual gateway details being persisted.
details: OldGatewayEndpointConfigV1_1_33,
}
impl PersistedGatewayConfig {
fn verify(&self, shared_key: &LegacySharedKeys) -> bool {
let key_bytes = Zeroizing::new(shared_key.to_bytes());
let mut key_hasher = Sha256::new();
key_hasher.update(&key_bytes);
let key_hash = key_hasher.finalize();
self.key_hash == key_hash.deref()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedCustomGatewayDetails {
gateway_id: String,
}
fn load_shared_key<P: AsRef<Path>>(path: P) -> Result<LegacySharedKeys, ClientCoreError> {
// the shared key was a simple pem file
Ok(nym_pemstore::load_key(path)?)
}
fn gateway_details_from_raw(
gateway_id: String,
gateway_owner: String,
gateway_listener: String,
gateway_shared_key: LegacySharedKeys,
) -> Result<GatewayDetails, ClientCoreError> {
Ok(GatewayDetails::Remote(RemoteGatewayDetails {
gateway_id: gateway_id
.parse()
.map_err(|err| ClientCoreError::UpgradeFailure {
message: format!("the stored gateway id was malformed: {err}"),
})?,
shared_key: Arc::new(gateway_shared_key.into()),
gateway_owner_address: Some(gateway_owner.parse().map_err(|err| {
ClientCoreError::UpgradeFailure {
message: format!("the stored gateway owner address was malformed: {err}"),
}
})?),
gateway_listener: gateway_listener.parse().map_err(|err| {
ClientCoreError::UpgradeFailure {
message: format!("the stored gateway listener address was malformed: {err}"),
}
})?,
}))
}
// helper to extract shared key and gateway details into the new GatewayRegistration
fn extract_gateway_registration(
storage_paths: &CommonClientPathsV1_1_33,
) -> Result<GatewayRegistration, ClientCoreError> {
let details_file = std::fs::File::open(&storage_paths.gateway_details).map_err(|err| {
ClientCoreError::UpgradeFailure {
message: format!(
"failed to open gateway details file at {}: {err}",
storage_paths.gateway_details.display()
),
}
})?;
// in v1.1.33 of the clients, the gateway details struct was saved as json
let details: PersistedGatewayDetails =
serde_json::from_reader(details_file).map_err(|err| {
ClientCoreError::UpgradeFailure {
message: format!(
"failed to deserialize gateway details from {}: {err}",
storage_paths.gateway_details.display()
),
}
})?;
let details = match details {
PersistedGatewayDetails::Default(config) => {
let gateway_shared_key =
load_shared_key(&storage_paths.keys.gateway_shared_key_file)?;
if !config.verify(&gateway_shared_key) {
return Err(ClientCoreError::UpgradeFailure {
message: "failed to verify consistency of the existing gateway details"
.to_string(),
});
}
gateway_details_from_raw(
config.details.gateway_id,
config.details.gateway_owner,
config.details.gateway_listener,
gateway_shared_key,
)?
}
PersistedGatewayDetails::Custom(custom) => {
GatewayDetails::Custom(CustomGatewayDetails {
gateway_id: custom.gateway_id.parse().map_err(|err| {
ClientCoreError::UpgradeFailure {
message: format!("the stored gateway id was malformed: {err}"),
}
})?,
data: None,
})
}
};
Ok(details.into())
}
// it's responsibility of the caller to ensure this is called **after** new registration has already been saved
fn remove_old_gateway_details(storage_paths: &CommonClientPathsV1_1_33) -> std::io::Result<()> {
std::fs::remove_file(&storage_paths.gateway_details)?;
if storage_paths.keys.gateway_shared_key_file.exists() {
std::fs::remove_file(&storage_paths.keys.gateway_shared_key_file)?;
}
Ok(())
}
pub async fn migrate_gateway_details(
old_storage_paths: &CommonClientPathsV1_1_33,
new_storage_paths: &CommonClientPaths,
preloaded_config: Option<OldGatewayEndpointConfigV1_1_33>,
_old_storage_paths: &CommonClientPathsV1_1_33,
_new_storage_paths: &CommonClientPaths,
_preloaded_config: Option<OldGatewayEndpointConfigV1_1_33>,
) -> Result<(), ClientCoreError> {
let gateway_registration = match preloaded_config {
Some(config) => {
let gateway_shared_key =
load_shared_key(&old_storage_paths.keys.gateway_shared_key_file)?;
gateway_details_from_raw(
config.gateway_id,
config.gateway_owner,
config.gateway_listener,
gateway_shared_key,
)?
.into()
}
None => extract_gateway_registration(old_storage_paths)?,
};
// since we're migrating to a brand new store, the store should be empty
// and thus set the 'new' gateway as the active one
let details_store =
setup_fs_gateways_storage(&new_storage_paths.gateway_registrations).await?;
store_gateway_details(&details_store, &gateway_registration).await?;
set_active_gateway(
&details_store,
&gateway_registration.details.gateway_id().to_base58_string(),
)
.await?;
remove_old_gateway_details(old_storage_paths).map_err(|err| {
ClientCoreError::UpgradeFailure {
message: format!("failed to remove old data: {err}"),
}
})
Err(ClientCoreError::UnsupportedMigration(
"migration of legacy keys has been removed and is no longer supported".into(),
))
}
}
@@ -32,9 +32,12 @@ where
/// Key used to encrypt and decrypt content of an ACK packet.
ack_key: Arc<AckKey>,
/// Average delay an acknowledgement packet is going to get delay at a single mixnode.
/// Average delay an acknowledgement packet is going to get delayed at a single mixnode.
average_ack_delay: Duration,
/// Average delay a forward packet is going to get delayed at a single mixnode.
average_packet_delay: Duration,
/// Defines configuration options related to cover traffic.
cover_traffic: config::CoverTraffic,
@@ -122,6 +125,7 @@ impl LoopCoverTrafficStream<OsRng> {
LoopCoverTrafficStream {
ack_key,
average_ack_delay,
average_packet_delay: traffic_config.average_packet_delay,
cover_traffic: cover_config,
next_delay,
mix_tx,
@@ -187,7 +191,7 @@ impl LoopCoverTrafficStream<OsRng> {
&self.ack_key,
&self.our_full_destination,
self.average_ack_delay,
self.cover_traffic.loop_cover_traffic_average_delay,
self.average_packet_delay,
cover_traffic_packet_size,
self.packet_type,
) {
@@ -6,7 +6,7 @@ use nym_crypto::{
asymmetric::{ed25519, x25519},
hkdf::{DerivationMaterial, InvalidLength},
};
use nym_gateway_requests::shared_key::{LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey};
use nym_gateway_requests::shared_key::SharedSymmetricKey;
use nym_sphinx::acknowledgements::AckKey;
use rand::{CryptoRng, RngCore};
use std::sync::Arc;
@@ -106,7 +106,5 @@ fn _assert_keys_zeroize_on_drop() {
_assert_zeroize_on_drop::<ed25519::KeyPair>();
_assert_zeroize_on_drop::<x25519::KeyPair>();
_assert_zeroize_on_drop::<AckKey>();
_assert_zeroize_on_drop::<LegacySharedKeys>();
_assert_zeroize_on_drop::<SharedSymmetricKey>();
_assert_zeroize_on_drop::<SharedGatewayKey>();
}
@@ -12,7 +12,7 @@ use std::cmp::min;
use tracing::{debug, error, warn};
use url::Url;
#[derive(Debug)]
#[derive(Debug, Copy, Clone)]
pub struct Config {
pub min_mixnode_performance: u8,
pub min_gateway_performance: u8,
+9
View File
@@ -16,6 +16,9 @@ use std::path::PathBuf;
#[derive(thiserror::Error, Debug)]
pub enum ClientCoreError {
#[error("could not perform the state migration: {0}")]
UnsupportedMigration(String),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
@@ -43,6 +46,9 @@ pub enum ClientCoreError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("node doesn't advertise ip addresses : {0}")]
MissingIpAddress(String),
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::ResolveError),
@@ -163,6 +169,9 @@ pub enum ClientCoreError {
#[error("custom selection of gateway was expected")]
CustomGatewaySelectionExpected,
#[error("custom selection of gateway was unexpected")]
UnexpectedCustomGatewaySelection,
#[error("the persisted gateway details were set for a custom setup")]
UnexpectedPersistedCustomGatewayDetails,
+3 -10
View File
@@ -5,6 +5,7 @@ use crate::error::ClientCoreError;
use crate::init::types::RegistrationResult;
use futures::{SinkExt, StreamExt};
use nym_crypto::asymmetric::ed25519;
use nym_gateway_client::client::GatewayListeners;
use nym_gateway_client::GatewayClient;
use nym_topology::node::RoutingNode;
use nym_validator_client::client::{IdentityKeyRef, NymApiClientExt};
@@ -379,12 +380,12 @@ pub(super) fn get_specified_gateway(
pub(super) async fn register_with_gateway(
gateway_id: ed25519::PublicKey,
gateway_listener: Url,
gateway_listeners: GatewayListeners,
our_identity: Arc<ed25519::KeyPair>,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
) -> Result<RegistrationResult, ClientCoreError> {
let mut gateway_client = GatewayClient::new_init(
gateway_listener,
gateway_listeners,
gateway_id,
our_identity.clone(),
#[cfg(unix)]
@@ -409,14 +410,6 @@ pub(super) async fn register_with_gateway(
}
})?;
// this should NEVER happen, if it did, it means the function was misused,
// because for any fresh **registration**, the derived key is always up to date
if auth_response.requires_key_upgrade {
return Err(ClientCoreError::UnexpectedKeyUpgrade {
gateway_id: gateway_id.to_base58_string(),
});
}
Ok(RegistrationResult {
shared_keys: auth_response.initial_shared_key,
authenticated_ephemeral_client: gateway_client,
+58 -12
View File
@@ -5,7 +5,7 @@
use crate::client::base_client::storage::helpers::{
has_gateway_details, load_active_gateway_details, load_client_keys, load_gateway_details,
store_gateway_details,
store_gateway_details, update_stored_published_data_gateway,
};
use crate::client::key_manager::persistence::KeyStore;
use crate::client::key_manager::ClientKeys;
@@ -16,8 +16,8 @@ use crate::init::helpers::{
use crate::init::types::{
GatewaySelectionSpecification, GatewaySetup, InitialisationResult, SelectedGateway,
};
use nym_client_core_gateways_storage::GatewaysDetailsStore;
use nym_client_core_gateways_storage::{GatewayDetails, GatewayRegistration};
use nym_client_core_gateways_storage::{GatewayPublishedData, GatewaysDetailsStore};
use nym_gateway_client::client::InitGatewayClient;
use nym_topology::node::RoutingNode;
use rand::rngs::OsRng;
@@ -71,21 +71,28 @@ where
let mut rng = OsRng;
let selected_gateway = match selection_specification {
GatewaySelectionSpecification::UniformRemote { must_use_tls } => {
GatewaySelectionSpecification::UniformRemote {
must_use_tls,
no_hostname,
} => {
let gateway = uniformly_random_gateway(&mut rng, &available_gateways, must_use_tls)?;
SelectedGateway::from_topology_node(gateway, must_use_tls)?
SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?
}
GatewaySelectionSpecification::RemoteByLatency { must_use_tls } => {
GatewaySelectionSpecification::RemoteByLatency {
must_use_tls,
no_hostname,
} => {
let gateway =
choose_gateway_by_latency(&mut rng, &available_gateways, must_use_tls).await?;
SelectedGateway::from_topology_node(gateway, must_use_tls)?
SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?
}
GatewaySelectionSpecification::Specified {
must_use_tls,
no_hostname,
identity,
} => {
let gateway = get_specified_gateway(&identity, &available_gateways, must_use_tls)?;
SelectedGateway::from_topology_node(gateway, must_use_tls)?
SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?
}
GatewaySelectionSpecification::Custom {
gateway_identity,
@@ -105,15 +112,15 @@ where
let (gateway_details, authenticated_ephemeral_client) = match selected_gateway {
SelectedGateway::Remote {
gateway_id,
gateway_owner_address,
gateway_listener,
gateway_listeners,
} => {
// if we're using a 'normal' gateway setup, do register
let our_identity = client_keys.identity_keypair();
let registration = helpers::register_with_gateway(
gateway_id,
gateway_listener.clone(),
gateway_listeners.clone(),
our_identity,
#[cfg(unix)]
connection_fd_callback,
@@ -123,8 +130,7 @@ where
GatewayDetails::new_remote(
gateway_id,
registration.shared_keys,
gateway_owner_address,
gateway_listener,
GatewayPublishedData::new(gateway_listeners),
),
Some(registration.authenticated_ephemeral_client),
)
@@ -150,6 +156,46 @@ where
})
}
pub async fn refresh_gateway_published_data<D>(
details_store: &D,
registration: GatewayRegistration,
available_gateways: Vec<RoutingNode>,
must_use_tls: bool,
no_hostname: bool,
) -> Result<(), ClientCoreError>
where
D: GatewaysDetailsStore,
D::StorageError: Send + Sync + 'static,
{
let gateway_id = registration.gateway_id().to_base58_string();
tracing::trace!("Updating gateway details : {gateway_id}");
let gateway = get_specified_gateway(&gateway_id, &available_gateways, must_use_tls)?;
let selected_gateway = SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?;
let new_gateway_listeners = match selected_gateway {
SelectedGateway::Remote {
gateway_listeners, ..
} => gateway_listeners,
SelectedGateway::Custom { .. } => {
// this should not happen, as `from_topology_node` returns a Remote
Err(ClientCoreError::UnexpectedCustomGatewaySelection)?
}
};
let new_published_data = GatewayPublishedData::new(new_gateway_listeners);
// update gateway details
update_stored_published_data_gateway(
details_store,
&registration.gateway_id(),
&new_published_data,
)
.await?;
Ok(())
}
async fn use_loaded_gateway_details<K, D>(
key_store: &K,
details_store: &D,
+70 -23
View File
@@ -10,12 +10,11 @@ use nym_client_core_gateways_storage::{
GatewayRegistration, GatewaysDetailsStore, RemoteGatewayDetails,
};
use nym_crypto::asymmetric::ed25519;
use nym_gateway_client::client::InitGatewayClient;
use nym_gateway_requests::shared_key::SharedGatewayKey;
use nym_gateway_client::client::{GatewayListeners, InitGatewayClient};
use nym_gateway_client::SharedSymmetricKey;
use nym_sphinx::addressing::clients::Recipient;
use nym_topology::node::RoutingNode;
use nym_validator_client::client::IdentityKey;
use nym_validator_client::nyxd::AccountId;
use serde::Serialize;
use std::fmt::{Debug, Display};
#[cfg(unix)]
@@ -28,9 +27,7 @@ pub enum SelectedGateway {
Remote {
gateway_id: ed25519::PublicKey,
gateway_owner_address: Option<AccountId>,
gateway_listener: Url,
gateway_listeners: GatewayListeners,
},
Custom {
gateway_id: ed25519::PublicKey,
@@ -42,24 +39,40 @@ impl SelectedGateway {
pub fn from_topology_node(
node: RoutingNode,
must_use_tls: bool,
no_hostname: bool,
) -> Result<Self, ClientCoreError> {
// for now, let's use 'old' behaviour, if you want to change it, you can pass it up the enum stack yourself : )
let prefer_ipv6 = false;
let gateway_listener = if must_use_tls {
node.ws_entry_address_tls()
.ok_or(ClientCoreError::UnsupportedWssProtocol {
gateway: node.identity_key.to_base58_string(),
})?
let (gateway_listener, fallback_listener) = if must_use_tls {
// WSS main, no fallback
let primary =
node.ws_entry_address_tls()
.ok_or(ClientCoreError::UnsupportedWssProtocol {
gateway: node.identity_key.to_base58_string(),
})?;
(primary, None)
} else {
node.ws_entry_address(prefer_ipv6)
.ok_or(ClientCoreError::UnsupportedEntry {
let (maybe_primary, fallback) =
node.ws_entry_address_with_fallback(prefer_ipv6, no_hostname);
(
maybe_primary.ok_or(ClientCoreError::UnsupportedEntry {
id: node.node_id,
identity: node.identity_key.to_base58_string(),
})?
})?,
fallback,
)
};
let gateway_listener =
let fallback_listener_url = fallback_listener.and_then(|address| {
Url::parse(&address)
.inspect_err(|err| {
tracing::warn!("Malformed fallback listener, none will be used : {err}")
})
.ok()
});
let gateway_listener_url =
Url::parse(&gateway_listener).map_err(|source| ClientCoreError::MalformedListener {
gateway_id: node.identity_key.to_base58_string(),
raw_listener: gateway_listener,
@@ -68,8 +81,10 @@ impl SelectedGateway {
Ok(SelectedGateway::Remote {
gateway_id: node.identity_key,
gateway_owner_address: None,
gateway_listener,
gateway_listeners: GatewayListeners {
primary: gateway_listener_url,
fallback: fallback_listener_url,
},
})
}
@@ -98,7 +113,7 @@ impl SelectedGateway {
/// - shared keys derived between ourselves and the node
/// - an authenticated handle of an ephemeral handle created for the purposes of registration
pub struct RegistrationResult {
pub shared_keys: Arc<SharedGatewayKey>,
pub shared_keys: Arc<SharedSymmetricKey>,
pub authenticated_ephemeral_client: InitGatewayClient,
}
@@ -145,20 +160,36 @@ impl InitialisationResult {
pub fn gateway_id(&self) -> ed25519::PublicKey {
self.gateway_registration.details.gateway_id()
}
// indicates if the remote gateway details TTL has expired
pub fn exipred_details(&self) -> bool {
if let Some(expiration_timestamp) = self.gateway_registration.details.details_exipration() {
OffsetDateTime::now_utc() > expiration_timestamp
} else {
false
}
}
}
#[derive(Clone, Debug)]
pub enum GatewaySelectionSpecification {
/// Uniformly choose a random remote gateway.
UniformRemote { must_use_tls: bool },
UniformRemote {
must_use_tls: bool,
no_hostname: bool,
},
/// Should the new, remote, gateway be selected based on latency.
RemoteByLatency { must_use_tls: bool },
RemoteByLatency {
must_use_tls: bool,
no_hostname: bool,
},
/// Gateway with this specific identity should be chosen.
// JS: I don't really like the name of this enum variant but couldn't think of anything better at the time
Specified {
must_use_tls: bool,
no_hostname: bool,
identity: IdentityKey,
},
@@ -174,6 +205,7 @@ impl Default for GatewaySelectionSpecification {
fn default() -> Self {
GatewaySelectionSpecification::UniformRemote {
must_use_tls: false,
no_hostname: false,
}
}
}
@@ -183,16 +215,24 @@ impl GatewaySelectionSpecification {
gateway_identity: Option<String>,
latency_based_selection: Option<bool>,
must_use_tls: bool,
no_hostname: bool,
) -> Self {
if let Some(identity) = gateway_identity {
GatewaySelectionSpecification::Specified {
identity,
must_use_tls,
no_hostname,
}
} else if let Some(true) = latency_based_selection {
GatewaySelectionSpecification::RemoteByLatency { must_use_tls }
GatewaySelectionSpecification::RemoteByLatency {
must_use_tls,
no_hostname,
}
} else {
GatewaySelectionSpecification::UniformRemote { must_use_tls }
GatewaySelectionSpecification::UniformRemote {
must_use_tls,
no_hostname,
}
}
}
}
@@ -315,6 +355,7 @@ pub struct InitResults {
pub encryption_key: String,
pub gateway_id: String,
pub gateway_listener: String,
pub fallback_listener: Option<String>,
pub gateway_registration: OffsetDateTime,
pub address: Recipient,
}
@@ -332,7 +373,13 @@ impl InitResults {
identity_key: address.identity().to_base58_string(),
encryption_key: address.encryption_key().to_base58_string(),
gateway_id: gateway.gateway_id.to_base58_string(),
gateway_listener: gateway.gateway_listener.to_string(),
gateway_listener: gateway.published_data.listeners.primary.to_string(),
fallback_listener: gateway
.published_data
.listeners
.fallback
.as_ref()
.map(|uri| uri.to_string()),
gateway_registration: registration,
address,
}
@@ -21,8 +21,8 @@ use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::registration::handshake::client_handshake;
use nym_gateway_requests::{
BandwidthResponse, BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersion,
GatewayProtocolVersionExt, GatewayRequestsError, SensitiveServerResponse, ServerResponse,
SharedGatewayKey, SharedSymmetricKey, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION,
GatewayProtocolVersionExt, GatewayRequestsError, ServerResponse, SharedSymmetricKey,
CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION,
};
use nym_sphinx::forwarding::packet::MixPacket;
use nym_statistics_common::clients::connection::ConnectionStatsEvent;
@@ -47,43 +47,39 @@ use std::os::raw::c_int as RawFd;
use wasm_utils::websocket::JSWebsocket;
#[cfg(target_arch = "wasm32")]
use wasmtimer::tokio::sleep;
use zeroize::Zeroizing;
pub mod config;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod websockets;
#[cfg(not(target_arch = "wasm32"))]
use websockets::connect_async;
use crate::client::websockets::connect_async_with_fallback;
pub struct GatewayConfig {
pub gateway_identity: ed25519::PublicKey,
// currently a dead field
pub gateway_owner: Option<String>,
pub gateway_listener: String,
pub gateway_listeners: GatewayListeners,
}
impl GatewayConfig {
pub fn new(
gateway_identity: ed25519::PublicKey,
gateway_owner: Option<String>,
gateway_listener: String,
) -> Self {
pub fn new(gateway_identity: ed25519::PublicKey, gateway_listeners: GatewayListeners) -> Self {
GatewayConfig {
gateway_identity,
gateway_owner,
gateway_listener,
gateway_listeners,
}
}
}
#[derive(Debug, Clone)]
pub struct GatewayListeners {
pub primary: Url,
pub fallback: Option<Url>,
}
#[must_use]
#[derive(Debug)]
pub struct AuthenticationResponse {
pub initial_shared_key: Arc<SharedGatewayKey>,
pub requires_key_upgrade: bool,
pub initial_shared_key: Arc<SharedSymmetricKey>,
}
// TODO: this should be refactored into a state machine that keeps track of its authentication state
@@ -92,10 +88,10 @@ pub struct GatewayClient<C, St = EphemeralCredentialStorage> {
authenticated: bool,
bandwidth: ClientBandwidth,
gateway_address: String,
gateway_addresses: GatewayListeners,
gateway_identity: ed25519::PublicKey,
local_identity: Arc<ed25519::KeyPair>,
shared_key: Option<Arc<SharedGatewayKey>>,
shared_key: Option<Arc<SharedSymmetricKey>>,
connection: SocketState,
packet_router: PacketRouter,
bandwidth_controller: Option<BandwidthController<C, St>>,
@@ -118,7 +114,7 @@ impl<C, St> GatewayClient<C, St> {
gateway_config: GatewayConfig,
local_identity: Arc<ed25519::KeyPair>,
// TODO: make it mandatory. if you don't want to pass it, use `new_init`
shared_key: Option<Arc<SharedGatewayKey>>,
shared_key: Option<Arc<SharedSymmetricKey>>,
packet_router: PacketRouter,
bandwidth_controller: Option<BandwidthController<C, St>>,
stats_reporter: ClientStatsSender,
@@ -129,7 +125,7 @@ impl<C, St> GatewayClient<C, St> {
cfg,
authenticated: false,
bandwidth: ClientBandwidth::new_empty(),
gateway_address: gateway_config.gateway_listener,
gateway_addresses: gateway_config.gateway_listeners,
gateway_identity: gateway_config.gateway_identity,
local_identity,
shared_key,
@@ -148,7 +144,7 @@ impl<C, St> GatewayClient<C, St> {
self.gateway_identity
}
pub fn shared_key(&self) -> Option<Arc<SharedGatewayKey>> {
pub fn shared_key(&self) -> Option<Arc<SharedSymmetricKey>> {
self.shared_key.clone()
}
@@ -203,12 +199,19 @@ impl<C, St> GatewayClient<C, St> {
#[cfg(not(target_arch = "wasm32"))]
pub async fn establish_connection(&mut self) -> Result<(), GatewayClientError> {
debug!(
"Attempting to establish connection to gateway at: {}",
self.gateway_address
);
let (ws_stream, _) = connect_async(
&self.gateway_address,
if let Some(fallback_url) = &self.gateway_addresses.fallback {
debug!(
"Attempting to establish connection to gateway at: {}, with fallback at: {fallback_url}",
self.gateway_addresses.primary
);
} else {
debug!(
"Attempting to establish connection to gateway at: {}",
self.gateway_addresses.primary
);
}
let (ws_stream, _) = connect_async_with_fallback(
&self.gateway_addresses,
#[cfg(unix)]
self.connection_fd_callback.clone(),
)
@@ -221,7 +224,7 @@ impl<C, St> GatewayClient<C, St> {
#[cfg(target_arch = "wasm32")]
pub async fn establish_connection(&mut self) -> Result<(), GatewayClientError> {
let ws_stream = match JSWebsocket::new(&self.gateway_address) {
let ws_stream = match JSWebsocket::new(self.gateway_addresses.primary.as_ref()) {
Ok(ws_stream) => ws_stream,
Err(e) => {
return Err(GatewayClientError::NetworkErrorWasm(e));
@@ -274,7 +277,7 @@ impl<C, St> GatewayClient<C, St> {
message: ClientRequest,
) -> Result<(), GatewayClientError> {
if let Some(shared_key) = self.shared_key() {
let encrypted = message.encrypt(&*shared_key)?;
let encrypted = message.encrypt(&shared_key)?;
Box::pin(self.send_websocket_message_without_response(encrypted)).await?;
Ok(())
} else {
@@ -463,19 +466,14 @@ impl<C, St> GatewayClient<C, St> {
async fn register(
&mut self,
supported_gateway_protocol: Option<GatewayProtocolVersion>,
supported_gateway_protocol: GatewayProtocolVersion,
) -> Result<(), GatewayClientError> {
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
let derive_aes256_gcm_siv_key = supported_gateway_protocol.supports_aes256_gcm_siv();
debug_assert!(self.connection.is_available());
log::debug!(
"registering with gateway. using legacy key derivation: {}",
!derive_aes256_gcm_siv_key
);
log::debug!("registering with gateway");
// it's fine to instantiate it here as it's only used once (during authentication or registration)
// and putting it into the GatewayClient struct would be a hassle
@@ -525,75 +523,6 @@ impl<C, St> GatewayClient<C, St> {
Ok(())
}
pub async fn upgrade_key_authenticated(
&mut self,
) -> Result<Zeroizing<SharedSymmetricKey>, GatewayClientError> {
info!("*** STARTING AES128CTR-HMAC KEY UPGRADE INTO AES256GCM-SIV***");
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
if !self.authenticated {
return Err(GatewayClientError::NotAuthenticated);
}
let Some(shared_key) = self.shared_key.as_ref() else {
return Err(GatewayClientError::NoSharedKeyAvailable);
};
if !shared_key.is_legacy() {
return Err(GatewayClientError::KeyAlreadyUpgraded);
}
// make sure we have the only reference, so we could safely swap it
if Arc::strong_count(shared_key) != 1 {
return Err(GatewayClientError::KeyAlreadyInUse);
}
assert!(shared_key.is_legacy());
let legacy_key = shared_key.unwrap_legacy();
let (updated_key, hkdf_salt) = legacy_key.upgrade();
let derived_key_digest = updated_key.digest();
let upgrade_request = ClientRequest::UpgradeKey {
hkdf_salt,
derived_key_digest,
}
.encrypt(legacy_key)?;
info!("sending upgrade request and awaiting the acknowledgement back");
let (ciphertext, nonce) = match self
.send_websocket_message_with_response(upgrade_request)
.await?
{
ServerResponse::EncryptedResponse { ciphertext, nonce } => (ciphertext, nonce),
ServerResponse::Error { message } => {
return Err(GatewayClientError::GatewayError(message))
}
other => return Err(GatewayClientError::UnexpectedResponse { name: other.name() }),
};
// attempt to decrypt it using NEW key
let Ok(response) = SensitiveServerResponse::decrypt(&ciphertext, &nonce, &updated_key)
else {
return Err(GatewayClientError::FatalKeyUpgradeFailure);
};
match response {
SensitiveServerResponse::KeyUpgradeAck { .. } => {
info!("received key upgrade acknowledgement")
}
_ => return Err(GatewayClientError::FatalKeyUpgradeFailure),
}
// perform in memory swap and make a copy for updating storage
let zeroizing_updated_key = updated_key.zeroizing_clone();
self.shared_key = Some(Arc::new(updated_key.into()));
Ok(zeroizing_updated_key)
}
async fn send_authenticate_request_and_handle_response(
&mut self,
msg: ClientControlRequest,
@@ -606,17 +535,14 @@ impl<C, St> GatewayClient<C, St> {
upgrade_mode,
} => {
if protocol_version.is_future_version() {
// SAFETY: future version is always defined
#[allow(clippy::unwrap_used)]
let version = protocol_version.unwrap();
error!("the gateway insists on using v{version} protocol which is not supported by this client");
error!("the gateway insists on using v{protocol_version} protocol which is not supported by this client");
return Err(GatewayClientError::AuthenticationFailure);
}
self.authenticated = status;
self.bandwidth
.update_and_maybe_log(bandwidth_remaining, upgrade_mode);
self.negotiated_protocol = protocol_version;
self.negotiated_protocol = Some(protocol_version);
log::debug!("authenticated: {status}, bandwidth remaining: {bandwidth_remaining}");
if upgrade_mode {
warn!("the system is currently undergoing an upgrade. some of its functionalities might be unstable")
@@ -629,27 +555,6 @@ impl<C, St> GatewayClient<C, St> {
}
}
async fn authenticate_v1(&mut self) -> Result<(), GatewayClientError> {
debug!("using v1 authentication");
let Some(shared_key) = self.shared_key.as_ref() else {
return Err(GatewayClientError::NoSharedKeyAvailable);
};
let self_address = self
.local_identity
.public_key()
.derive_destination_address();
let msg = ClientControlRequest::new_legacy_authenticate(
self_address,
shared_key,
self.cfg.bandwidth.require_tickets,
)?;
self.send_authenticate_request_and_handle_response(msg)
.await
}
async fn authenticate_v2(
&mut self,
requested_protocol_version: GatewayProtocolVersion,
@@ -670,30 +575,22 @@ impl<C, St> GatewayClient<C, St> {
async fn authenticate(
&mut self,
supported_gateway_protocol: Option<GatewayProtocolVersion>,
requested_protocol_version: GatewayProtocolVersion,
) -> Result<(), GatewayClientError> {
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
debug!("authenticating with gateway");
if supported_gateway_protocol.supports_authenticate_v2() {
// use the highest possible protocol version the gateway has announced support for
// SAFETY: if announced protocol supports auth v2, it means it's properly set
#[allow(clippy::unwrap_used)]
self.authenticate_v2(supported_gateway_protocol.unwrap())
.await
} else {
self.authenticate_v1().await
}
// use the highest possible protocol version the gateway has announced support for
self.authenticate_v2(requested_protocol_version).await
}
/// Helper method to either call register or authenticate based on self.shared_key value
#[instrument(skip_all,
fields(
gateway = %self.gateway_identity,
gateway_address = %self.gateway_address
gateway_address = %self.gateway_addresses.primary
)
)]
pub async fn perform_initial_authentication(
@@ -704,15 +601,9 @@ impl<C, St> GatewayClient<C, St> {
}
// 1. check gateway's protocol version
let gw_protocol = match self.get_gateway_protocol().await {
Ok(protocol) => Some(protocol),
Err(_) => {
// if we failed to send the request, it means the gateway is running the old binary,
// so it has reset our connection - we have to reconnect
self.establish_connection().await?;
None
}
};
// if we failed to get this request resolved, it means the gateway is on an old version
// that definitely does not support auth v2 or aes256gcm, so we bail
let gw_protocol = self.get_gateway_protocol().await?;
debug!("supported gateway protocol: {gw_protocol:?}");
@@ -727,6 +618,16 @@ impl<C, St> GatewayClient<C, St> {
if !supports_auth_v2 {
warn!("this gateway is on an old version that doesn't support authentication v2")
}
// Dropping v1 support
if !supports_auth_v2 || !supports_aes_gcm_siv {
// we can't continue
return Err(GatewayClientError::IncompatibleProtocol {
gateway: gw_protocol,
current: CURRENT_PROTOCOL_VERSION,
});
}
if !supports_key_rotation_info {
warn!("this gateway is on an old version that doesn't support key rotation packets")
}
@@ -736,7 +637,7 @@ impl<C, St> GatewayClient<C, St> {
let gw_protocol = if gw_protocol.is_future_version() {
warn!("we're running outdated software as gateway is announcing protocol {gw_protocol:?} whilst we're using {}. we're going to attempt to downgrade", GatewayProtocolVersion::CURRENT);
Some(GatewayProtocolVersion::CURRENT)
GatewayProtocolVersion::CURRENT
} else {
gw_protocol
};
@@ -746,7 +647,6 @@ impl<C, St> GatewayClient<C, St> {
return if let Some(shared_key) = &self.shared_key {
Ok(AuthenticationResponse {
initial_shared_key: Arc::clone(shared_key),
requires_key_upgrade: shared_key.is_legacy() && supports_aes_gcm_siv,
})
} else {
Err(GatewayClientError::AuthenticationFailureWithPreexistingSharedKey)
@@ -761,11 +661,8 @@ impl<C, St> GatewayClient<C, St> {
#[allow(clippy::unwrap_used)]
let shared_key = self.shared_key.as_ref().unwrap();
let requires_key_upgrade = shared_key.is_legacy() && supports_aes_gcm_siv;
Ok(AuthenticationResponse {
initial_shared_key: Arc::clone(shared_key),
requires_key_upgrade,
})
} else {
Err(GatewayClientError::AuthenticationFailure)
@@ -781,7 +678,6 @@ impl<C, St> GatewayClient<C, St> {
// so no upgrades are required
Ok(AuthenticationResponse {
initial_shared_key: Arc::clone(shared_key),
requires_key_upgrade: false,
})
}
}
@@ -1143,7 +1039,12 @@ impl<C, St> GatewayClient<C, St> {
}
// if we're reconnecting, because we lost connection, we need to re-authenticate the connection
self.authenticate(self.negotiated_protocol).await?;
if let Some(negotiated_protocol) = self.negotiated_protocol {
self.authenticate(negotiated_protocol).await?;
} else {
// This should never happen, because it would mean we're not registered
return Err(GatewayClientError::NotRegistered);
}
// this call is NON-blocking
self.start_listening_for_mixnet_messages()?;
@@ -1188,7 +1089,7 @@ pub struct InitOnly;
impl GatewayClient<InitOnly, EphemeralCredentialStorage> {
// for initialisation we do not need credential storage. Though it's still a bit weird we have to set the generic...
pub fn new_init(
gateway_listener: Url,
gateway_listeners: GatewayListeners,
gateway_identity: ed25519::PublicKey,
local_identity: Arc<ed25519::KeyPair>,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
@@ -1207,7 +1108,7 @@ impl GatewayClient<InitOnly, EphemeralCredentialStorage> {
cfg: GatewayClientConfig::default().with_disabled_credentials_mode(true),
authenticated: false,
bandwidth: ClientBandwidth::new_empty(),
gateway_address: gateway_listener.to_string(),
gateway_addresses: gateway_listeners,
gateway_identity,
local_identity,
shared_key: None,
@@ -1239,7 +1140,7 @@ impl GatewayClient<InitOnly, EphemeralCredentialStorage> {
cfg: self.cfg,
authenticated: self.authenticated,
bandwidth: self.bandwidth,
gateway_address: self.gateway_address,
gateway_addresses: self.gateway_addresses,
gateway_identity: self.gateway_identity,
local_identity: self.local_identity,
shared_key: self.shared_key,
@@ -1,3 +1,5 @@
#[cfg(not(target_arch = "wasm32"))]
use crate::client::GatewayListeners;
use crate::error::GatewayClientError;
use nym_http_api_client::HickoryDnsResolver;
@@ -85,3 +87,35 @@ pub(crate) async fn connect_async(
source: Box::new(error),
})
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn connect_async_with_fallback(
endpoints: &GatewayListeners,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), GatewayClientError> {
match connect_async(
endpoints.primary.as_ref(),
#[cfg(unix)]
connection_fd_callback.clone(),
)
.await
{
Ok(inner) => Ok(inner),
Err(e) => {
if let Some(fallback) = &endpoints.fallback {
tracing::warn!(
"Main endpoint failed {} : {e}, trying fallback : {fallback}",
endpoints.primary
);
connect_async(
fallback.as_ref(),
#[cfg(unix)]
connection_fd_callback,
)
.await
} else {
Err(e)
}
}
}
}
@@ -83,6 +83,9 @@ pub enum GatewayClientError {
#[error("Client is not authenticated")]
NotAuthenticated,
#[error("Client is not registered")]
NotRegistered,
#[error("Client does not have enough bandwidth: estimated {0}, remaining: {1}")]
NotEnoughBandwidth(i64, i64),
@@ -116,8 +119,8 @@ pub enum GatewayClientError {
#[error("Failed to send mixnet message")]
MixnetMsgSenderFailedToSend,
#[error("Attempted to negotiate connection with gateway using incompatible protocol version. Ours is {current} and the gateway reports {gateway:?}")]
IncompatibleProtocol { gateway: Option<u8>, current: u8 },
#[error("Attempted to negotiate connection with gateway using incompatible protocol version. Ours is {current} and the gateway reports {gateway}")]
IncompatibleProtocol { gateway: u8, current: u8 },
#[error(
"The packet router hasn't been set - are you sure you started up the client correctly?"
+2 -4
View File
@@ -7,9 +7,7 @@ use tracing::{error, warn};
use tungstenite::{protocol::Message, Error as WsError};
pub use client::{config::GatewayClientConfig, GatewayClient, GatewayConfig};
pub use nym_gateway_requests::shared_key::{
LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey,
};
pub use nym_gateway_requests::shared_key::SharedSymmetricKey;
pub use packet_router::{
AcknowledgementReceiver, AcknowledgementSender, MixnetMessageReceiver, MixnetMessageSender,
PacketRouter,
@@ -47,7 +45,7 @@ pub(crate) fn cleanup_socket_messages(
pub(crate) fn try_decrypt_binary_message(
bin_msg: Vec<u8>,
shared_keys: &SharedGatewayKey,
shared_keys: &SharedSymmetricKey,
) -> Option<Vec<u8>> {
match BinaryResponse::try_from_encrypted_tagged_bytes(bin_msg, shared_keys) {
Ok(bin_response) => match bin_response {
@@ -10,7 +10,7 @@ use crate::{cleanup_socket_messages, try_decrypt_binary_message};
use futures::channel::oneshot;
use futures::stream::{SplitSink, SplitStream};
use futures::{SinkExt, StreamExt};
use nym_gateway_requests::shared_key::SharedGatewayKey;
use nym_gateway_requests::shared_key::SharedSymmetricKey;
use nym_gateway_requests::{
SendResponse, SensitiveServerResponse, ServerResponse, SimpleGatewayRequestsError,
};
@@ -66,7 +66,7 @@ pub(crate) struct PartiallyDelegatedHandle {
struct PartiallyDelegatedRouter {
packet_router: PacketRouter,
shared_key: Arc<SharedGatewayKey>,
shared_key: Arc<SharedSymmetricKey>,
client_bandwidth: ClientBandwidth,
stream_return: SplitStreamSender,
@@ -76,7 +76,7 @@ struct PartiallyDelegatedRouter {
impl PartiallyDelegatedRouter {
fn new(
packet_router: PacketRouter,
shared_key: Arc<SharedGatewayKey>,
shared_key: Arc<SharedSymmetricKey>,
client_bandwidth: ClientBandwidth,
stream_return: SplitStreamSender,
stream_return_requester: oneshot::Receiver<()>,
@@ -214,11 +214,6 @@ impl PartiallyDelegatedRouter {
SensitiveServerResponse::RememberMeAck {} => {
info!("received remember me acknowledgement");
}
SensitiveServerResponse::KeyUpgradeAck {} => {
warn!(
"received illegal key upgrade acknowledgement in an authenticated client"
);
}
_ => {
warn!("received unknown SensitiveServerResponse");
}
@@ -294,7 +289,7 @@ impl PartiallyDelegatedHandle {
pub(crate) fn split_and_listen_for_mixnet_messages(
conn: WsConn,
packet_router: PacketRouter,
shared_key: Arc<SharedGatewayKey>,
shared_key: Arc<SharedSymmetricKey>,
client_bandwidth: ClientBandwidth,
shutdown: ShutdownToken,
) -> Self {
@@ -40,8 +40,11 @@ impl SqliteEcashTicketbookManager {
Ok(())
}
pub(crate) async fn begin_storage_tx(&self) -> Result<Transaction<'_, Sqlite>, sqlx::Error> {
self.connection_pool.begin().await
/// Starts a write (IMMEDIATE) transaction, to prevent issue when upgrading from a read one to a write one
pub(crate) async fn begin_storage_write_tx(
&self,
) -> Result<Transaction<'_, Sqlite>, sqlx::Error> {
self.connection_pool.begin_with("BEGIN IMMEDIATE").await
}
pub(crate) async fn insert_pending_ticketbook(
@@ -244,7 +244,7 @@ impl Storage for PersistentStorage {
tickets: u32,
) -> Result<Option<RetrievedTicketbook>, Self::StorageError> {
let deadline = ecash_today().ecash_date();
let mut tx = self.storage_manager.begin_storage_tx().await?;
let mut tx = self.storage_manager.begin_storage_write_tx().await?;
// we don't want ticketbooks with expiration in the past
let Some(raw) =
@@ -1,73 +0,0 @@
// Copyright 2020-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::shared_key::{SharedGatewayKey, SharedKeyUsageError};
use nym_sphinx::DestinationAddressBytes;
use thiserror::Error;
/// Replacement for what used to be an `AuthToken`.
///
/// Replacement for what used to be an `AuthToken`. We used to be generating an `AuthToken` based on
/// local secret and remote address in order to allow for authentication. Due to changes in registration
/// and the fact we are deriving a shared key, we are encrypting remote's address with the previously
/// derived shared key. If the value is as expected, then authentication is successful.
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
// this is no longer constant size due to the differences in ciphertext between aes128ctr and aes256gcm-siv (inclusion of tag)
pub struct EncryptedAddressBytes(Vec<u8>);
impl From<Vec<u8>> for EncryptedAddressBytes {
fn from(encrypted_address: Vec<u8>) -> Self {
EncryptedAddressBytes(encrypted_address)
}
}
#[derive(Debug, Error)]
pub enum EncryptedAddressConversionError {
#[error("Failed to decode the encrypted address - {0}")]
DecodeError(#[from] bs58::decode::Error),
}
impl EncryptedAddressBytes {
pub fn new(
address: &DestinationAddressBytes,
key: &SharedGatewayKey,
nonce: &[u8],
) -> Result<Self, SharedKeyUsageError> {
let ciphertext = key.encrypt_naive(address.as_bytes_ref(), Some(nonce))?;
Ok(EncryptedAddressBytes(ciphertext))
}
pub fn verify(
&self,
address: &DestinationAddressBytes,
key: &SharedGatewayKey,
nonce: &[u8],
) -> bool {
let Ok(reconstructed) = Self::new(address, key, nonce) else {
return false;
};
self == &reconstructed
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn try_from_base58_string<S: Into<String>>(
val: S,
) -> Result<Self, EncryptedAddressConversionError> {
let decoded = bs58::decode(val.into()).into_vec()?;
Ok(EncryptedAddressBytes(decoded))
}
pub fn to_base58_string(self) -> String {
bs58::encode(self.0).into_string()
}
}
impl From<EncryptedAddressBytes> for String {
fn from(val: EncryptedAddressBytes) -> Self {
val.to_base58_string()
}
}
@@ -1,4 +0,0 @@
// Copyright 2020 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod encrypted_address;
+2 -7
View File
@@ -7,17 +7,12 @@ use nym_sphinx::params::GatewayIntegrityHmacAlgorithm;
pub use types::*;
pub mod authentication;
pub mod models;
pub mod registration;
pub mod shared_key;
pub mod types;
pub use shared_key::helpers::SymmetricKey;
pub use shared_key::legacy::{LegacySharedKeySize, LegacySharedKeys};
pub use shared_key::{
SharedGatewayKey, SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey,
};
pub use shared_key::{SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey};
pub type GatewayProtocolVersion = u8;
@@ -29,7 +24,7 @@ pub const CURRENT_PROTOCOL_VERSION: GatewayProtocolVersion = UPGRADE_MODE_VERSIO
// 1 - initial release
// 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
// 4 - introduction of v2 authentication protocol to prevent replay attacks
// 5 - add key rotation information to the serialised mix packet
// 6 - support for 'upgrade mode'
pub const INITIAL_PROTOCOL_VERSION: GatewayProtocolVersion = 1;
@@ -5,7 +5,7 @@ use crate::registration::handshake::messages::{Finalization, GatewayMaterialExch
use crate::registration::handshake::state::State;
use crate::registration::handshake::HandshakeResult;
use crate::registration::handshake::{error::HandshakeError, WsItem};
use crate::{GatewayProtocolVersionExt, INITIAL_PROTOCOL_VERSION};
use crate::GatewayProtocolVersionExt;
use futures::{Sink, Stream};
use rand::{CryptoRng, RngCore};
use tracing::info;
@@ -18,11 +18,11 @@ impl<S, R> State<'_, S, R> {
R: CryptoRng + RngCore,
{
// 1. if we're using non-legacy, i.e. aes256gcm-siv derivation, generate initiator salt for kdf
let maybe_hkdf_salt = self.maybe_generate_initiator_salt();
let hkdf_salt = self.generate_initiator_salt();
// 1. send ed25519 pubkey alongside ephemeral x25519 pubkey and a hkdf salt if we're using non-legacy client
// LOCAL_ID_PUBKEY || EPHEMERAL_KEY || MAYBE_SALT
let init_message = self.init_message(maybe_hkdf_salt.clone());
// LOCAL_ID_PUBKEY || EPHEMERAL_KEY || SALT
let init_message = self.init_message(hkdf_salt.clone());
self.send_handshake_data(init_message).await?;
// 2. wait for response with remote x25519 pubkey as well as encrypted signature
@@ -33,23 +33,20 @@ impl<S, R> State<'_, S, R> {
// NEGOTIATE PROTOCOL
if gateway_protocol.is_future_version() {
// SAFETY: future version means it's greater than CURRENT, which is always a `Some`
#[allow(clippy::unwrap_used)]
return Err(HandshakeError::UnsupportedProtocol {
version: gateway_protocol.unwrap(),
version: gateway_protocol,
});
}
let gateway_protocol = gateway_protocol.unwrap_or(INITIAL_PROTOCOL_VERSION);
// that should never happen, but we're fine with that outcome
if Some(gateway_protocol) != self.proposed_protocol_version() {
if gateway_protocol != self.proposed_protocol_version() {
info!("the gateway insists on protocol version different from the one we suggested. it wants {gateway_protocol} whilst we wanted {:?}, however, we can support it", self.proposed_protocol_version());
self.set_protocol_version(gateway_protocol);
}
// 3. derive shared keys locally
// hkdf::<blake3>::(g^xy)
self.derive_shared_key(&mid_res.ephemeral_dh, maybe_hkdf_salt.as_deref());
self.derive_shared_key(&mid_res.ephemeral_dh, &hkdf_salt);
// 4. verify the received signature using the locally derived keys
self.verify_remote_key_material(&mid_res.materials, &mid_res.ephemeral_dh)?;
@@ -56,10 +56,7 @@ impl<S, R> State<'_, S, R> {
// 2. derive shared keys locally
// hkdf::<blake3>::(g^xy)
self.derive_shared_key(
&init_message.ephemeral_dh,
init_message.initiator_salt.as_deref(),
);
self.derive_shared_key(&init_message.ephemeral_dh, &init_message.initiator_salt);
// 3. send ephemeral x25519 pubkey alongside the encrypted signature
// g^y || AES(k, sig(gate_priv, (g^y || g^x))
@@ -4,7 +4,7 @@
use crate::registration::handshake::error::HandshakeError;
use crate::registration::handshake::KDF_SALT_LENGTH;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_crypto::symmetric::aead::{nonce_size, tag_size};
use nym_crypto::symmetric::aead::{nonce_size, tag_size, Nonce};
use nym_sphinx::params::GatewayEncryptionAlgorithm;
// it is vital nobody changes the serialisation implementation unless you have an EXTREMELY good reason,
@@ -21,13 +21,13 @@ pub trait HandshakeMessage {
pub struct Initialisation {
pub identity: ed25519::PublicKey,
pub ephemeral_dh: x25519::PublicKey,
pub initiator_salt: Option<Vec<u8>>,
pub initiator_salt: Vec<u8>,
}
#[derive(Debug)]
pub struct MaterialExchange {
pub signature_ciphertext: Vec<u8>,
pub nonce: Option<Vec<u8>>,
pub nonce: Nonce<GatewayEncryptionAlgorithm>,
}
impl MaterialExchange {
@@ -61,21 +61,16 @@ impl Finalization {
}
impl HandshakeMessage for Initialisation {
// LOCAL_ID_PUBKEY || EPHEMERAL_KEY || MAYBE_SALT
// LOCAL_ID_PUBKEY || EPHEMERAL_KEY || SALT
// Eventually the ID_PUBKEY prefix will get removed and recipient will know
// initializer's identity from another source.
fn into_bytes(self) -> Vec<u8> {
let bytes = self
.identity
self.identity
.to_bytes()
.into_iter()
.chain(self.ephemeral_dh.to_bytes());
if let Some(salt) = self.initiator_salt {
bytes.chain(salt).collect()
} else {
bytes.collect()
}
.chain(self.ephemeral_dh.to_bytes())
.chain(self.initiator_salt)
.collect()
}
// this will need to be adjusted when REMOTE_ID_PUBKEY is removed
@@ -83,9 +78,8 @@ impl HandshakeMessage for Initialisation {
where
Self: Sized,
{
let legacy_len = ed25519::PUBLIC_KEY_LENGTH + x25519::PUBLIC_KEY_SIZE;
let current_len = legacy_len + KDF_SALT_LENGTH;
if bytes.len() != legacy_len && bytes.len() != current_len {
let current_len = ed25519::PUBLIC_KEY_LENGTH + x25519::PUBLIC_KEY_SIZE + KDF_SALT_LENGTH;
if bytes.len() != current_len {
return Err(HandshakeError::MalformedRequest);
}
@@ -95,14 +89,13 @@ impl HandshakeMessage for Initialisation {
// SAFETY: this can only fail if the provided bytes have len different from encryption::PUBLIC_KEY_SIZE
// which is impossible
#[allow(clippy::unwrap_used)]
let ephemeral_dh =
x25519::PublicKey::from_bytes(&bytes[ed25519::PUBLIC_KEY_LENGTH..legacy_len]).unwrap();
let ephemeral_dh = x25519::PublicKey::from_bytes(
&bytes
[ed25519::PUBLIC_KEY_LENGTH..ed25519::PUBLIC_KEY_LENGTH + x25519::PUBLIC_KEY_SIZE],
)
.unwrap();
let initiator_salt = if bytes.len() == legacy_len {
None
} else {
Some(bytes[legacy_len..].to_vec())
};
let initiator_salt = bytes[ed25519::PUBLIC_KEY_LENGTH + x25519::PUBLIC_KEY_SIZE..].to_vec();
Ok(Initialisation {
identity,
@@ -115,43 +108,31 @@ impl HandshakeMessage for Initialisation {
impl HandshakeMessage for MaterialExchange {
// AES(k, SIG(PRIV_GATE, G^y || G^x))
fn into_bytes(self) -> Vec<u8> {
if let Some(nonce) = self.nonce {
self.signature_ciphertext
.iter()
.cloned()
.chain(nonce)
.collect()
} else {
self.signature_ciphertext.to_vec()
}
self.signature_ciphertext
.iter()
.cloned()
.chain(self.nonce)
.collect()
}
fn try_from_bytes(bytes: &[u8]) -> Result<Self, HandshakeError>
where
Self: Sized,
{
// we expect to receive either:
// LEGACY: ed25519 signature ciphertext (64 bytes)
// CURRENT: ed25519 signature ciphertext (+ tag) + AES256-GCM-SIV nonce (76 bytes)
let legacy_len = ed25519::SIGNATURE_LENGTH;
let current_len = legacy_len
let current_len = ed25519::SIGNATURE_LENGTH
+ tag_size::<GatewayEncryptionAlgorithm>()
+ nonce_size::<GatewayEncryptionAlgorithm>();
if bytes.len() != legacy_len && bytes.len() != current_len {
if bytes.len() != current_len {
return Err(HandshakeError::MalformedResponse);
}
let (signature_ciphertext, nonce) = if bytes.len() == current_len {
let ciphertext_len =
ed25519::SIGNATURE_LENGTH + tag_size::<GatewayEncryptionAlgorithm>();
(
bytes[..ciphertext_len].to_vec(),
Some(bytes[ciphertext_len..].to_vec()),
)
} else {
(bytes.to_vec(), None)
};
let ciphertext_len = ed25519::SIGNATURE_LENGTH + tag_size::<GatewayEncryptionAlgorithm>();
let signature_ciphertext = bytes[..ciphertext_len].to_vec();
// SAFETY: we know the bytes have correct length
let nonce = Nonce::<GatewayEncryptionAlgorithm>::clone_from_slice(&bytes[ciphertext_len..]);
Ok(MaterialExchange {
signature_ciphertext,
@@ -3,7 +3,7 @@
use self::error::HandshakeError;
use crate::registration::handshake::state::State;
use crate::{GatewayProtocolVersion, SharedGatewayKey};
use crate::{GatewayProtocolVersion, SharedSymmetricKey};
use futures::future::BoxFuture;
use futures::{Sink, Stream};
use nym_crypto::asymmetric::ed25519;
@@ -48,7 +48,7 @@ impl Future for GatewayHandshake<'_> {
#[derive(Debug, PartialEq)]
pub struct HandshakeResult {
pub negotiated_protocol: GatewayProtocolVersion,
pub derived_key: SharedGatewayKey,
pub derived_key: SharedSymmetricKey,
}
pub fn client_handshake<'a, S, R>(
@@ -56,7 +56,7 @@ pub fn client_handshake<'a, S, R>(
ws_stream: &'a mut S,
identity: &'a ed25519::KeyPair,
gateway_pubkey: ed25519::PublicKey,
gateway_protocol: Option<GatewayProtocolVersion>,
gateway_protocol: GatewayProtocolVersion,
#[cfg(not(target_arch = "wasm32"))] shutdown_token: ShutdownToken,
) -> GatewayHandshake<'a>
where
@@ -84,7 +84,7 @@ pub fn gateway_handshake<'a, S, R>(
ws_stream: &'a mut S,
identity: &'a ed25519::KeyPair,
received_init_payload: Vec<u8>,
requested_client_protocol: Option<GatewayProtocolVersion>,
requested_client_protocol: GatewayProtocolVersion,
shutdown_token: ShutdownToken,
) -> GatewayHandshake<'a>
where
@@ -125,7 +125,7 @@ DONE(status)
#[cfg(test)]
mod tests {
use super::*;
use crate::{ClientControlRequest, CURRENT_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION};
use crate::{ClientControlRequest, CURRENT_PROTOCOL_VERSION};
use anyhow::{bail, Context};
use futures::StreamExt;
use nym_test_utils::helpers::u64_seeded_rng;
@@ -221,7 +221,7 @@ mod tests {
client.socket,
client.keys,
*gateway.keys.public_key(),
Some(CURRENT_PROTOCOL_VERSION),
CURRENT_PROTOCOL_VERSION,
ShutdownToken::default(),
);
@@ -235,7 +235,7 @@ mod tests {
gateway.socket,
gateway.keys,
init_msg,
Some(CURRENT_PROTOCOL_VERSION),
CURRENT_PROTOCOL_VERSION,
ShutdownToken::default(),
);
@@ -261,7 +261,7 @@ mod tests {
client.socket,
client.keys,
*gateway.keys.public_key(),
Some(CURRENT_PROTOCOL_VERSION + 42),
CURRENT_PROTOCOL_VERSION + 42,
ShutdownToken::default(),
);
@@ -274,7 +274,7 @@ mod tests {
gateway.socket,
gateway.keys,
init_msg,
Some(CURRENT_PROTOCOL_VERSION + 42),
CURRENT_PROTOCOL_VERSION + 42,
ShutdownToken::default(),
);
@@ -292,46 +292,4 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn protocol_upgrade() -> anyhow::Result<()> {
let (client, gateway) = setup();
let handshake_client = client_handshake(
client.rng,
client.socket,
client.keys,
*gateway.keys.public_key(),
None,
ShutdownToken::default(),
);
let client_fut = handshake_client.spawn_timeboxed();
// we need to receive the first message so that it could be propagated to the gateway side of the handshake
let init_msg = gateway.socket.get_handshake_init_data().await?;
let handshake_gateway = gateway_handshake(
gateway.rng,
gateway.socket,
gateway.keys,
init_msg,
None,
ShutdownToken::default(),
);
let gateway_fut = handshake_gateway.spawn_timeboxed();
let (client, gateway) = join!(client_fut, gateway_fut);
let client_res = client???;
let gateway_res = gateway???;
// ensure the created keys are the same
assert_eq!(client_res, gateway_res);
// and the protocol got upgraded to the first known version
assert_eq!(client_res.negotiated_protocol, INITIAL_PROTOCOL_VERSION);
Ok(())
}
}
@@ -5,12 +5,11 @@ use crate::registration::handshake::error::HandshakeError;
use crate::registration::handshake::messages::{
HandshakeMessage, Initialisation, MaterialExchange,
};
use crate::registration::handshake::{HandshakeResult, SharedGatewayKey, WsItem, KDF_SALT_LENGTH};
use crate::shared_key::SharedKeySize;
use crate::{
types, GatewayProtocolVersion, GatewayProtocolVersionExt, LegacySharedKeySize,
LegacySharedKeys, SharedSymmetricKey, INITIAL_PROTOCOL_VERSION,
use crate::registration::handshake::{
HandshakeResult, SharedSymmetricKey, WsItem, KDF_SALT_LENGTH,
};
use crate::shared_key::SharedKeySize;
use crate::{types, GatewayProtocolVersion};
use futures::{Sink, SinkExt, Stream, StreamExt};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_crypto::symmetric::aead::random_nonce;
@@ -48,17 +47,15 @@ pub(crate) struct State<'a, S, R> {
ephemeral_keypair: x25519::KeyPair,
/// The derived shared key using the ephemeral keys of both parties.
derived_shared_keys: Option<SharedGatewayKey>,
derived_shared_keys: Option<SharedSymmetricKey>,
/// The known or received public identity key of the remote.
/// Ideally it would always be known before the handshake was initiated.
remote_pubkey: Option<ed25519::PublicKey>,
/// Version of the protocol to use during the handshake that also implicitly specifies
/// additional features such as the type of derived shared keys, i.e.
/// AES128Ctr + blake3 HMAC keys (legacy) or AES256-GCM-SIV (current)
/// the above is decided by whether the specified protocol version supports the new variant or not.
protocol_version: Option<GatewayProtocolVersion>,
/// additional features
protocol_version: GatewayProtocolVersion,
// channel to receive shutdown signal
#[cfg(not(target_arch = "wasm32"))]
@@ -71,7 +68,7 @@ impl<'a, S, R> State<'a, S, R> {
ws_stream: &'a mut S,
identity: &'a ed25519::KeyPair,
remote_pubkey: Option<ed25519::PublicKey>,
protocol_version: Option<GatewayProtocolVersion>,
protocol_version: GatewayProtocolVersion,
#[cfg(not(target_arch = "wasm32"))] shutdown_token: ShutdownToken,
) -> Self
where
@@ -96,31 +93,27 @@ impl<'a, S, R> State<'a, S, R> {
self.ephemeral_keypair.public_key()
}
pub(crate) fn proposed_protocol_version(&self) -> Option<GatewayProtocolVersion> {
pub(crate) fn proposed_protocol_version(&self) -> GatewayProtocolVersion {
self.protocol_version
}
pub(crate) fn set_protocol_version(&mut self, protocol_version: GatewayProtocolVersion) {
self.protocol_version = Some(protocol_version);
self.protocol_version = protocol_version;
}
pub(crate) fn maybe_generate_initiator_salt(&mut self) -> Option<Vec<u8>>
pub(crate) fn generate_initiator_salt(&mut self) -> Vec<u8>
where
R: CryptoRng + RngCore,
{
if self.protocol_version.supports_aes256_gcm_siv() {
let mut salt = vec![0u8; KDF_SALT_LENGTH];
self.rng.fill_bytes(&mut salt);
Some(salt)
} else {
None
}
let mut salt = vec![0u8; KDF_SALT_LENGTH];
self.rng.fill_bytes(&mut salt);
salt
}
// LOCAL_ID_PUBKEY || EPHEMERAL_KEY || MAYBE_SALT
// LOCAL_ID_PUBKEY || EPHEMERAL_KEY || SALT
// Eventually the ID_PUBKEY prefix will get removed and recipient will know
// initializer's identity from another source.
pub(crate) fn init_message(&self, initiator_salt: Option<Vec<u8>>) -> Initialisation {
pub(crate) fn init_message(&self, initiator_salt: Vec<u8>) -> Initialisation {
Initialisation {
identity: *self.identity.public_key(),
ephemeral_dh: *self.ephemeral_keypair.public_key(),
@@ -138,23 +131,19 @@ impl<'a, S, R> State<'a, S, R> {
pub(crate) fn derive_shared_key(
&mut self,
remote_ephemeral_key: &x25519::PublicKey,
initiator_salt: Option<&[u8]>,
initiator_salt: &[u8],
) {
let dh_result = self
.ephemeral_keypair
.private_key()
.diffie_hellman(remote_ephemeral_key);
let key_size = if self.protocol_version.supports_aes256_gcm_siv() {
SharedKeySize::to_usize()
} else {
LegacySharedKeySize::to_usize()
};
let key_size = SharedKeySize::to_usize();
// SAFETY: there is no reason for this to fail as our okm is expected to be only 16 bytes
#[allow(clippy::expect_used)]
let okm = hkdf::extract_then_expand::<GatewaySharedKeyHkdfAlgorithm>(
initiator_salt,
Some(initiator_salt),
&dh_result,
None,
key_size,
@@ -162,17 +151,10 @@ impl<'a, S, R> State<'a, S, R> {
.expect("somehow too long okm was provided");
// SAFETY: the okm has been expanded to the length expected by the corresponding keys
let shared_key = if self.protocol_version.supports_aes256_gcm_siv() {
#[allow(clippy::expect_used)]
let current_key = SharedSymmetricKey::try_from_bytes(&okm)
.expect("okm was expanded to incorrect length!");
SharedGatewayKey::Current(current_key)
} else {
#[allow(clippy::expect_used)]
let legacy_key = LegacySharedKeys::try_from_bytes(&okm)
.expect("okm was expanded to incorrect length!");
SharedGatewayKey::Legacy(legacy_key)
};
#[allow(clippy::expect_used)]
let shared_key = SharedSymmetricKey::try_from_bytes(&okm)
.expect("okm was expanded to incorrect length!");
self.derived_shared_keys = Some(shared_key)
}
@@ -191,12 +173,8 @@ impl<'a, S, R> State<'a, S, R> {
.collect();
let signature = self.identity.private_key().sign(plaintext);
let nonce = if self.protocol_version.supports_aes256_gcm_siv() {
let mut rng = thread_rng();
Some(random_nonce::<GatewayEncryptionAlgorithm, _>(&mut rng).to_vec())
} else {
None
};
let mut rng = thread_rng();
let nonce = random_nonce::<GatewayEncryptionAlgorithm, _>(&mut rng);
// SAFETY: this function is only called after the local key has already been derived
#[allow(clippy::expect_used)]
@@ -204,7 +182,7 @@ impl<'a, S, R> State<'a, S, R> {
.derived_shared_keys
.as_ref()
.expect("shared key was not derived!")
.encrypt_naive(&signature.to_bytes(), nonce.as_deref())?;
.encrypt(&signature.to_bytes(), &nonce)?;
Ok(MaterialExchange {
signature_ciphertext,
@@ -224,15 +202,10 @@ impl<'a, S, R> State<'a, S, R> {
.as_ref()
.expect("shared key was not derived!");
// if the [client] init message contained non-legacy flag, the associated nonce MUST be present
if self.protocol_version.supports_aes256_gcm_siv() && remote_response.nonce.is_none() {
return Err(HandshakeError::MissingNonceForCurrentKey);
}
// first decrypt received data
let decrypted_signature = derived_shared_key.decrypt_naive(
let decrypted_signature = derived_shared_key.decrypt(
&remote_response.signature_ciphertext,
remote_response.nonce.as_deref(),
&remote_response.nonce,
)?;
// now verify signature itself
@@ -262,7 +235,7 @@ impl<'a, S, R> State<'a, S, R> {
#[allow(clippy::complexity)]
fn on_wg_msg(
msg: Option<WsItem>,
) -> Result<Option<(Vec<u8>, Option<GatewayProtocolVersion>)>, HandshakeError> {
) -> Result<Option<(Vec<u8>, GatewayProtocolVersion)>, HandshakeError> {
let Some(msg) = msg else {
return Err(HandshakeError::ClosedStream);
};
@@ -303,7 +276,7 @@ impl<'a, S, R> State<'a, S, R> {
#[cfg(not(target_arch = "wasm32"))]
async fn _receive_handshake_message_bytes(
&mut self,
) -> Result<(Vec<u8>, Option<GatewayProtocolVersion>), HandshakeError>
) -> Result<(Vec<u8>, GatewayProtocolVersion), HandshakeError>
where
S: Stream<Item = WsItem> + Unpin,
{
@@ -324,7 +297,7 @@ impl<'a, S, R> State<'a, S, R> {
#[cfg(target_arch = "wasm32")]
async fn _receive_handshake_message_bytes(
&mut self,
) -> Result<(Vec<u8>, Option<GatewayProtocolVersion>), HandshakeError>
) -> Result<(Vec<u8>, GatewayProtocolVersion), HandshakeError>
where
S: Stream<Item = WsItem> + Unpin,
{
@@ -339,7 +312,7 @@ impl<'a, S, R> State<'a, S, R> {
pub(crate) async fn receive_handshake_message<M>(
&mut self,
) -> Result<(M, Option<GatewayProtocolVersion>), HandshakeError>
) -> Result<(M, GatewayProtocolVersion), HandshakeError>
where
S: Stream<Item = WsItem> + Unpin,
M: HandshakeMessage,
@@ -396,9 +369,7 @@ impl<'a, S, R> State<'a, S, R> {
// SAFETY: handshake can't be finalised without deriving the shared keys
#[allow(clippy::unwrap_used)]
HandshakeResult {
negotiated_protocol: self
.proposed_protocol_version()
.unwrap_or(INITIAL_PROTOCOL_VERSION),
negotiated_protocol: self.proposed_protocol_version(),
derived_key: self.derived_shared_keys.unwrap(),
}
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_crypto::blake3;
use nym_crypto::crypto_hash::compute_digest;
use nym_crypto::generic_array::{typenum::Unsigned, GenericArray};
use nym_crypto::symmetric::aead::{
self, nonce_size, random_nonce, AeadError, AeadKey, KeySizeUser, Nonce,
};
use nym_pemstore::traits::PemStorableKey;
use nym_sphinx::params::GatewayEncryptionAlgorithm;
use rand::thread_rng;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
pub type SharedKeySize = <GatewayEncryptionAlgorithm as KeySizeUser>::KeySize;
#[derive(Debug, Error)]
pub enum SharedKeyUsageError {
#[error("the request is too short")]
TooShortRequest,
#[error("provided MAC is invalid")]
InvalidMac,
#[error("the provided nonce (or legacy IV) did not have the expected length")]
MalformedNonce,
#[error("did not provide a valid nonce for aead encryption")]
MissingAeadNonce,
#[error("failed to either encrypt or decrypt provided message")]
AeadFailure(#[from] AeadError),
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct SharedSymmetricKey(AeadKey<GatewayEncryptionAlgorithm>);
type KeySize = <GatewayEncryptionAlgorithm as KeySizeUser>::KeySize;
#[derive(Debug, Clone, Copy, Error)]
pub enum SharedKeyConversionError {
#[error("the string representation of the shared key was malformed: {0}")]
DecodeError(#[from] bs58::decode::Error),
#[error(
"the received shared keys had invalid size. Got: {received}, but expected: {expected}"
)]
InvalidSharedKeysSize { received: usize, expected: usize },
}
impl SharedSymmetricKey {
pub fn random_nonce(&self) -> Nonce<GatewayEncryptionAlgorithm> {
let mut rng = thread_rng();
random_nonce::<GatewayEncryptionAlgorithm, _>(&mut rng)
}
pub fn nonce_size(&self) -> usize {
nonce_size::<GatewayEncryptionAlgorithm>()
}
pub fn validate_aead_nonce(
raw: &[u8],
) -> Result<Nonce<GatewayEncryptionAlgorithm>, SharedKeyUsageError> {
if raw.len() != nonce_size::<GatewayEncryptionAlgorithm>() {
return Err(SharedKeyUsageError::MalformedNonce);
}
Ok(Nonce::<GatewayEncryptionAlgorithm>::clone_from_slice(raw))
}
pub fn try_from_bytes(bytes: &[u8]) -> Result<Self, SharedKeyConversionError> {
if bytes.len() != KeySize::to_usize() {
return Err(SharedKeyConversionError::InvalidSharedKeysSize {
received: bytes.len(),
expected: KeySize::to_usize(),
});
}
Ok(SharedSymmetricKey(GenericArray::clone_from_slice(bytes)))
}
pub fn zeroizing_clone(&self) -> Zeroizing<Self> {
Zeroizing::new(SharedSymmetricKey(self.0))
}
pub fn digest(&self) -> Vec<u8> {
compute_digest::<blake3::Hasher>(self.as_bytes()).to_vec()
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
pub fn to_bytes(&self) -> Vec<u8> {
self.0.iter().copied().collect()
}
pub fn try_from_base58_string<S: Into<String>>(
val: S,
) -> Result<Self, SharedKeyConversionError> {
let bs58_str = Zeroizing::new(val.into());
let decoded = Zeroizing::new(bs58::decode(bs58_str).into_vec()?);
Self::try_from_bytes(&decoded)
}
pub fn to_base58_string(&self) -> String {
let bytes = Zeroizing::new(self.to_bytes());
bs58::encode(bytes).into_string()
}
pub fn encrypt(
&self,
plaintext: &[u8],
nonce: &Nonce<GatewayEncryptionAlgorithm>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
aead::encrypt::<GatewayEncryptionAlgorithm>(&self.0, nonce, plaintext).map_err(Into::into)
}
pub fn decrypt(
&self,
ciphertext: &[u8],
nonce: &Nonce<GatewayEncryptionAlgorithm>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
aead::decrypt::<GatewayEncryptionAlgorithm>(&self.0, nonce, ciphertext).map_err(Into::into)
}
}
impl PemStorableKey for SharedSymmetricKey {
type Error = SharedKeyConversionError;
fn pem_type() -> &'static str {
"AES-256-GCM-SIV GATEWAY SHARED KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.to_bytes()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
Self::try_from_bytes(bytes)
}
}
@@ -1,98 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{LegacySharedKeys, SharedGatewayKey, SharedKeyUsageError, SharedSymmetricKey};
use nym_crypto::symmetric::aead::random_nonce;
use nym_crypto::symmetric::stream_cipher::random_iv;
use nym_sphinx::params::{GatewayEncryptionAlgorithm, LegacyGatewayEncryptionAlgorithm};
use rand::thread_rng;
pub trait SymmetricKey {
fn random_nonce_or_iv(&self) -> Vec<u8>;
fn encrypt(
&self,
plaintext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError>;
fn decrypt(
&self,
ciphertext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError>;
}
impl SymmetricKey for SharedGatewayKey {
fn random_nonce_or_iv(&self) -> Vec<u8> {
self.random_nonce_or_iv()
}
fn encrypt(
&self,
plaintext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
self.encrypt(plaintext, nonce)
}
fn decrypt(
&self,
ciphertext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
self.decrypt(ciphertext, nonce)
}
}
impl SymmetricKey for SharedSymmetricKey {
fn random_nonce_or_iv(&self) -> Vec<u8> {
let mut rng = thread_rng();
random_nonce::<GatewayEncryptionAlgorithm, _>(&mut rng).to_vec()
}
fn encrypt(
&self,
plaintext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
let nonce = SharedGatewayKey::validate_aead_nonce(nonce)?;
self.encrypt(plaintext, &nonce)
}
fn decrypt(
&self,
ciphertext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
let nonce = SharedGatewayKey::validate_aead_nonce(nonce)?;
self.decrypt(ciphertext, &nonce)
}
}
impl SymmetricKey for LegacySharedKeys {
fn random_nonce_or_iv(&self) -> Vec<u8> {
let mut rng = thread_rng();
random_iv::<LegacyGatewayEncryptionAlgorithm, _>(&mut rng).to_vec()
}
fn encrypt(
&self,
plaintext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
let iv = SharedGatewayKey::validate_cipher_iv(nonce)?;
Ok(self.encrypt_and_tag(plaintext, iv))
}
fn decrypt(
&self,
ciphertext: &[u8],
nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
let iv = SharedGatewayKey::validate_cipher_iv(nonce)?;
self.decrypt_tagged(ciphertext, iv)
}
}
@@ -1,246 +0,0 @@
// Copyright 2020-2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::registration::handshake::KDF_SALT_LENGTH;
use crate::shared_key::SharedSymmetricKey;
use crate::shared_key::{SharedKeyConversionError, SharedKeySize, SharedKeyUsageError};
use crate::LegacyGatewayMacSize;
use nym_crypto::generic_array::{
typenum::{Sum, Unsigned, U16},
GenericArray,
};
use nym_crypto::hkdf;
use nym_crypto::hmac::{compute_keyed_hmac, recompute_keyed_hmac_and_verify_tag};
use nym_crypto::symmetric::stream_cipher::{self, CipherKey, KeySizeUser, IV};
use nym_pemstore::traits::PemStorableKey;
use nym_sphinx::params::{
GatewayIntegrityHmacAlgorithm, GatewaySharedKeyHkdfAlgorithm, LegacyGatewayEncryptionAlgorithm,
};
use rand::{thread_rng, RngCore};
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
// shared key is as long as the encryption key and the MAC key combined.
pub type LegacySharedKeySize = Sum<EncryptionKeySize, MacKeySize>;
// we're using 16 byte long key in sphinx, so let's use the same one here
type MacKeySize = U16;
type EncryptionKeySize = <LegacyGatewayEncryptionAlgorithm as KeySizeUser>::KeySize;
/// Shared key used when computing MAC for messages exchanged between client and its gateway.
pub type MacKey = GenericArray<u8, MacKeySize>;
#[derive(Debug, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct LegacySharedKeys {
encryption_key: CipherKey<LegacyGatewayEncryptionAlgorithm>,
mac_key: MacKey,
}
impl LegacySharedKeys {
pub fn upgrade(&self) -> (SharedSymmetricKey, Vec<u8>) {
let mut rng = thread_rng();
let mut salt = vec![0u8; KDF_SALT_LENGTH];
rng.fill_bytes(&mut salt);
let legacy_bytes = Zeroizing::new(self.to_bytes());
#[allow(clippy::expect_used)]
let okm = hkdf::extract_then_expand::<GatewaySharedKeyHkdfAlgorithm>(
Some(&salt),
&legacy_bytes,
None,
SharedKeySize::to_usize(),
)
.expect("somehow too long okm was provided");
#[allow(clippy::expect_used)]
let key = SharedSymmetricKey::try_from_bytes(&okm)
.expect("okm was expanded to incorrect length!");
(key, salt)
}
pub fn upgrade_verify(
&self,
salt: &[u8],
expected_digest: &[u8],
) -> Option<SharedSymmetricKey> {
let legacy_bytes = Zeroizing::new(self.to_bytes());
#[allow(clippy::expect_used)]
let okm = hkdf::extract_then_expand::<GatewaySharedKeyHkdfAlgorithm>(
Some(salt),
&legacy_bytes,
None,
SharedKeySize::to_usize(),
)
.expect("somehow too long okm was provided");
#[allow(clippy::expect_used)]
let key = SharedSymmetricKey::try_from_bytes(&okm)
.expect("okm was expanded to incorrect length!");
if key.digest() != expected_digest {
// no need to zeroize that key since it's malformed and we won't be using it anyway
None
} else {
Some(key)
}
}
pub fn try_from_bytes(bytes: &[u8]) -> Result<Self, SharedKeyConversionError> {
if bytes.len() != LegacySharedKeySize::to_usize() {
return Err(SharedKeyConversionError::InvalidSharedKeysSize {
received: bytes.len(),
expected: LegacySharedKeySize::to_usize(),
});
}
let encryption_key =
GenericArray::clone_from_slice(&bytes[..EncryptionKeySize::to_usize()]);
let mac_key = GenericArray::clone_from_slice(&bytes[EncryptionKeySize::to_usize()..]);
Ok(LegacySharedKeys {
encryption_key,
mac_key,
})
}
/// Encrypts the provided data using the optionally provided initialisation vector,
/// or a 0 value if nothing was given.
/// It does **NOT** attach any integrity macs on the produced ciphertext
pub fn encrypt_without_tagging(
&self,
data: &[u8],
iv: Option<&IV<LegacyGatewayEncryptionAlgorithm>>,
) -> Vec<u8> {
match iv {
Some(iv) => stream_cipher::encrypt::<LegacyGatewayEncryptionAlgorithm>(
self.encryption_key(),
iv,
data,
),
None => {
let zero_iv = stream_cipher::zero_iv::<LegacyGatewayEncryptionAlgorithm>();
stream_cipher::encrypt::<LegacyGatewayEncryptionAlgorithm>(
self.encryption_key(),
&zero_iv,
data,
)
}
}
}
/// Encrypts the provided data using the optionally provided initialisation vector,
/// or a 0 value if nothing was given. Then it computes an integrity mac and concatenates it
/// with the previously produced ciphertext.
pub fn encrypt_and_tag(
&self,
data: &[u8],
iv: Option<&IV<LegacyGatewayEncryptionAlgorithm>>,
) -> Vec<u8> {
let ciphertext = self.encrypt_without_tagging(data, iv);
let mac = compute_keyed_hmac::<GatewayIntegrityHmacAlgorithm>(
self.mac_key().as_slice(),
&ciphertext,
);
mac.into_bytes().into_iter().chain(ciphertext).collect()
}
pub fn decrypt_without_tag(
&self,
ciphertext: &[u8],
iv: Option<&IV<LegacyGatewayEncryptionAlgorithm>>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
let zero_iv = stream_cipher::zero_iv::<LegacyGatewayEncryptionAlgorithm>();
let iv = iv.unwrap_or(&zero_iv);
Ok(stream_cipher::decrypt::<LegacyGatewayEncryptionAlgorithm>(
self.encryption_key(),
iv,
ciphertext,
))
}
pub fn decrypt_tagged(
&self,
enc_data: &[u8],
iv: Option<&IV<LegacyGatewayEncryptionAlgorithm>>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
let mac_size = LegacyGatewayMacSize::to_usize();
if enc_data.len() < mac_size {
return Err(SharedKeyUsageError::TooShortRequest);
}
let mac_tag = &enc_data[..mac_size];
let message_bytes = &enc_data[mac_size..];
if !recompute_keyed_hmac_and_verify_tag::<GatewayIntegrityHmacAlgorithm>(
self.mac_key().as_slice(),
message_bytes,
mac_tag,
) {
return Err(SharedKeyUsageError::InvalidMac);
}
// couldn't have made the first borrow mutable as you can't have an immutable borrow
// together with a mutable one
let mut message_bytes_mut = message_bytes.to_vec();
let zero_iv = stream_cipher::zero_iv::<LegacyGatewayEncryptionAlgorithm>();
let iv = iv.unwrap_or(&zero_iv);
stream_cipher::decrypt_in_place::<LegacyGatewayEncryptionAlgorithm>(
self.encryption_key(),
iv,
&mut message_bytes_mut,
);
Ok(message_bytes_mut)
}
pub fn encryption_key(&self) -> &CipherKey<LegacyGatewayEncryptionAlgorithm> {
&self.encryption_key
}
pub fn mac_key(&self) -> &MacKey {
&self.mac_key
}
pub fn to_bytes(&self) -> Vec<u8> {
self.encryption_key
.iter()
.copied()
.chain(self.mac_key.iter().copied())
.collect()
}
pub fn try_from_base58_string<S: Into<String>>(
val: S,
) -> Result<Self, SharedKeyConversionError> {
let decoded = bs58::decode(val.into()).into_vec()?;
LegacySharedKeys::try_from_bytes(&decoded)
}
pub fn to_base58_string(&self) -> String {
bs58::encode(self.to_bytes()).into_string()
}
}
impl From<LegacySharedKeys> for String {
fn from(keys: LegacySharedKeys) -> Self {
keys.to_base58_string()
}
}
impl PemStorableKey for LegacySharedKeys {
type Error = SharedKeyConversionError;
fn pem_type() -> &'static str {
// TODO: If common\nymsphinx\params\src\lib::GatewayIntegrityHmacAlgorithm changes
// the pem type needs updating!
"AES-128-CTR + HMAC-BLAKE3 GATEWAY SHARED KEYS"
}
fn to_bytes(&self) -> Vec<u8> {
self.to_bytes()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
Self::try_from_bytes(bytes)
}
}
@@ -1,306 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_crypto::blake3;
use nym_crypto::crypto_hash::compute_digest;
use nym_crypto::generic_array::{typenum::Unsigned, GenericArray};
use nym_crypto::symmetric::aead::{
self, nonce_size, random_nonce, AeadError, AeadKey, KeySizeUser, Nonce,
};
use nym_crypto::symmetric::stream_cipher::{iv_size, random_iv, IV};
use nym_pemstore::traits::PemStorableKey;
use nym_sphinx::params::{GatewayEncryptionAlgorithm, LegacyGatewayEncryptionAlgorithm};
use rand::thread_rng;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
pub use legacy::LegacySharedKeys;
pub mod helpers;
pub mod legacy;
pub type SharedKeySize = <GatewayEncryptionAlgorithm as KeySizeUser>::KeySize;
#[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop)]
pub enum SharedGatewayKey {
Current(SharedSymmetricKey),
Legacy(LegacySharedKeys),
}
impl SharedGatewayKey {
pub fn is_legacy(&self) -> bool {
matches!(self, SharedGatewayKey::Legacy(..))
}
pub fn aes128_ctr_hmac_bs58(&self) -> Option<Zeroizing<String>> {
match self {
SharedGatewayKey::Current(_) => None,
SharedGatewayKey::Legacy(key) => Some(Zeroizing::new(key.to_base58_string())),
}
}
pub fn aes256_gcm_siv(&self) -> Option<Zeroizing<Vec<u8>>> {
match self {
SharedGatewayKey::Current(key) => Some(Zeroizing::new(key.to_bytes())),
SharedGatewayKey::Legacy(_) => None,
}
}
// it is responsibility of the caller to ensure the correct variant is present
#[allow(clippy::panic)]
pub fn unwrap_legacy(&self) -> &LegacySharedKeys {
match self {
SharedGatewayKey::Current(_) => panic!("expected legacy key"),
SharedGatewayKey::Legacy(key) => key,
}
}
pub fn random_nonce_or_iv(&self) -> Vec<u8> {
let mut rng = thread_rng();
if self.is_legacy() {
random_iv::<LegacyGatewayEncryptionAlgorithm, _>(&mut rng).to_vec()
} else {
random_nonce::<GatewayEncryptionAlgorithm, _>(&mut rng).to_vec()
}
}
pub fn random_nonce_or_zero_iv(&self) -> Option<Vec<u8>> {
if self.is_legacy() {
None
} else {
let mut rng = thread_rng();
Some(random_nonce::<GatewayEncryptionAlgorithm, _>(&mut rng).to_vec())
}
}
pub fn nonce_size(&self) -> usize {
match self {
SharedGatewayKey::Current(_) => nonce_size::<GatewayEncryptionAlgorithm>(),
SharedGatewayKey::Legacy(_) => iv_size::<LegacyGatewayEncryptionAlgorithm>(),
}
}
}
impl From<LegacySharedKeys> for SharedGatewayKey {
fn from(keys: LegacySharedKeys) -> Self {
SharedGatewayKey::Legacy(keys)
}
}
impl From<SharedSymmetricKey> for SharedGatewayKey {
fn from(keys: SharedSymmetricKey) -> Self {
SharedGatewayKey::Current(keys)
}
}
#[derive(Debug, Error)]
pub enum SharedKeyUsageError {
#[error("the request is too short")]
TooShortRequest,
#[error("provided MAC is invalid")]
InvalidMac,
#[error("the provided nonce (or legacy IV) did not have the expected length")]
MalformedNonce,
#[error("did not provide a valid nonce for aead encryption")]
MissingAeadNonce,
#[error("failed to either encrypt or decrypt provided message")]
AeadFailure(#[from] AeadError),
}
impl SharedGatewayKey {
fn validate_aead_nonce(
raw: Option<&[u8]>,
) -> Result<Nonce<GatewayEncryptionAlgorithm>, SharedKeyUsageError> {
let Some(raw) = raw else {
return Err(SharedKeyUsageError::MissingAeadNonce);
};
if raw.len() != nonce_size::<GatewayEncryptionAlgorithm>() {
return Err(SharedKeyUsageError::MalformedNonce);
}
Ok(Nonce::<GatewayEncryptionAlgorithm>::clone_from_slice(raw))
}
fn validate_cipher_iv(
raw: Option<&[u8]>,
) -> Result<Option<&IV<LegacyGatewayEncryptionAlgorithm>>, SharedKeyUsageError> {
let Some(raw) = raw else { return Ok(None) };
let iv = if raw.is_empty() {
None
} else {
if raw.len() != iv_size::<LegacyGatewayEncryptionAlgorithm>() {
return Err(SharedKeyUsageError::MalformedNonce);
}
Some(IV::<LegacyGatewayEncryptionAlgorithm>::from_slice(raw))
};
Ok(iv)
}
pub fn encrypt(
&self,
plaintext: &[u8],
// the best common denominator for converting into 'IV' and 'Nonce' types
raw_nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
match self {
SharedGatewayKey::Current(aes_gcm_siv) => {
let nonce = Self::validate_aead_nonce(raw_nonce)?;
aes_gcm_siv.encrypt(plaintext, &nonce)
}
SharedGatewayKey::Legacy(aes_ctr) => {
let iv = Self::validate_cipher_iv(raw_nonce)?;
Ok(aes_ctr.encrypt_and_tag(plaintext, iv))
}
}
}
pub fn decrypt(
&self,
ciphertext: &[u8],
// the best common denominator for converting into 'IV' and 'Nonce' types
raw_nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
match self {
SharedGatewayKey::Current(aes_gcm_siv) => {
let nonce = Self::validate_aead_nonce(raw_nonce)?;
aes_gcm_siv.decrypt(ciphertext, &nonce)
}
SharedGatewayKey::Legacy(aes_ctr) => {
let iv = Self::validate_cipher_iv(raw_nonce)?;
aes_ctr.decrypt_tagged(ciphertext, iv)
}
}
}
// for the legacy keys do not use integrity MAC
pub fn encrypt_naive(
&self,
plaintext: &[u8],
// the best common denominator for converting into 'IV' and 'Nonce' types
raw_nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
match self {
SharedGatewayKey::Current(aes_gcm_siv) => {
let nonce = Self::validate_aead_nonce(raw_nonce)?;
aes_gcm_siv.encrypt(plaintext, &nonce)
}
SharedGatewayKey::Legacy(aes_ctr) => {
let iv = Self::validate_cipher_iv(raw_nonce)?;
Ok(aes_ctr.encrypt_without_tagging(plaintext, iv))
}
}
}
// for the legacy keys do not use integrity MAC
pub fn decrypt_naive(
&self,
ciphertext: &[u8],
// the best common denominator for converting into 'IV' and 'Nonce' types
raw_nonce: Option<&[u8]>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
match self {
SharedGatewayKey::Current(aes_gcm_siv) => {
let nonce = Self::validate_aead_nonce(raw_nonce)?;
aes_gcm_siv.decrypt(ciphertext, &nonce)
}
SharedGatewayKey::Legacy(aes_ctr) => {
let iv = Self::validate_cipher_iv(raw_nonce)?;
aes_ctr.decrypt_without_tag(ciphertext, iv)
}
}
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct SharedSymmetricKey(AeadKey<GatewayEncryptionAlgorithm>);
type KeySize = <GatewayEncryptionAlgorithm as KeySizeUser>::KeySize;
#[derive(Debug, Clone, Copy, Error)]
pub enum SharedKeyConversionError {
#[error("the string representation of the shared key was malformed: {0}")]
DecodeError(#[from] bs58::decode::Error),
#[error(
"the received shared keys had invalid size. Got: {received}, but expected: {expected}"
)]
InvalidSharedKeysSize { received: usize, expected: usize },
}
impl SharedSymmetricKey {
pub fn try_from_bytes(bytes: &[u8]) -> Result<Self, SharedKeyConversionError> {
if bytes.len() != KeySize::to_usize() {
return Err(SharedKeyConversionError::InvalidSharedKeysSize {
received: bytes.len(),
expected: KeySize::to_usize(),
});
}
Ok(SharedSymmetricKey(GenericArray::clone_from_slice(bytes)))
}
pub fn zeroizing_clone(&self) -> Zeroizing<Self> {
Zeroizing::new(SharedSymmetricKey(self.0))
}
pub fn digest(&self) -> Vec<u8> {
compute_digest::<blake3::Hasher>(self.as_bytes()).to_vec()
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
pub fn to_bytes(&self) -> Vec<u8> {
self.0.iter().copied().collect()
}
pub fn try_from_base58_string<S: Into<String>>(
val: S,
) -> Result<Self, SharedKeyConversionError> {
let bs58_str = Zeroizing::new(val.into());
let decoded = Zeroizing::new(bs58::decode(bs58_str).into_vec()?);
Self::try_from_bytes(&decoded)
}
pub fn to_base58_string(&self) -> String {
let bytes = Zeroizing::new(self.to_bytes());
bs58::encode(bytes).into_string()
}
pub fn encrypt(
&self,
plaintext: &[u8],
nonce: &Nonce<GatewayEncryptionAlgorithm>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
aead::encrypt::<GatewayEncryptionAlgorithm>(&self.0, nonce, plaintext).map_err(Into::into)
}
pub fn decrypt(
&self,
ciphertext: &[u8],
nonce: &Nonce<GatewayEncryptionAlgorithm>,
) -> Result<Vec<u8>, SharedKeyUsageError> {
aead::decrypt::<GatewayEncryptionAlgorithm>(&self.0, nonce, ciphertext).map_err(Into::into)
}
}
impl PemStorableKey for SharedSymmetricKey {
type Error = SharedKeyConversionError;
fn pem_type() -> &'static str {
"AES-256-GCM-SIV GATEWAY SHARED KEY"
}
fn to_bytes(&self) -> Vec<u8> {
self.to_bytes()
}
fn from_bytes(bytes: &[u8]) -> Result<Self, Self::Error> {
Self::try_from_bytes(bytes)
}
}
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::types::helpers::BinaryData;
use crate::{GatewayRequestsError, SharedGatewayKey};
use crate::{GatewayRequestsError, SharedSymmetricKey};
use nym_sphinx::forwarding::packet::MixPacket;
use strum::FromRepr;
use tungstenite::Message;
@@ -57,14 +57,14 @@ impl BinaryRequest {
pub fn try_from_encrypted_tagged_bytes(
bytes: Vec<u8>,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Self, GatewayRequestsError> {
BinaryData::from_raw(&bytes, shared_key)?.into_request(shared_key)
}
pub fn into_encrypted_tagged_bytes(
self,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Vec<u8>, GatewayRequestsError> {
let kind = self.kind();
@@ -78,7 +78,7 @@ impl BinaryRequest {
pub fn into_ws_message(
self,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Message, GatewayRequestsError> {
// all variants are currently encrypted
let blob = match self {
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::types::helpers::BinaryData;
use crate::{GatewayRequestsError, SharedGatewayKey};
use crate::{GatewayRequestsError, SharedSymmetricKey};
use strum::FromRepr;
use tungstenite::Message;
@@ -38,14 +38,14 @@ impl BinaryResponse {
pub fn try_from_encrypted_tagged_bytes(
bytes: Vec<u8>,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Self, GatewayRequestsError> {
BinaryData::from_raw(&bytes, shared_key)?.into_response(shared_key)
}
pub fn into_encrypted_tagged_bytes(
self,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Vec<u8>, GatewayRequestsError> {
let kind = self.kind();
@@ -58,7 +58,7 @@ impl BinaryResponse {
pub fn into_ws_message(
self,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Message, GatewayRequestsError> {
// all variants are currently encrypted
let blob = match self {
+24 -38
View File
@@ -3,12 +3,12 @@
use crate::{
BinaryRequest, BinaryRequestKind, BinaryResponse, BinaryResponseKind, GatewayRequestsError,
SharedGatewayKey,
SharedSymmetricKey,
};
use std::iter::once;
// each binary message consists of the following structure (for non-legacy messages)
// KIND || ENC_FLAG || MAYBE_NONCE || CIPHERTEXT/PLAINTEXT
// KIND || ENC_FLAG || NONCE || CIPHERTEXT/PLAINTEXT
// first byte is the kind of data to influence further serialisation/deseralisation
// second byte is a flag indicating whether the content is encrypted
// then it's followed by a pseudorandom nonce, assuming encryption is used
@@ -16,43 +16,25 @@ use std::iter::once;
pub struct BinaryData<'a> {
kind: u8,
encrypted: bool,
maybe_nonce: Option<&'a [u8]>,
nonce: &'a [u8],
data: &'a [u8],
}
impl<'a> BinaryData<'a> {
// serialises possibly encrypted data into bytes to be put on the wire
pub fn into_raw(self, legacy: bool) -> Vec<u8> {
if legacy {
return self.data.to_vec();
}
let i = once(self.kind).chain(once(if self.encrypted { 1 } else { 0 }));
if let Some(nonce) = self.maybe_nonce {
i.chain(nonce.iter().copied())
.chain(self.data.iter().copied())
.collect()
} else {
i.chain(self.data.iter().copied()).collect()
}
pub fn into_raw(self) -> Vec<u8> {
once(self.kind)
.chain(once(if self.encrypted { 1 } else { 0 }))
.chain(self.nonce.iter().copied())
.chain(self.data.iter().copied())
.collect()
}
// attempts to perform basic parsing on bytes received from the wire
pub fn from_raw(
raw: &'a [u8],
available_key: &SharedGatewayKey,
available_key: &SharedSymmetricKey,
) -> Result<Self, GatewayRequestsError> {
// if we're using legacy key, it's quite simple:
// it's always encrypted with no nonce and the request/response kind is always 1
if available_key.is_legacy() {
return Ok(BinaryData {
kind: 1,
encrypted: true,
maybe_nonce: None,
data: raw,
});
}
if raw.len() < 2 {
return Err(GatewayRequestsError::TooShortRequest);
}
@@ -74,7 +56,7 @@ impl<'a> BinaryData<'a> {
Ok(BinaryData {
kind,
encrypted,
maybe_nonce: Some(&raw[2..2 + available_key.nonce_size()]),
nonce: &raw[2..2 + available_key.nonce_size()],
data: &raw[2 + available_key.nonce_size()..],
})
}
@@ -83,30 +65,32 @@ impl<'a> BinaryData<'a> {
pub fn make_encrypted_blob(
kind: u8,
plaintext: &[u8],
key: &SharedGatewayKey,
key: &SharedSymmetricKey,
) -> Result<Vec<u8>, GatewayRequestsError> {
let maybe_nonce = key.random_nonce_or_zero_iv();
let nonce = key.random_nonce();
let ciphertext = key.encrypt(plaintext, maybe_nonce.as_deref())?;
let ciphertext = key.encrypt(plaintext, &nonce)?;
Ok(BinaryData {
kind,
encrypted: true,
maybe_nonce: maybe_nonce.as_deref(),
nonce: &nonce,
data: &ciphertext,
}
.into_raw(key.is_legacy()))
.into_raw())
}
// attempts to parse previously recovered bytes into a [`BinaryRequest`]
pub fn into_request(
self,
key: &SharedGatewayKey,
key: &SharedSymmetricKey,
) -> Result<BinaryRequest, GatewayRequestsError> {
let kind = BinaryRequestKind::from_repr(self.kind)
.ok_or(GatewayRequestsError::UnknownRequestKind { kind: self.kind })?;
let plaintext = if self.encrypted {
&*key.decrypt(self.data, self.maybe_nonce)?
let nonce = SharedSymmetricKey::validate_aead_nonce(self.nonce)?;
&*key.decrypt(self.data, &nonce)?
} else {
self.data
};
@@ -117,13 +101,15 @@ impl<'a> BinaryData<'a> {
// attempts to parse previously recovered bytes into a [`BinaryResponse`]
pub fn into_response(
self,
key: &SharedGatewayKey,
key: &SharedSymmetricKey,
) -> Result<BinaryResponse, GatewayRequestsError> {
let kind = BinaryResponseKind::from_repr(self.kind)
.ok_or(GatewayRequestsError::UnknownResponseKind { kind: self.kind })?;
let plaintext = if self.encrypted {
&*key.decrypt(self.data, self.maybe_nonce)?
let nonce = SharedSymmetricKey::validate_aead_nonce(self.nonce)?;
&*key.decrypt(self.data, &nonce)?
} else {
self.data
};
@@ -10,7 +10,7 @@ use std::str::FromStr;
pub enum RegistrationHandshake {
HandshakePayload {
#[serde(default)]
protocol_version: Option<GatewayProtocolVersion>,
protocol_version: GatewayProtocolVersion,
data: Vec<u8>,
},
HandshakeError {
@@ -19,7 +19,7 @@ pub enum RegistrationHandshake {
}
impl RegistrationHandshake {
pub fn new_payload(data: Vec<u8>, protocol_version: Option<GatewayProtocolVersion>) -> Self {
pub fn new_payload(data: Vec<u8>, protocol_version: GatewayProtocolVersion) -> Self {
RegistrationHandshake::HandshakePayload {
protocol_version,
data,
@@ -66,7 +66,7 @@ mod tests {
fn handshake_payload_can_be_deserialized_into_register_handshake_init_request() {
let handshake_data = vec![1, 2, 3, 4, 5, 6];
let handshake_payload_with_protocol = RegistrationHandshake::HandshakePayload {
protocol_version: Some(42),
protocol_version: 42,
data: handshake_data.clone(),
};
let serialized = serde_json::to_string(&handshake_payload_with_protocol).unwrap();
@@ -77,25 +77,7 @@ mod tests {
protocol_version,
data,
} => {
assert_eq!(protocol_version, Some(42));
assert_eq!(data, handshake_data)
}
_ => panic!("this branch shouldn't have been reached!"),
}
let handshake_payload_without_protocol = RegistrationHandshake::HandshakePayload {
protocol_version: None,
data: handshake_data.clone(),
};
let serialized = serde_json::to_string(&handshake_payload_without_protocol).unwrap();
let deserialized = ClientControlRequest::try_from(serialized).unwrap();
match deserialized {
ClientControlRequest::RegisterHandshakeInitRequest {
protocol_version,
data,
} => {
assert!(protocol_version.is_none());
assert_eq!(protocol_version, 42);
assert_eq!(data, handshake_data)
}
_ => panic!("this branch shouldn't have been reached!"),
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::{
AuthenticationFailure, GatewayProtocolVersion, GatewayRequestsError, SharedGatewayKey,
AuthenticationFailure, GatewayProtocolVersion, GatewayRequestsError, SharedSymmetricKey,
};
use nym_crypto::asymmetric::ed25519;
use serde::{Deserialize, Serialize};
@@ -23,7 +23,7 @@ pub struct AuthenticateRequest {
impl AuthenticateRequest {
pub fn new(
protocol_version: GatewayProtocolVersion,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
identity_keys: &ed25519::KeyPair,
) -> Result<AuthenticateRequest, GatewayRequestsError> {
let content = AuthenticateRequestContent::new(
@@ -72,14 +72,14 @@ impl AuthenticateRequest {
pub fn verify_ciphertext(
&self,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<(), AuthenticationFailure> {
let expected = shared_key.encrypt(
self.content
.client_identity
.derive_destination_address()
.as_bytes_ref(),
Some(&self.content.nonce),
&SharedSymmetricKey::validate_aead_nonce(&self.content.nonce)?,
)?;
if !bool::from(expected.ct_eq(&self.content.address_ciphertext)) {
@@ -117,20 +117,19 @@ pub struct AuthenticateRequestContent {
impl AuthenticateRequestContent {
fn new(
protocol_version: u8,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
client_identity: ed25519::PublicKey,
) -> Result<AuthenticateRequestContent, GatewayRequestsError> {
let nonce = shared_key.random_nonce_or_iv();
let nonce = shared_key.random_nonce();
let destination_address = client_identity.derive_destination_address();
let address_ciphertext =
shared_key.encrypt(destination_address.as_bytes_ref(), Some(&nonce))?;
let address_ciphertext = shared_key.encrypt(destination_address.as_bytes_ref(), &nonce)?;
let now = OffsetDateTime::now_utc();
Ok(AuthenticateRequestContent {
protocol_version,
client_identity,
address_ciphertext,
nonce,
nonce: nonce.to_vec(),
request_unix_timestamp: now.unix_timestamp() as u64, // SAFETY: we're running this in post 1970...
})
}
@@ -3,13 +3,9 @@
use crate::models::CredentialSpendingRequest;
use crate::text_request::authenticate::AuthenticateRequest;
use crate::{
GatewayProtocolVersion, GatewayRequestsError, SharedGatewayKey, SymmetricKey,
AES_GCM_SIV_PROTOCOL_VERSION, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION,
};
use crate::{GatewayProtocolVersion, GatewayRequestsError, SharedSymmetricKey};
use nym_credentials_interface::CredentialSpendingData;
use nym_crypto::asymmetric::ed25519;
use nym_sphinx::DestinationAddressBytes;
use nym_statistics_common::types::SessionType;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
@@ -21,23 +17,14 @@ pub mod authenticate;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub enum ClientRequest {
UpgradeKey {
hkdf_salt: Vec<u8>,
derived_key_digest: Vec<u8>,
},
ForgetMe {
client: bool,
stats: bool,
},
RememberMe {
session_type: SessionType,
},
ForgetMe { client: bool, stats: bool },
RememberMe { session_type: SessionType },
}
impl ClientRequest {
pub fn encrypt<S: SymmetricKey>(
pub fn encrypt(
&self,
key: &S,
key: &SharedSymmetricKey,
) -> Result<ClientControlRequest, GatewayRequestsError> {
// we're using json representation for few reasons:
// - ease of re-implementation in other languages (compared to for example bincode)
@@ -47,17 +34,21 @@ impl ClientRequest {
// SAFETY: the trait has been derived correctly with no weird variants
#[allow(clippy::unwrap_used)]
let plaintext = serde_json::to_vec(self).unwrap();
let nonce = key.random_nonce_or_iv();
let ciphertext = key.encrypt(&plaintext, Some(&nonce))?;
Ok(ClientControlRequest::EncryptedRequest { ciphertext, nonce })
let nonce = key.random_nonce();
let ciphertext = key.encrypt(&plaintext, &nonce)?;
Ok(ClientControlRequest::EncryptedRequest {
ciphertext,
nonce: nonce.to_vec(),
})
}
pub fn decrypt<S: SymmetricKey>(
pub fn decrypt(
ciphertext: &[u8],
nonce: &[u8],
key: &S,
key: &SharedSymmetricKey,
) -> Result<Self, GatewayRequestsError> {
let plaintext = key.decrypt(ciphertext, Some(nonce))?;
let nonce = SharedSymmetricKey::validate_aead_nonce(nonce)?;
let plaintext = key.decrypt(ciphertext, &nonce)?;
serde_json::from_slice(&plaintext)
.map_err(|source| GatewayRequestsError::MalformedRequest { source })
}
@@ -68,35 +59,20 @@ impl ClientRequest {
#[serde(tag = "type", rename_all = "camelCase")]
#[non_exhaustive]
pub enum ClientControlRequest {
// TODO: should this also contain a MAC considering that at this point we already
// have the shared key derived?
Authenticate {
#[serde(default)]
protocol_version: Option<GatewayProtocolVersion>,
address: String,
enc_address: String,
iv: String,
},
AuthenticateV2(Box<AuthenticateRequest>),
#[serde(alias = "handshakePayload")]
RegisterHandshakeInitRequest {
#[serde(default)]
protocol_version: Option<GatewayProtocolVersion>,
protocol_version: GatewayProtocolVersion,
data: Vec<u8>,
},
BandwidthCredential {
enc_credential: Vec<u8>,
iv: Vec<u8>,
},
BandwidthCredentialV2 {
enc_credential: Vec<u8>,
iv: Vec<u8>,
},
EcashCredential {
enc_credential: Vec<u8>,
iv: Vec<u8>,
// Old gateways only understand `iv` so rename the field, but have nonce as an alias for next version update, to then phase out `iv`
#[serde(rename = "iv")]
#[serde(alias = "nonce")]
nonce: Vec<u8>,
},
UpgradeModeJWT {
// no need to encrypt it as it's public anyway
@@ -109,40 +85,29 @@ pub enum ClientControlRequest {
},
SupportedProtocol {},
// if you're adding new variants here, consider putting them inside `ClientRequest` instead
// NO LONGER SUPPORTED
Authenticate {
#[serde(default)]
protocol_version: Option<GatewayProtocolVersion>,
address: String,
enc_address: String,
iv: String,
},
BandwidthCredential {
enc_credential: Vec<u8>,
iv: Vec<u8>,
},
BandwidthCredentialV2 {
enc_credential: Vec<u8>,
iv: Vec<u8>,
},
}
impl ClientControlRequest {
pub fn new_legacy_authenticate(
address: DestinationAddressBytes,
shared_key: &SharedGatewayKey,
uses_credentials: bool,
) -> Result<Self, GatewayRequestsError> {
// if we're encrypting with non-legacy key, the remote must support AES256-GCM-SIV
// since we are using legacy authentication, the gateway definitely doesn't understand the protocol downgrade,
// so use the lowest possible version we can
let protocol_version = if !shared_key.is_legacy() {
Some(AES_GCM_SIV_PROTOCOL_VERSION)
} else if uses_credentials {
Some(CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION)
} else {
// if we're not going to be using credentials, advertise lower protocol version to allow connection
// to wider range of gateways
Some(INITIAL_PROTOCOL_VERSION)
};
let nonce = shared_key.random_nonce_or_iv();
let ciphertext = shared_key.encrypt_naive(address.as_bytes_ref(), Some(&nonce))?;
Ok(ClientControlRequest::Authenticate {
protocol_version,
address: address.as_base58_string(),
enc_address: bs58::encode(&ciphertext).into_string(),
iv: bs58::encode(&nonce).into_string(),
})
}
pub fn new_authenticate_v2(
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
identity_keys: &ed25519::KeyPair,
protocol_version: GatewayProtocolVersion,
) -> Result<Self, GatewayRequestsError> {
@@ -174,26 +139,27 @@ impl ClientControlRequest {
pub fn new_enc_ecash_credential(
credential: CredentialSpendingData,
shared_key: &SharedGatewayKey,
shared_key: &SharedSymmetricKey,
) -> Result<Self, GatewayRequestsError> {
let cred = CredentialSpendingRequest::new(credential);
let serialized_credential = cred.to_bytes();
let nonce = shared_key.random_nonce_or_iv();
let enc_credential = shared_key.encrypt(&serialized_credential, Some(&nonce))?;
let nonce = shared_key.random_nonce();
let enc_credential = shared_key.encrypt(&serialized_credential, &nonce)?;
Ok(ClientControlRequest::EcashCredential {
enc_credential,
iv: nonce,
nonce: nonce.to_vec(),
})
}
pub fn try_from_enc_ecash_credential(
enc_credential: Vec<u8>,
shared_key: &SharedGatewayKey,
iv: Vec<u8>,
shared_key: &SharedSymmetricKey,
nonce: Vec<u8>,
) -> Result<CredentialSpendingRequest, GatewayRequestsError> {
let credential_bytes = shared_key.decrypt(&enc_credential, Some(&iv))?;
let nonce = SharedSymmetricKey::validate_aead_nonce(&nonce)?;
let credential_bytes = shared_key.decrypt(&enc_credential, &nonce)?;
CredentialSpendingRequest::try_from_bytes(credential_bytes.as_slice())
.map_err(|_| GatewayRequestsError::MalformedEncryption)
}
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::{
GatewayProtocolVersion, GatewayRequestsError, SimpleGatewayRequestsError, SymmetricKey,
GatewayProtocolVersion, GatewayRequestsError, SharedSymmetricKey, SimpleGatewayRequestsError,
};
use serde::{Deserialize, Serialize};
use tungstenite::Message;
@@ -12,15 +12,14 @@ use tungstenite::Message;
#[derive(Serialize, Deserialize, Debug)]
#[non_exhaustive]
pub enum SensitiveServerResponse {
KeyUpgradeAck {},
ForgetMeAck {},
RememberMeAck {},
}
impl SensitiveServerResponse {
pub fn encrypt<S: SymmetricKey>(
pub fn encrypt(
&self,
key: &S,
key: &SharedSymmetricKey,
) -> Result<ServerResponse, GatewayRequestsError> {
// we're using json representation for few reasons:
// - ease of re-implementation in other languages (compared to for example bincode)
@@ -30,17 +29,21 @@ impl SensitiveServerResponse {
// SAFETY: the trait has been derived correctly with no weird variants
#[allow(clippy::unwrap_used)]
let plaintext = serde_json::to_vec(self).unwrap();
let nonce = key.random_nonce_or_iv();
let ciphertext = key.encrypt(&plaintext, Some(&nonce))?;
Ok(ServerResponse::EncryptedResponse { ciphertext, nonce })
let nonce = key.random_nonce();
let ciphertext = key.encrypt(&plaintext, &nonce)?;
Ok(ServerResponse::EncryptedResponse {
ciphertext,
nonce: nonce.to_vec(),
})
}
pub fn decrypt<S: SymmetricKey>(
pub fn decrypt(
ciphertext: &[u8],
nonce: &[u8],
key: &S,
key: &SharedSymmetricKey,
) -> Result<Self, GatewayRequestsError> {
let plaintext = key.decrypt(ciphertext, Some(nonce))?;
let nonce = SharedSymmetricKey::validate_aead_nonce(nonce)?;
let plaintext = key.decrypt(ciphertext, &nonce)?;
serde_json::from_slice(&plaintext)
.map_err(|source| GatewayRequestsError::MalformedRequest { source })
}
@@ -72,7 +75,7 @@ pub struct SendResponse {
pub enum ServerResponse {
Authenticate {
#[serde(default)]
protocol_version: Option<GatewayProtocolVersion>,
protocol_version: GatewayProtocolVersion,
status: bool,
bandwidth_remaining: i64,
@@ -83,7 +86,7 @@ pub enum ServerResponse {
},
Register {
#[serde(default)]
protocol_version: Option<GatewayProtocolVersion>,
protocol_version: GatewayProtocolVersion,
status: bool,
/// Flag indicating whether the gateway has detected the system is undergoing the upgrade
@@ -0,0 +1,23 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: GPL-3.0-only
*/
-- make aes256gcm column non-nullable and drop any clients that still use the legacy keys
CREATE TABLE shared_keys_tmp
(
client_id INTEGER NOT NULL PRIMARY KEY REFERENCES clients (id),
client_address_bs58 TEXT NOT NULL UNIQUE,
derived_aes256_gcm_siv_key BLOB NOT NULL,
last_used_authentication TIMESTAMP WITHOUT TIME ZONE
);
INSERT INTO shared_keys_tmp (client_id, client_address_bs58, derived_aes256_gcm_siv_key, last_used_authentication)
SELECT client_id, client_address_bs58, derived_aes256_gcm_siv_key, last_used_authentication
FROM shared_keys
WHERE derived_aes256_gcm_siv_key IS NOT NULL;
DROP TABLE shared_keys;
ALTER TABLE shared_keys_tmp
RENAME TO shared_keys;
+3 -4
View File
@@ -9,7 +9,7 @@ use models::{
VerifiedTicket, WireguardPeer,
};
use nym_credentials_interface::ClientTicket;
use nym_gateway_requests::shared_key::SharedGatewayKey;
use nym_gateway_requests::shared_key::SharedSymmetricKey;
use nym_sphinx::DestinationAddressBytes;
use shared_keys::SharedKeysManager;
use sqlx::{
@@ -165,7 +165,7 @@ impl SharedKeyGatewayStorage for GatewayStorage {
async fn insert_shared_keys(
&self,
client_address: DestinationAddressBytes,
shared_keys: &SharedGatewayKey,
shared_keys: &SharedSymmetricKey,
) -> Result<i64, GatewayStorageError> {
let client_address_bs58 = client_address.as_base58_string();
let client_id = match self
@@ -184,8 +184,7 @@ impl SharedKeyGatewayStorage for GatewayStorage {
.insert_shared_keys(
client_id,
client_address_bs58,
shared_keys.aes128_ctr_hmac_bs58().as_deref(),
shared_keys.aes256_gcm_siv().as_deref(),
shared_keys.to_bytes().as_ref(),
)
.await?;
Ok(client_id)
+5 -22
View File
@@ -3,7 +3,7 @@
use crate::{error::GatewayStorageError, make_bincode_serializer};
use nym_credentials_interface::{AvailableBandwidth, ClientTicket, CredentialSpendingData};
use nym_gateway_requests::shared_key::{LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey};
use nym_gateway_requests::shared_key::SharedSymmetricKey;
use sqlx::FromRow;
use time::OffsetDateTime;
@@ -18,33 +18,16 @@ pub struct PersistedSharedKeys {
#[allow(dead_code)]
pub client_address_bs58: String,
pub derived_aes128_ctr_blake3_hmac_keys_bs58: Option<String>,
pub derived_aes256_gcm_siv_key: Option<Vec<u8>>,
pub derived_aes256_gcm_siv_key: Vec<u8>,
pub last_used_authentication: Option<OffsetDateTime>,
}
impl TryFrom<PersistedSharedKeys> for SharedGatewayKey {
impl TryFrom<PersistedSharedKeys> for SharedSymmetricKey {
type Error = GatewayStorageError;
fn try_from(value: PersistedSharedKeys) -> Result<Self, Self::Error> {
match (
&value.derived_aes256_gcm_siv_key,
&value.derived_aes128_ctr_blake3_hmac_keys_bs58,
) {
(None, None) => Err(GatewayStorageError::MissingSharedKey {
id: value.client_id,
}),
(Some(aes256gcm_siv), _) => {
let current_key = SharedSymmetricKey::try_from_bytes(aes256gcm_siv)
.map_err(|source| GatewayStorageError::DataCorruption(source.to_string()))?;
Ok(SharedGatewayKey::Current(current_key))
}
(None, Some(aes128ctr_hmac)) => {
let legacy_key = LegacySharedKeys::try_from_base58_string(aes128ctr_hmac)
.map_err(|source| GatewayStorageError::DataCorruption(source.to_string()))?;
Ok(SharedGatewayKey::Legacy(legacy_key))
}
}
SharedSymmetricKey::try_from_bytes(&value.derived_aes256_gcm_siv_key)
.map_err(|source| GatewayStorageError::DataCorruption(source.to_string()))
}
}
+2 -6
View File
@@ -42,26 +42,22 @@ impl SharedKeysManager {
&self,
client_id: i64,
client_address_bs58: String,
derived_aes128_ctr_blake3_hmac_keys_bs58: Option<&String>,
derived_aes256_gcm_siv_key: Option<&Vec<u8>>,
derived_aes256_gcm_siv_key: &Vec<u8>,
) -> Result<(), sqlx::Error> {
// https://stackoverflow.com/a/20310838
// we don't want to be using `INSERT OR REPLACE INTO` due to the foreign key on `available_bandwidth` if the entry already exists
sqlx::query!(
r#"
INSERT OR IGNORE INTO shared_keys(client_id, client_address_bs58, derived_aes128_ctr_blake3_hmac_keys_bs58, derived_aes256_gcm_siv_key) VALUES (?, ?, ?, ?);
INSERT OR IGNORE INTO shared_keys(client_id, client_address_bs58,derived_aes256_gcm_siv_key) VALUES (?, ?, ?);
UPDATE shared_keys
SET
derived_aes128_ctr_blake3_hmac_keys_bs58 = ?,
derived_aes256_gcm_siv_key = ?
WHERE client_address_bs58 = ?
"#,
client_id,
client_address_bs58,
derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key,
derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key,
client_address_bs58,
).execute(&self.connection_pool).await?;
+2 -2
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait;
use nym_credentials_interface::ClientTicket;
use nym_gateway_requests::SharedGatewayKey;
use nym_gateway_requests::SharedSymmetricKey;
use nym_sphinx::DestinationAddressBytes;
use time::OffsetDateTime;
@@ -25,7 +25,7 @@ pub trait SharedKeyGatewayStorage {
async fn insert_shared_keys(
&self,
client_address: DestinationAddressBytes,
shared_keys: &SharedGatewayKey,
shared_keys: &SharedSymmetricKey,
) -> Result<i64, GatewayStorageError>;
async fn get_shared_keys(
&self,
+110 -114
View File
@@ -23,7 +23,7 @@
//! priority = 10; // Optional, defaults to 0
//! timeout = std::time::Duration::from_secs(30),
//! gzip = true,
//! user_agent = "MyApp/1.0"
//! user_agent = "Nym/1.0"
//! );
//! ```
//!
@@ -60,9 +60,8 @@
//! - Positive priorities: Late configuration (e.g., 100 for overrides)
use proc_macro::TokenStream;
use proc_macro_crate::{FoundCrate, crate_name};
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{format_ident, quote};
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{
Expr, Ident, LitInt, Result, Token, braced,
parse::{Parse, ParseStream},
@@ -74,22 +73,22 @@ use syn::{
// ------------------ core crate path resolution ------------------
fn core_path() -> TokenStream2 {
use proc_macro_crate::{FoundCrate, crate_name};
match crate_name("nym-http-api-client") {
Ok(FoundCrate::Itself) => quote!(crate),
Ok(FoundCrate::Name(name)) => {
let ident = Ident::new(&name, Span::call_site());
let ident = Ident::new(&name, proc_macro2::Span::call_site());
quote!( ::#ident )
}
Err(_) => {
// Fallback if the crate is not found by name (unlikely if deps set up correctly)
quote!(::nym_http_api_client)
}
Err(_) => quote!(::nym_http_api_client),
}
}
// ------------------ DSL parsing ------------------
struct Items(Punctuated<Item, Token![,]>);
impl Parse for Items {
fn parse(input: ParseStream<'_>) -> Result<Self> {
Ok(Self(Punctuated::parse_terminated(input)?))
@@ -101,19 +100,19 @@ enum Item {
key: Ident,
_eq: Token![=],
value: Expr,
}, // foo = EXPR
},
Call {
key: Ident,
args: Punctuated<Expr, Token![,]>,
_p: token::Paren,
}, // foo(a,b)
},
DefaultHeaders {
_key: Ident,
map: HeaderMapInit,
}, // default_headers { ... }
},
Flag {
key: Ident,
}, // foo
},
}
impl Parse for Item {
@@ -125,16 +124,19 @@ impl Parse for Item {
let value: Expr = input.parse()?;
return Ok(Self::Assign { key, _eq, value });
}
if input.peek(token::Paren) {
let content;
let _p = syn::parenthesized!(content in input);
let args = Punctuated::<Expr, Token![,]>::parse_terminated(&content)?;
return Ok(Self::Call { key, args, _p });
}
if input.peek(token::Brace) && key == format_ident!("default_headers") {
if input.peek(token::Brace) && key == quote::format_ident!("default_headers") {
let map = input.parse::<HeaderMapInit>()?;
return Ok(Self::DefaultHeaders { _key: key, map });
}
Ok(Self::Flag { key })
}
}
@@ -144,6 +146,7 @@ struct HeaderPair {
_arrow: Token![=>],
v: Expr,
}
impl Parse for HeaderPair {
fn parse(input: ParseStream<'_>) -> Result<Self> {
Ok(Self {
@@ -158,6 +161,7 @@ struct HeaderMapInit {
_brace: token::Brace,
pairs: Punctuated<HeaderPair, Token![,]>,
}
impl Parse for HeaderMapInit {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let content;
@@ -170,6 +174,7 @@ impl Parse for HeaderMapInit {
// Generate statements that mutate a builder named `b` using the resolved core path.
fn to_stmts(items: Items, core: &TokenStream2) -> TokenStream2 {
let mut stmts = Vec::new();
for it in items.0 {
match it {
Item::Assign { key, value, .. } => {
@@ -204,9 +209,73 @@ fn to_stmts(items: Items, core: &TokenStream2) -> TokenStream2 {
}
}
}
quote! { #(#stmts)* }
}
struct MaybePrioritized {
priority: i32,
items: Items,
}
impl Parse for MaybePrioritized {
fn parse(input: ParseStream<'_>) -> Result<Self> {
// Optional header: `priority = <int> ;`
let fork = input.fork();
let mut priority = 0i32;
if fork.peek(Ident) && fork.parse::<Ident>()? == "priority" && fork.peek(Token![=]) {
// commit
let _ = input.parse::<Ident>()?; // priority
let _ = input.parse::<Token![=]>()?; // =
let lit: LitInt = input.parse()?;
priority = lit.base10_parse()?;
let _ = input.parse::<Token![;]>()?; // ;
}
let items = input.parse::<Items>()?;
Ok(Self { priority, items })
}
}
fn describe_items(items: &Items) -> String {
use std::fmt::Write;
let mut buf = String::new();
for (idx, item) in items.0.iter().enumerate() {
if idx > 0 {
buf.push_str(", ");
}
match item {
Item::Assign { key, value, .. } => {
let k = quote!(#key).to_string();
let v = quote!(#value).to_string();
let _ = write!(buf, "{}={}", k, v);
}
Item::Call { key, args, .. } => {
let k = quote!(#key).to_string();
let args_str = args
.iter()
.map(|a| quote!(#a).to_string())
.collect::<Vec<_>>()
.join(", ");
let _ = write!(buf, "{}({})", k, args_str);
}
Item::Flag { key } => {
let k = quote!(#key).to_string();
let _ = write!(buf, "{}()", k);
}
Item::DefaultHeaders { .. } => {
buf.push_str("default_headers{...}");
}
}
}
buf
}
// ------------------ client_cfg! ------------------
/// Creates a closure that configures a `ReqwestClientBuilder`.
@@ -236,30 +305,6 @@ pub fn client_cfg(input: TokenStream) -> TokenStream {
out.into()
}
// ------------------ client_defaults! with optional priority header ------------------
struct MaybePrioritized {
priority: i32,
items: Items,
}
impl Parse for MaybePrioritized {
fn parse(input: ParseStream<'_>) -> Result<Self> {
// Optional header: `priority = <int> ;`
let fork = input.fork();
let mut priority = 0i32;
if fork.peek(Ident) && fork.parse::<Ident>()? == "priority" && fork.peek(Token![=]) {
// commit
let _ = input.parse::<Ident>()?; // priority
let _ = input.parse::<Token![=]>()?;
let lit: LitInt = input.parse()?;
priority = lit.base10_parse()?;
let _ = input.parse::<Token![;]>()?;
}
let items = input.parse::<Items>()?;
Ok(Self { priority, items })
}
}
/// Registers global default configurations for HTTP clients.
///
/// This macro submits a configuration record to the global registry that will
@@ -280,7 +325,7 @@ impl Parse for MaybePrioritized {
/// connect_timeout = std::time::Duration::from_secs(10),
/// pool_max_idle_per_host = 32,
/// default_headers {
/// "User-Agent" => "MyApp/1.0",
/// "User-Agent" => "Nym/1.0",
/// "Accept" => "application/json"
/// }
/// );
@@ -290,99 +335,50 @@ pub fn client_defaults(input: TokenStream) -> TokenStream {
let MaybePrioritized { priority, items } = parse_macro_input!(input as MaybePrioritized);
let core = core_path();
// Generate a description of what this config does (before consuming items)
let config_description = if cfg!(feature = "debug-inventory") {
let descriptions = items
.0
.iter()
.map(|item| match item {
Item::Assign { key, value, .. } => {
format!("{}={:?}", quote!(#key), quote!(#value).to_string())
}
Item::Call { key, args, .. } => {
let args_str = args
.iter()
.map(|a| quote!(#a).to_string())
.collect::<Vec<_>>()
.join(", ");
format!("{}({})", quote!(#key), args_str)
}
Item::Flag { key } => {
format!("{}()", quote!(#key))
}
Item::DefaultHeaders { .. } => "default_headers{{...}}".to_string(),
})
.collect::<Vec<_>>()
.join(", ");
// Deterministic debug description string (used only when debug feature is enabled).
let description = describe_items(&items);
quote! {
pub const __CONFIG_DESC: &str = #descriptions;
}
} else {
quote! {}
};
// Now consume items to generate the body
// Turn the DSL into statements that mutate `b`.
let body = to_stmts(items, &core);
// Generate a unique identifier for this submission
let submission_id = format!("__client_defaults_{}", uuid::Uuid::new_v4().simple());
let submission_ident = syn::Ident::new(&submission_id, proc_macro2::Span::call_site());
// Debug output at compile time if enabled
// Optional compile-time diagnostics for the macro author (does not affect output).
if std::env::var("DEBUG_HTTP_INVENTORY").is_ok() {
eprintln!(
"cargo:warning=[HTTP-INVENTORY] Registering config with priority={} from {}",
"cargo:warning=[HTTP-INVENTORY] Registering config with priority={} from {}: {}",
priority,
std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".to_string())
std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".to_string()),
description,
);
}
// Add debug_print_inventory call if the feature is enabled
let debug_call = if cfg!(feature = "debug-inventory") {
// Debug logging injected into the generated closure, gated by the
// *macro crate's* `debug-inventory` feature (checked at expansion time).
let debug_block = if cfg!(feature = "debug-inventory") {
quote! {
#config_description
// Ensure the debug function gets called when config is applied
pub fn __cfg_with_debug(
b: #core::ReqwestClientBuilder
) -> #core::ReqwestClientBuilder {
eprintln!("[HTTP-INVENTORY] Applying: {} (priority={})", __CONFIG_DESC, #priority);
__cfg(b)
}
eprintln!(
"[HTTP-INVENTORY] Applying: {} (priority={})",
#description,
#priority
);
}
} else {
quote! {}
};
// Use the debug wrapper if feature is enabled
let apply_fn = if cfg!(feature = "debug-inventory") {
quote! { __cfg_with_debug }
} else {
quote! { __cfg }
};
// `apply` is a capture-free closure; it will coerce to a fn pointer
// if `ConfigRecord::apply` is typed as `fn(ReqwestClientBuilder) -> ReqwestClientBuilder`.
let out = quote! {
#[allow(non_snake_case)]
mod #submission_ident {
use super::*;
#[allow(unused)]
pub fn __cfg(
mut b: #core::ReqwestClientBuilder
) -> #core::ReqwestClientBuilder {
#body
b
}
#debug_call
#core::inventory::submit! {
#core::registry::ConfigRecord {
priority: #priority,
apply: #apply_fn,
}
#core::inventory::submit! {
#core::registry::ConfigRecord {
priority: #priority,
apply: |mut b: #core::ReqwestClientBuilder| {
#debug_block
#body
b
},
}
}
};
out.into()
}
+381 -132
View File
@@ -3,28 +3,41 @@
//! DNS resolver configuration for internal lookups.
//!
//! The resolver itself is the set combination of the google, cloudflare, and quad9 endpoints
//! supporting DoH and DoT.
//! The resolver itself is the set combination of the cloudflare, and quad9 endpoints supporting DoH
//! and DoT.
//!
//! This resolver supports a fallback mechanism where, should the DNS-over-TLS resolution fail, a
//! followup resolution will be done using the hosts configured default (e.g. `/etc/resolve.conf` on
//! linux). This is disabled by default and can be enabled using [`enable_system_fallback`].
//!
//! Requires the `dns-over-https-rustls`, `webpki-roots` feature for the
//! `hickory-resolver` crate
//!
//!
//! Note: The hickory DoH resolver can cause warning logs about H2 connection failure. This
//! indicates that the long lived https connection was closed by the remote peer and the resolver
//! will have to reconnect. It should not impact actual functionality.
//!
//! code ref: https://github.com/hickory-dns/hickory-dns/blob/06a8b1ce9bd9322d8e6accf857d30257e1274427/crates/proto/src/h2/h2_client_stream.rs#L534
//!
//! example log:
//!
//! ```txt
//! WARN /home/ubuntu/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hickory-proto-0.24.3/src/h2/h2_client_stream.rs:493: h2 connection failed: unexpected end of file
//! ```rust
//! use nym_http_api_client::HickoryDnsResolver;
//! # use nym_http_api_client::ResolveError;
//! # type Err = ResolveError;
//! # async fn run() -> Result<(), Err> {
//! let resolver = HickoryDnsResolver::default();
//! resolver.resolve_str("example.com").await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Fallbacks
//!
//! **System Resolver --** This resolver supports an optional fallback mechanism where, should the
//! DNS-over-TLS resolution fail, a followup resolution will be done using the hosts configured
//! default (e.g. `/etc/resolve.conf` on linux).
//!
//! This is disabled by default and can be enabled using `enable_system_fallback`.
//!
//! **Static Table --** There is also a second optional fallback mechanism that allows a static map
//! to be used as a last resort. This can help when DNS encounters errors due to blocked resolvers
//! or unknown conditions. This is enabled by default, and can be customized if building a new
//! resolver.
//!
//! ## IPv4 / IPv6
//!
//! By default the resolver uses only IPv4 nameservers, and is configured to do `A` lookups first,
//! and only do `AAAA` if no `A` record is available.
//!
//! ---
//!
//! Requires the `dns-over-https-rustls`, `webpki-roots` feature for the `hickory-resolver` crate
#![deny(missing_docs)]
use crate::ClientBuilder;
@@ -39,7 +52,7 @@ use std::{
use hickory_resolver::{
TokioResolver,
config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig},
config::{NameServerConfig, NameServerConfigGroup, ResolverConfig, ResolverOpts},
lookup_ip::LookupIpIntoIter,
name_server::TokioConnectionProvider,
};
@@ -49,7 +62,11 @@ use tracing::*;
mod constants;
mod static_resolver;
pub use static_resolver::*;
pub(crate) use static_resolver::*;
pub(crate) const DEFAULT_POSITIVE_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(1800);
pub(crate) const DEFAULT_OVERALL_LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
impl ClientBuilder {
/// Override the DNS resolver implementation used by the underlying http client.
@@ -71,7 +88,10 @@ impl ClientBuilder {
// but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional.
static SHARED_RESOLVER: LazyLock<HickoryDnsResolver> = LazyLock::new(|| {
tracing::debug!("Initializing shared DNS resolver");
HickoryDnsResolver::default()
HickoryDnsResolver {
use_shared: false, // prevent infinite recursion
..Default::default()
}
});
#[derive(Debug, thiserror::Error)]
@@ -111,7 +131,7 @@ pub struct HickoryDnsResolver {
state: Arc<OnceCell<TokioResolver>>,
fallback: Option<Arc<OnceCell<TokioResolver>>>,
static_base: Option<Arc<OnceCell<StaticResolver>>>,
dont_use_shared: bool,
use_shared: bool,
/// Overall timeout for dns lookup associated with any individual host resolution. For example,
/// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached.
overall_dns_timeout: Duration,
@@ -122,9 +142,9 @@ impl Default for HickoryDnsResolver {
Self {
state: Default::default(),
fallback: Default::default(),
static_base: Default::default(),
dont_use_shared: Default::default(),
overall_dns_timeout: Duration::from_secs(10),
static_base: Some(Default::default()),
use_shared: true,
overall_dns_timeout: DEFAULT_OVERALL_LOOKUP_TIMEOUT,
}
}
}
@@ -134,7 +154,7 @@ impl Resolve for HickoryDnsResolver {
let resolver = self.state.clone();
let maybe_fallback = self.fallback.clone();
let maybe_static = self.static_base.clone();
let independent = self.dont_use_shared;
let use_shared = self.use_shared;
let overall_dns_timeout = self.overall_dns_timeout;
Box::pin(async move {
resolve(
@@ -142,7 +162,7 @@ impl Resolve for HickoryDnsResolver {
resolver,
maybe_fallback,
maybe_static,
independent,
use_shared,
overall_dns_timeout,
)
.await
@@ -159,7 +179,21 @@ async fn resolve(
independent: bool,
overall_dns_timeout: Duration,
) -> Result<Addrs, ResolveError> {
let resolver = resolver.get_or_try_init(|| HickoryDnsResolver::new_resolver(independent))?;
let resolver = resolver.get_or_init(|| HickoryDnsResolver::new_resolver(independent));
// try checking the static table to see if any of the addresses in the table have been
// looked up previously within the timeout to where we are not yet ready to try the
// default resolver yet again.
if let Some(ref static_resolver) = maybe_static {
let resolver =
static_resolver.get_or_init(|| HickoryDnsResolver::new_static_fallback(independent));
if let Some(addrs) = resolver.pre_resolve(name.as_str()) {
let addrs: Addrs =
Box::new(addrs.into_iter().map(|ip_addr| SocketAddr::new(ip_addr, 0)));
return Ok(addrs);
}
}
// Attempt a lookup using the primary resolver
let resolve_fut = tokio::time::timeout(overall_dns_timeout, resolver.lookup_ip(name.as_str()));
@@ -236,7 +270,7 @@ impl HickoryDnsResolver {
self.state.clone(),
self.fallback.clone(),
self.static_base.clone(),
self.dont_use_shared,
self.use_shared,
self.overall_dns_timeout,
)
.await
@@ -246,25 +280,25 @@ impl HickoryDnsResolver {
/// Create a (lazy-initialized) resolver that is not shared across threads.
pub fn thread_resolver() -> Self {
Self {
dont_use_shared: true,
use_shared: false,
..Default::default()
}
}
fn new_resolver(dont_use_shared: bool) -> Result<TokioResolver, ResolveError> {
fn new_resolver(use_shared: bool) -> TokioResolver {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if dont_use_shared {
new_resolver()
if use_shared {
SHARED_RESOLVER.state.get_or_init(new_resolver).clone()
} else {
Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone())
new_resolver()
}
}
fn new_resolver_system(dont_use_shared: bool) -> Result<TokioResolver, ResolveError> {
fn new_resolver_system(use_shared: bool) -> Result<TokioResolver, ResolveError> {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if dont_use_shared || SHARED_RESOLVER.fallback.is_none() {
if !use_shared || SHARED_RESOLVER.fallback.is_none() {
new_resolver_system()
} else {
Ok(SHARED_RESOLVER
@@ -276,8 +310,8 @@ impl HickoryDnsResolver {
}
}
fn new_static_fallback(dont_use_shared: bool) -> StaticResolver {
if !dont_use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
fn new_static_fallback(use_shared: bool) -> StaticResolver {
if use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
shared_resolver
.get_or_init(new_default_static_fallback)
.clone()
@@ -294,6 +328,11 @@ impl HickoryDnsResolver {
.as_ref()
.unwrap()
.get_or_try_init(new_resolver_system)?;
// IF THIS INSTANCE IS A FRONT FOR THE SHARED RESOLVER SHOULDN'T THIS FN ENABLE THE SYSTEM FALLBACK FOR THE SHARED RESOLVER TOO?
// if self.use_shared {
// SHARED_RESOLVER.enable_system_fallback()?;
// }
Ok(())
}
@@ -301,6 +340,11 @@ impl HickoryDnsResolver {
/// returned immediately
pub fn disable_system_fallback(&mut self) {
self.fallback = None;
// // IF THIS INSTANCE IS A FRONT FOR THE SHARED RESOLVER SHOULDN'T THIS FN ENABLE THE SYSTEM FALLBACK FOR THE SHARED RESOLVER TOO?
// if self.use_shared {
// SHARED_RESOLVER.fallback = None;
// }
}
/// Get the current map of hostname to address in use by the fallback static lookup if one
@@ -316,39 +360,123 @@ impl HickoryDnsResolver {
.expect("infallible assign");
self.static_base = Some(Arc::new(cell));
}
/// Successfully resolved addresses are cached for a minimum of 30 minutes
/// Individual lookup Timeouts are set to 3 seconds
/// Number of retries after lookup failure before giving up is set to (default) to 2
/// Lookup order is set to (default) A then AAAA
/// Number or parallel lookup is set to (default) 2
/// Nameserver selection uses the (default) EWMA statistics / performance based strategy
fn default_options() -> ResolverOpts {
let mut opts = ResolverOpts::default();
// Always cache successful responses for queries received by this resolver for 30 min minimum.
opts.positive_min_ttl = Some(DEFAULT_POSITIVE_LOOKUP_CACHE_TTL);
opts.timeout = DEFAULT_QUERY_TIMEOUT;
opts.attempts = 0;
opts
}
/// Get the list of currently available nameserver configs.
pub fn all_configured_name_servers(&self) -> Vec<NameServerConfig> {
default_nameserver_group().to_vec()
}
/// Get the list of currently used nameserver configs.
pub fn active_name_servers(&self) -> Vec<NameServerConfig> {
if !self.use_shared {
return self
.state
.get()
.map(|r| r.config().name_servers().to_vec())
.unwrap_or(self.all_configured_name_servers());
}
SHARED_RESOLVER.active_name_servers()
}
/// Do a trial resolution using each nameserver individually to test which are working and which
/// fail to complete a lookup. This will always try the full set of default configured resolvers.
pub async fn trial_nameservers(&self) {
let nameservers = default_nameserver_group();
for (ns, result) in trial_nameservers_inner(&nameservers).await {
if let Err(e) = result {
warn!("trial {ns:?} errored: {e}");
} else {
info!("trial {ns:?} succeeded");
}
}
}
}
/// Create a new resolver with a custom DoT based configuration. The options are overridden to look
/// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm.
///
/// Timeout Defaults to 5 seconds
/// Individual lookup Timeouts are set to 3 seconds
/// Number of retries after lookup failure before giving up Defaults to 2
/// Lookup order is set to (default) A then AAAA
///
/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup.
/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs.
fn new_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new configured resolver");
let mut name_servers = NameServerConfigGroup::quad9_tls();
name_servers.merge(NameServerConfigGroup::quad9_https());
name_servers.merge(NameServerConfigGroup::cloudflare_tls());
name_servers.merge(NameServerConfigGroup::cloudflare_https());
fn new_resolver() -> TokioResolver {
let name_servers = default_nameserver_group_ipv4_only();
configure_and_build_resolver(name_servers)
}
fn configure_and_build_resolver(
name_servers: NameServerConfigGroup,
) -> Result<TokioResolver, ResolveError> {
fn configure_and_build_resolver<G>(name_servers: G) -> TokioResolver
where
G: Into<NameServerConfigGroup>,
{
let options = HickoryDnsResolver::default_options();
let name_servers: NameServerConfigGroup = name_servers.into();
info!("building new configured resolver");
debug!("configuring resolver with {options:?}, {name_servers:?}");
let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
let mut resolver_builder =
TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
// Cache successful responses for queries received by this resolver for 30 min minimum.
resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800));
resolver_builder = resolver_builder.with_options(options);
Ok(resolver_builder.build())
resolver_builder.build()
}
fn filter_ipv4(nameservers: impl AsRef<[NameServerConfig]>) -> Vec<NameServerConfig> {
nameservers
.as_ref()
.iter()
.filter(|ns| ns.socket_addr.is_ipv4())
.cloned()
.collect()
}
#[allow(unused)]
fn filter_ipv6(nameservers: impl AsRef<[NameServerConfig]>) -> Vec<NameServerConfig> {
nameservers
.as_ref()
.iter()
.filter(|ns| ns.socket_addr.is_ipv6())
.cloned()
.collect()
}
#[allow(unused)]
fn default_nameserver_group() -> NameServerConfigGroup {
let mut name_servers = NameServerConfigGroup::quad9_tls();
name_servers.merge(NameServerConfigGroup::quad9_https());
name_servers.merge(NameServerConfigGroup::cloudflare_tls());
name_servers.merge(NameServerConfigGroup::cloudflare_https());
name_servers
}
fn default_nameserver_group_ipv4_only() -> NameServerConfigGroup {
filter_ipv4(&default_nameserver_group() as &[NameServerConfig]).into()
}
#[allow(unused)]
fn default_nameserver_group_ipv6_only() -> NameServerConfigGroup {
filter_ipv6(&default_nameserver_group() as &[NameServerConfig]).into()
}
/// Create a new resolver with the default configuration, which reads from the system DNS config
@@ -356,7 +484,12 @@ fn configure_and_build_resolver(
/// addresses to work with "happy eyeballs" algorithm.
fn new_resolver_system() -> Result<TokioResolver, ResolveError> {
let mut resolver_builder = TokioResolver::builder_tokio()?;
resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
let options = HickoryDnsResolver::default_options();
info!("building new fallback system resolver");
debug!("fallback system resolver with {options:?}");
resolver_builder = resolver_builder.with_options(options);
Ok(resolver_builder.build())
}
@@ -365,11 +498,54 @@ fn new_default_static_fallback() -> StaticResolver {
StaticResolver::new(constants::default_static_addrs())
}
/// Do a trial resolution using each nameserver individually to test which are working and which
/// fail to complete a lookup.
async fn trial_nameservers_inner(
name_servers: &[NameServerConfig],
) -> Vec<(NameServerConfig, Result<(), ResolveError>)> {
let mut trial_lookups = tokio::task::JoinSet::new();
for name_server in name_servers {
let ns = name_server.clone();
trial_lookups.spawn(async { (ns.clone(), trial_lookup(ns, "example.com").await) });
}
trial_lookups.join_all().await
}
/// Create an independent resolver that has only the provided nameserver and do one lookup for the
/// provided query target.
async fn trial_lookup(name_server: NameServerConfig, query: &str) -> Result<(), ResolveError> {
debug!("running ns trial {name_server:?} query={query}");
let resolver = configure_and_build_resolver(vec![name_server]);
match tokio::time::timeout(DEFAULT_OVERALL_LOOKUP_TIMEOUT, resolver.ipv4_lookup(query)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(ResolveError::Timeout),
}
}
#[cfg(test)]
mod test {
use super::*;
use itertools::Itertools;
use std::collections::HashMap;
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
time::Instant,
};
/// IP addresses guaranteed to fail attempts to resolve
///
/// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
];
#[tokio::test]
async fn reqwest_with_custom_dns() {
@@ -428,99 +604,172 @@ mod test {
assert!(addrs.contains(&example_ip6));
Ok(())
}
}
#[cfg(test)]
mod failure_test {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
// Test the nameserver trial functionality with mostly nameservers guaranteed to be broken and
// one that should work.
#[tokio::test]
async fn trial_nameservers() {
let good_cf_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
/// IP addresses guaranteed to fail attempts to resolve
///
/// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
];
let mut ns_ips = GUARANTEED_BROKEN_IPS_1.to_vec();
ns_ips.push(good_cf_ip);
// Create a resolver that behaves the same as the custom configured router, except for the fact
// that it is guaranteed to fail.
fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new faulty resolver");
let mut broken_ns_group = NameServerConfigGroup::from_ips_tls(
GUARANTEED_BROKEN_IPS_1,
853,
"cloudflare-dns.com".to_string(),
true,
);
let broken_ns_https = NameServerConfigGroup::from_ips_https(
GUARANTEED_BROKEN_IPS_1,
&ns_ips,
443,
"cloudflare-dns.com".to_string(),
true,
);
broken_ns_group.merge(broken_ns_https);
configure_and_build_resolver(broken_ns_group)
}
#[tokio::test]
async fn dns_lookup_failures() -> Result<(), ResolveError> {
let time_start = std::time::Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
let inner = configure_and_build_resolver(broken_ns_https);
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
dont_use_shared: true,
state: Arc::new(r),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
let domain = "ifconfig.me";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
let duration = time_start.elapsed();
assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
Ok(())
}
#[tokio::test]
async fn fallback_to_static() -> Result<(), ResolveError> {
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
dont_use_shared: true,
state: Arc::new(r),
use_shared: false,
state: Arc::new(OnceCell::with_value(inner)),
static_base: Some(Default::default()),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
// successful lookup using fallback to static resolver
let domain = "nymvpn.com";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
let name_servers = resolver.state.get().unwrap().config().name_servers();
for (ns, result) in trial_nameservers_inner(name_servers).await {
if ns.socket_addr.ip() == good_cf_ip {
assert!(result.is_ok())
} else {
assert!(result.is_err())
}
}
}
// unsuccessful lookup - primary times out, and not in
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
mod failure_test {
use super::*;
Ok(())
// Create a resolver that behaves the same as the custom configured router, except for the fact
// that it is guaranteed to fail.
fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new faulty resolver");
let mut broken_ns_group = NameServerConfigGroup::from_ips_tls(
GUARANTEED_BROKEN_IPS_1,
853,
"cloudflare-dns.com".to_string(),
true,
);
let broken_ns_https = NameServerConfigGroup::from_ips_https(
GUARANTEED_BROKEN_IPS_1,
443,
"cloudflare-dns.com".to_string(),
true,
);
broken_ns_group.merge(broken_ns_https);
Ok(configure_and_build_resolver(broken_ns_group))
}
#[tokio::test]
async fn dns_lookup_failures() -> Result<(), ResolveError> {
let time_start = std::time::Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
use_shared: false,
state: Arc::new(r),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
let domain = "ifconfig.me";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
let duration = time_start.elapsed();
assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
Ok(())
}
#[tokio::test]
async fn fallback_to_static() -> Result<(), ResolveError> {
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
use_shared: false,
state: Arc::new(r),
static_base: Some(Default::default()),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
// successful lookup using fallback to static resolver
let domain = "nymvpn.com";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
// unsuccessful lookup - primary times out, and not in static table
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
Ok(())
}
#[test]
fn default_resolver_uses_ipv4_only_nameservers() {
let resolver = HickoryDnsResolver::thread_resolver();
resolver
.active_name_servers()
.iter()
.all(|cfg| cfg.socket_addr.is_ipv4());
SHARED_RESOLVER
.active_name_servers()
.iter()
.all(|cfg| cfg.socket_addr.is_ipv4());
}
#[tokio::test]
#[ignore]
// this test is dependent of external network setup -- i.e. blocking all traffic to the default
// resolvers. Otherwise the default resolvers will succeed without using the static fallback,
// making the test pointless
async fn dns_lookup_failure_on_shared() -> Result<(), ResolveError> {
let time_start = Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver::default();
// successful lookup using fallback to static resolver
let domain = "rpc.nymtech.net";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
println!(
"{}ms resolved {domain}",
(Instant::now() - time_start).as_millis()
);
// unsuccessful lookup - primary times out, and not in static table
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err());
// assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
// assert!(result.is_err_and(|e| matches!(e, ResolveError::ResolveError(e) if e.is_nx_domain())));
Ok(())
}
}
}
@@ -51,6 +51,9 @@ pub const VERCEL_COM_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(198, 169, 1, 193)),
];
pub const NYM_API_CDN: &str = "cdn1.media-platform.net";
pub const NYM_API_CDN_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(172, 104, 178, 252))];
pub const NYM_COM_DOMAIN: &str = "nym.com";
pub const NYM_COM_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 22))];
@@ -88,6 +91,7 @@ pub fn default_static_addrs() -> HashMap<String, Vec<IpAddr>> {
m.insert(YELP_FASTLY_DOMAIN.to_string(), YELP_FASTLY_IPS.to_vec());
m.insert(VERCEL_APP_DOMAIN.to_string(), VERCEL_APP_IPS.to_vec());
m.insert(VERCEL_COM_DOMAIN.to_string(), VERCEL_COM_IPS.to_vec());
m.insert(NYM_API_CDN.to_string(), NYM_API_CDN_IPS.to_vec());
m.insert(NYM_COM_DOMAIN.to_string(), NYM_COM_IPS.to_vec());
m.insert(NYM_STATS_API_DOMAIN.to_string(), NYM_STATS_API_IPS.to_vec());
m.insert(NYM_RPC_DOMAIN.to_string(), NYM_RPC_IPS.to_vec());
@@ -3,27 +3,114 @@ use crate::dns::ResolveError;
use std::{
collections::HashMap,
net::{IpAddr, SocketAddr},
sync::{Arc, Mutex},
sync::{Arc, Mutex, MutexGuard},
time::{Duration, Instant},
};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use tracing::*;
const DEFAULT_PRE_RESOLVE_TIMEOUT: Duration = super::DEFAULT_POSITIVE_LOOKUP_CACHE_TTL;
#[derive(Debug, Default, Clone)]
pub struct StaticResolver {
static_addr_map: Arc<Mutex<HashMap<String, Vec<IpAddr>>>>,
static_addr_map: Arc<Mutex<HashMap<String, Entry>>>,
pre_resolve_timeout: Option<Duration>,
}
#[derive(Debug, Clone, Default)]
struct Entry {
valid_for_pre_resolve_until: Option<Instant>,
addrs: Vec<IpAddr>,
}
impl Entry {
fn new(addrs: Vec<IpAddr>) -> Self {
Self {
valid_for_pre_resolve_until: None,
addrs,
}
}
}
impl StaticResolver {
pub fn new(static_entries: HashMap<String, Vec<IpAddr>>) -> StaticResolver {
debug!("building static resolver");
let static_entries = static_entries
.into_iter()
.map(|(name, ips)| (name, Entry::new(ips)))
.collect();
Self {
static_addr_map: Arc::new(Mutex::new(static_entries)),
pre_resolve_timeout: Some(DEFAULT_PRE_RESOLVE_TIMEOUT),
}
}
/// Return the full set of domain names and associated addresses stored in this static lookup table
pub fn get_addrs(&self) -> HashMap<String, Vec<IpAddr>> {
self.static_addr_map.lock().unwrap().clone()
let mut out = HashMap::new();
self.static_addr_map
.lock()
.unwrap()
.iter()
.for_each(|(name, entry)| {
out.insert(name.clone(), entry.addrs.clone());
});
out
}
/// Change the timeout for which domains can be pre-resolved after they are looked up in the
/// static lookup table.
#[allow(unused)]
pub fn with_pre_resolve_timeout(mut self, timeout: Duration) -> Self {
self.pre_resolve_timeout = Some(timeout);
self
}
/// Try looking up the domain in the static table. If the domain is in the table AND we have
/// recently (within the configured timeout) looked it up previously in this static table using
/// a regular resolve.
pub fn pre_resolve(&self, name: &str) -> Option<Vec<IpAddr>> {
debug!("found {name:?} in pre-resolve static table resolver");
self.pre_resolve_timeout?;
self.static_addr_map
.lock()
.unwrap()
.get(name)
.filter(|e| {
e.valid_for_pre_resolve_until
.is_some_and(|t| t > Instant::now())
})
.map(|e| e.addrs.clone())
}
#[allow(unused)]
pub fn resolve_str(&self, name: &str) -> Option<Vec<IpAddr>> {
Self::resolve_inner(
self.static_addr_map.lock().unwrap(),
name,
self.pre_resolve_timeout,
)
.map(|e| e.addrs)
}
fn resolve_inner(
mut table: MutexGuard<'_, HashMap<String, Entry>>,
name: &str,
timeout: Option<Duration>,
) -> Option<Entry> {
let resolved = table.get_mut(name)?;
debug!("found {name:?} in static table resolver");
if let Some(pre_resolve_timeout) = timeout {
// We had to look this entry up and a pre-resolve duration is defined, so it will
// trigger in pre-resolve lookups for the next _timeout_ window.
resolved.valid_for_pre_resolve_until = Some(Instant::now() + pre_resolve_timeout);
}
Some(resolved.clone())
}
}
@@ -31,15 +118,15 @@ impl Resolve for StaticResolver {
fn resolve(&self, name: Name) -> Resolving {
debug!("looking up {name:?} in static resolver");
let addr_map = self.static_addr_map.clone();
let timeout = self.pre_resolve_timeout;
Box::pin(async move {
let addr_map = addr_map.lock().unwrap();
let lookup = match addr_map.get(name.as_str()) {
let lookup = match Self::resolve_inner(addr_map, name.as_str(), timeout) {
None => return Err(ResolveError::StaticLookupMiss.into()),
Some(addrs) => addrs,
Some(entry) => entry.addrs,
};
let addrs: Addrs = Box::new(
lookup
.clone()
.into_iter()
.map(|ip_addr| SocketAddr::new(ip_addr, 0)),
);
@@ -86,4 +173,45 @@ mod test {
Ok(())
}
#[test]
fn static_lookup_pre_resolve() {
let example_duration = Duration::from_secs(3);
let example_domain = String::from("static.nymvpn.com");
let mut addr_map = HashMap::new();
let example_ip4: IpAddr = "10.10.10.10".parse().unwrap();
let example_ip6: IpAddr = "dead::beef".parse().unwrap();
addr_map.insert(example_domain.clone(), vec![example_ip4, example_ip6]);
let resolver = StaticResolver::new(addr_map).with_pre_resolve_timeout(example_duration);
// ensure that attempting to pre-resolve without first resolving returns none
let result = resolver.pre_resolve(&example_domain);
assert!(result.is_none());
// resolving should now update the pre-resolve validity timeout for the entry
let entry = StaticResolver::resolve_inner(
resolver.static_addr_map.lock().unwrap(),
&example_domain,
Some(example_duration),
)
.expect("missing entry???!!!!");
assert!(
entry
.valid_for_pre_resolve_until
.is_some_and(|t| t < Instant::now() + example_duration)
);
// check that pre-resolve now returns the expected record
let addrs = resolver
.pre_resolve(&example_domain)
.expect("entry should be in pre-resolve now");
assert!(addrs.contains(&example_ip4));
std::thread::sleep(example_duration);
// check that after the timeout duration the pre-resolve no longer returns the address
let result = resolver.pre_resolve(&example_domain);
assert!(result.is_none());
}
}
+16 -31
View File
@@ -92,7 +92,7 @@
//! pub status: ApiStatus,
//! pub uptime: u64,
//! }
//!
//!
//! #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
//! pub enum ApiStatus {
//! Up,
@@ -175,7 +175,7 @@ mod user_agent;
pub use user_agent::UserAgent;
#[cfg(not(target_arch = "wasm32"))]
mod dns;
pub mod dns;
mod path;
#[cfg(not(target_arch = "wasm32"))]
@@ -895,20 +895,22 @@ impl Client {
self.retry_limit = limit;
}
#[cfg(feature = "tunneling")]
fn matches_current_host(&self, url: &Url) -> bool {
if cfg!(feature = "tunneling") {
if let Some(ref front) = self.front
&& front.is_enabled()
{
url.host_str() == self.current_url().front_str()
} else {
url.host_str() == self.current_url().host_str()
}
if let Some(ref front) = self.front
&& front.is_enabled()
{
url.host_str() == self.current_url().front_str()
} else {
url.host_str() == self.current_url().host_str()
}
}
#[cfg(not(feature = "tunneling"))]
fn matches_current_host(&self, url: &Url) -> bool {
url.host_str() == self.current_url().host_str()
}
/// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
///
/// Takes an optional URL argument. If this is none, the current host will be updated automatically.
@@ -960,6 +962,10 @@ impl Client {
}
self.current_idx.store(next, Ordering::Relaxed);
debug!(
"http client rotating host {} -> {}",
self.base_urls[orig], self.base_urls[next]
);
}
}
@@ -1088,27 +1094,6 @@ impl ApiClientCore for Client {
self.apply_hosts_to_req(&mut req);
let url: Url = req.url().clone().into();
// try an explicit DNS resolution - if successful then it will be in cache when reqwest
// goes to execute the request. If failure then we get to handle the DNS lookup error.
#[cfg(not(target_arch = "wasm32"))]
if self.using_secure_dns
&& let Some(hostname) = req.url().domain()
// Default here will use a shared resolver instance
&& let Err(err) = HickoryDnsResolver::default().resolve_str(hostname).await
{
// on failure update host, but don't trigger fronting enable.
self.update_host(Some(url.clone()));
if attempts < self.retry_limit {
attempts += 1;
warn!(
"Retrying request due to dns error on attempt ({attempts}/{}): {err}",
self.retry_limit
);
continue;
}
}
#[cfg(target_arch = "wasm32")]
let response: Result<Response, HttpClientError> = {
Ok(wasmtimer::tokio::timeout(
+3 -2
View File
@@ -89,13 +89,14 @@ fn sanitizing_urls() {
// - on error without retries is where we have multiple urls, is the url updated?
#[tokio::test]
#[ignore] // test relies on external services being available and behaving in a specific way.
async fn api_client_retry() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new_with_urls(vec![
"http://broken.nym.test".parse()?, // This will fail because of DNS (rotate)
"http://broken.nym.test".parse()?, // This should fail because of DNS NXDomain (rotate)
"http://127.0.0.1:9".parse()?, // This will fail because of TCP refused (rotate)
"https://httpbin.org/status/200".parse()?, // This should succeed
])?
.with_retries(3)
.with_retries(2)
.build()?;
let req = client.create_get_request(&[], NO_PARAMS).unwrap();
+4
View File
@@ -51,6 +51,10 @@ pub const NYM_APIS: &[ApiUrlConst] = &[
url: "https://nym-frontdoor.global.ssl.fastly.net/api/",
front_hosts: Some(&["yelp.global.ssl.fastly.net"]),
},
ApiUrlConst {
url: "https://cdn1.media-platform.net/api/",
front_hosts: None,
},
];
pub const NYM_VPN_API: &str = "https://nymvpn.com/api/";
+1
View File
@@ -13,6 +13,7 @@ workspace = true
[dependencies]
tokio-util.workspace = true
serde.workspace = true
nym-authenticator-requests = { path = "../authenticator-requests" }
nym-crypto = { path = "../crypto" }
+4 -3
View File
@@ -1,10 +1,11 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use nym_authenticator_requests::AuthenticatorVersion;
use nym_crypto::asymmetric::x25519::PublicKey;
use nym_crypto::asymmetric::x25519::{PublicKey, serde_helpers::bs58_x25519_pubkey};
use nym_ip_packet_requests::IpPair;
use nym_sphinx::addressing::{NodeIdentity, Recipient};
@@ -16,9 +17,9 @@ pub struct NymNode {
pub authenticator_address: Option<Recipient>,
pub version: AuthenticatorVersion,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GatewayData {
#[serde(with = "bs58_x25519_pubkey")]
pub public_key: PublicKey,
pub endpoint: SocketAddr,
pub private_ipv4: Ipv4Addr,
@@ -6,6 +6,7 @@ use time::Date;
const BASIC_REPORT_KIND: &str = "vpn_client_stats_report";
const SESSION_REPORT_KIND: &str = "vpn_client_session_report";
const ACTIVE_DEVICE_REPORT_KIND: &str = "vpn_client_active_device";
const VERSION_1: &str = "v1";
const VERSION_2: &str = "v2";
@@ -64,6 +65,27 @@ impl VpnClientStatsReportV2 {
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveDeviceReport {
pub kind: String,
pub api_version: String,
pub stats_id: String,
pub static_information: StaticInformationReport,
}
impl ActiveDeviceReport {
pub fn new(stats_id: String, static_information: StaticInformationReport) -> Self {
ActiveDeviceReport {
kind: ACTIVE_DEVICE_REPORT_KIND.into(),
api_version: VERSION_2.into(),
stats_id,
static_information,
}
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticInformationReport {
@@ -90,6 +112,7 @@ pub struct SessionReport {
pub session_duration_min: i32,
pub disconnection_time_ms: i32,
pub exit_id: String,
pub exit_cc: Option<String>,
pub follow_up_id: Option<String>,
pub error: Option<String>,
}
+39
View File
@@ -89,6 +89,45 @@ impl RoutingNode {
self.ws_entry_address_no_tls(prefer_ipv6)
}
pub fn ws_entry_address_with_fallback(
&self,
prefer_ipv6: bool,
no_hostname: bool,
) -> (Option<String>, Option<String>) {
let Some(entry) = &self.entry else {
return (None, None);
};
// Put hostname first if we want it
let maybe_hostname = if !no_hostname {
entry.hostname.clone()
} else {
None
};
// Put ipv6 first or keep them as is
let ips: Vec<&IpAddr> = if prefer_ipv6 {
entry
.ip_addresses
.iter()
.filter(|ip| ip.is_ipv6())
.chain(entry.ip_addresses.iter().filter(|ip| ip.is_ipv4()))
.collect()
} else {
entry.ip_addresses.iter().collect()
};
// chain everything and keep the top two as ws addresses
let ws_addresses: Vec<_> = maybe_hostname
.into_iter()
.chain(ips.into_iter().map(|ip| ip.to_string()))
.take(2)
.map(|host| format!("ws://{host}:{}", entry.clients_ws_port))
.collect();
(ws_addresses.first().cloned(), ws_addresses.get(1).cloned())
}
pub fn identity(&self) -> ed25519::PublicKey {
self.identity_key
}
@@ -97,6 +97,7 @@ pub async fn attempt_retrieve_attestation(
let attestation = reqwest::ClientBuilder::new()
.user_agent(user_agent.unwrap_or_else(|| nym_http_api_client::generate_user_agent!()))
.timeout(std::time::Duration::from_secs(5))
.no_hickory_dns()
.build()
.map_err(retrieval_failure)?
.get(url)
+2 -1
View File
@@ -139,7 +139,7 @@ pub async fn setup_gateway_wasm(
GatewaySetup::MustLoad { gateway_id: None }
} else {
let selection_spec =
GatewaySelectionSpecification::new(chosen_gateway.clone(), None, force_tls);
GatewaySelectionSpecification::new(chosen_gateway.clone(), None, force_tls, false);
GatewaySetup::New {
specification: selection_spec,
@@ -218,6 +218,7 @@ pub async fn add_gateway(
preferred_gateway.clone(),
latency_based_selection,
force_tls,
false,
);
let preferred_gateway = preferred_gateway
@@ -8,15 +8,16 @@ use crate::storage::wasm_client_traits::WasmClientStorage;
use crate::storage::ClientStorage;
use async_trait::async_trait;
use nym_client_core::client::base_client::storage::{
gateways_storage::{ActiveGateway, GatewayRegistration, GatewaysDetailsStore},
gateways_storage::{
ActiveGateway, GatewayPublishedData, GatewayRegistration, GatewaysDetailsStore,
},
MixnetClientStorage,
};
use nym_client_core::client::key_manager::persistence::KeyStore;
use nym_client_core::client::key_manager::ClientKeys;
use nym_client_core::client::replies::reply_storage::browser_backend;
use nym_credential_storage::ephemeral_storage::EphemeralStorage as EphemeralCredentialStorage;
use nym_crypto::asymmetric::ed25519::PublicKey;
use nym_gateway_client::SharedSymmetricKey;
use nym_crypto::asymmetric::ed25519;
use wasm_utils::console_log;
// temporary until other variants are properly implemented (probably it should get changed into `ClientStorage`
@@ -156,17 +157,18 @@ impl GatewaysDetailsStore for ClientStorage {
self.store_registered_gateway(&raw_registration).await
}
async fn upgrade_stored_remote_gateway_key(
async fn update_gateway_published_data(
&self,
gateway_id: PublicKey,
updated_key: &SharedSymmetricKey,
gateway_id: &ed25519::PublicKey,
published_data: &GatewayPublishedData,
) -> Result<(), Self::StorageError> {
self.update_remote_gateway_key(
&gateway_id.to_base58_string(),
None,
Some(updated_key.as_bytes()),
)
.await
// Get gateway and update it
let mut gateway = self
.must_get_registered_gateway(&gateway_id.to_base58_string())
.await?;
gateway.published_data = published_data.into();
// Store it again, key didn't change
self.store_gateway_details(&gateway.try_into()?).await
}
async fn remove_gateway_details(&self, gateway_id: &str) -> Result<(), Self::StorageError> {
+31 -24
View File
@@ -2,11 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
use nym_client_core::client::base_client::storage::gateways_storage::{
BadGateway, GatewayDetails, GatewayRegistration, RawRemoteGatewayDetails, RemoteGatewayDetails,
BadGateway, GatewayDetails, GatewayPublishedData, GatewayRegistration, RawGatewayPublishedData,
RawRemoteGatewayDetails, RemoteGatewayDetails,
};
use nym_gateway_client::SharedGatewayKey;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use time::OffsetDateTime;
use zeroize::Zeroize;
@@ -18,14 +17,11 @@ pub struct WasmRawRegisteredGateway {
#[zeroize(skip)]
pub registration_timestamp: OffsetDateTime,
pub derived_aes128_ctr_blake3_hmac_keys_bs58: Option<String>,
#[serde(default)]
pub derived_aes256_gcm_siv_key: Option<Vec<u8>>,
pub derived_aes256_gcm_siv_key: Vec<u8>,
pub gateway_owner_address: Option<String>,
pub gateway_listener: String,
#[zeroize(skip)]
pub published_data: WasmRawGatewayPublishedData,
}
impl TryFrom<WasmRawRegisteredGateway> for GatewayRegistration {
@@ -35,11 +31,12 @@ impl TryFrom<WasmRawRegisteredGateway> for GatewayRegistration {
// offload some parsing to an existing impl
let raw_remote = RawRemoteGatewayDetails {
gateway_id_bs58: value.gateway_id_bs58,
derived_aes128_ctr_blake3_hmac_keys_bs58: value
.derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key: value.derived_aes256_gcm_siv_key,
gateway_owner_address: value.gateway_owner_address,
gateway_listener: value.gateway_listener,
published_data: RawGatewayPublishedData {
gateway_listener: value.published_data.gateway_listener,
fallback_listener: value.published_data.fallback_listener,
expiration_timestamp: value.published_data.expiration_timestamp,
},
};
let remote: RemoteGatewayDetails = raw_remote.try_into()?;
@@ -56,22 +53,32 @@ impl<'a> From<&'a GatewayRegistration> for WasmRawRegisteredGateway {
panic!("somehow obtained custom gateway registration in wasm!")
};
let (derived_aes128_ctr_blake3_hmac_keys_bs58, derived_aes256_gcm_siv_key) =
match remote_details.shared_key.deref() {
SharedGatewayKey::Current(key) => (None, Some(key.to_bytes())),
SharedGatewayKey::Legacy(key) => (Some(key.to_base58_string()), None),
};
let derived_aes256_gcm_siv_key = remote_details.shared_key.to_bytes().to_vec();
WasmRawRegisteredGateway {
gateway_id_bs58: remote_details.gateway_id.to_string(),
registration_timestamp: value.registration_timestamp,
derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key,
gateway_listener: remote_details.gateway_listener.to_string(),
gateway_owner_address: remote_details
.gateway_owner_address
.as_ref()
.map(|a| a.to_string()),
published_data: (&remote_details.published_data).into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmRawGatewayPublishedData {
pub gateway_listener: String,
pub fallback_listener: Option<String>,
pub expiration_timestamp: OffsetDateTime,
}
impl<'a> From<&'a GatewayPublishedData> for WasmRawGatewayPublishedData {
fn from(value: &'a GatewayPublishedData) -> Self {
WasmRawGatewayPublishedData {
gateway_listener: value.listeners.primary.to_string(),
fallback_listener: value.listeners.fallback.as_ref().map(|uri| uri.to_string()),
expiration_timestamp: value.expiration_timestamp,
}
}
}
@@ -10,7 +10,6 @@ use std::error::Error;
use thiserror::Error;
use wasm_bindgen::JsValue;
use wasm_storage::traits::BaseWasmStorage;
use zeroize::Zeroize;
// v1 tables
pub(crate) mod v1 {
@@ -238,23 +237,6 @@ pub trait WasmClientStorage: BaseWasmStorage {
.map_err(Into::into)
}
async fn update_remote_gateway_key(
&self,
gateway_id_bs58: &str,
derived_aes128_ctr_blake3_hmac_keys_bs58: Option<&str>,
derived_aes256_gcm_siv_key: Option<&[u8]>,
) -> Result<(), <Self as WasmClientStorage>::StorageError> {
if let Some(mut current) = self.maybe_get_registered_gateway(gateway_id_bs58).await? {
current.derived_aes128_ctr_blake3_hmac_keys_bs58 =
derived_aes128_ctr_blake3_hmac_keys_bs58.map(|k| k.to_string());
current.derived_aes256_gcm_siv_key = derived_aes256_gcm_siv_key.map(|k| k.to_vec());
self.store_registered_gateway(&current).await?;
current.zeroize();
}
Ok(())
}
async fn remove_registered_gateway(
&self,
gateway_id: &str,
@@ -27,7 +27,6 @@ use nym_gateway_requests::{
};
use nym_gateway_storage::error::GatewayStorageError;
use nym_gateway_storage::traits::BandwidthGatewayStorage;
use nym_gateway_storage::traits::SharedKeyGatewayStorage;
use nym_node_metrics::events::MetricsEvent;
use nym_sphinx::forwarding::packet::MixPacket;
use nym_statistics_common::{gateways::GatewaySessionEvent, types::SessionType};
@@ -65,7 +64,7 @@ pub enum RequestHandlingError {
#[error("failed to decrypt provided text request")]
InvalidEncryptedTextRequest,
#[error("Provided binary request was malformed - {0}")]
#[error("Provided text request was malformed - {0}")]
InvalidTextRequest(<ClientControlRequest as TryFrom<String>>::Error),
#[error("The received request is not valid in the current context: {additional_context}")]
@@ -410,35 +409,6 @@ impl<R, S> AuthenticatedHandler<R, S> {
Ok(SensitiveServerResponse::RememberMeAck {}.encrypt(&self.client.shared_keys)?)
}
async fn handle_key_upgrade(
&mut self,
hkdf_salt: Vec<u8>,
client_key_digest: Vec<u8>,
) -> Result<ServerResponse, RequestHandlingError> {
if !self.client.shared_keys.is_legacy() {
return Ok(ServerResponse::new_error(
"the connection is already using an aes256-gcm-siv key",
));
}
let legacy_key = self.client.shared_keys.unwrap_legacy();
let Some(upgraded_key) = legacy_key.upgrade_verify(&hkdf_salt, &client_key_digest) else {
return Ok(ServerResponse::new_error(
"failed to derive matching aes256-gcm-siv key",
));
};
let updated_key = upgraded_key.into();
self.inner
.shared_state
.storage
.insert_shared_keys(self.client.address, &updated_key)
.await?;
// swap the in-memory key
self.client.shared_keys = updated_key;
Ok(SensitiveServerResponse::KeyUpgradeAck {}.encrypt(&self.client.shared_keys)?)
}
async fn handle_encrypted_text_request(
&mut self,
ciphertext: Vec<u8>,
@@ -449,10 +419,6 @@ impl<R, S> AuthenticatedHandler<R, S> {
};
match req {
ClientRequest::UpgradeKey {
hkdf_salt,
derived_key_digest,
} => self.handle_key_upgrade(hkdf_salt, derived_key_digest).await,
ClientRequest::ForgetMe { client, stats } => self.handle_forget_me(client, stats).await,
ClientRequest::RememberMe { session_type } => {
self.handle_remember_me(session_type).await
@@ -489,9 +455,10 @@ impl<R, S> AuthenticatedHandler<R, S> {
ClientControlRequest::EncryptedRequest { ciphertext, nonce } => {
self.handle_encrypted_text_request(ciphertext, nonce).await
}
ClientControlRequest::EcashCredential { enc_credential, iv } => {
self.handle_ecash_bandwidth(enc_credential, iv).await
}
ClientControlRequest::EcashCredential {
enc_credential,
nonce,
} => self.handle_ecash_bandwidth(enc_credential, nonce).await,
ClientControlRequest::UpgradeModeJWT { token } => {
self.handle_upgrade_mode_jwt(token).await
}
@@ -17,15 +17,12 @@ use nym_credentials_interface::{AvailableBandwidth, DEFAULT_MIXNET_REQUEST_BANDW
use nym_crypto::aes::cipher::crypto_common::rand_core::RngCore;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::authenticate::AuthenticateRequest;
use nym_gateway_requests::authentication::encrypted_address::{
EncryptedAddressBytes, EncryptedAddressConversionError,
};
use nym_gateway_requests::registration::handshake::HandshakeResult;
use nym_gateway_requests::{
registration::handshake::{error::HandshakeError, gateway_handshake},
types::{ClientControlRequest, ServerResponse},
AuthenticationFailure, BinaryResponse, GatewayProtocolVersion, GatewayProtocolVersionExt,
SharedGatewayKey, CURRENT_PROTOCOL_VERSION,
SharedSymmetricKey, CURRENT_PROTOCOL_VERSION,
};
use nym_gateway_storage::error::GatewayStorageError;
use nym_gateway_storage::traits::BandwidthGatewayStorage;
@@ -50,6 +47,9 @@ pub(crate) enum InitialAuthenticationError {
#[error(transparent)]
AuthenticationFailure(#[from] AuthenticationFailure),
#[error("the legacy authentication method is no longer supported. please update your client")]
UnsupportedLegacyAuthentication,
#[error("attempted to overwrite client session with a stale authentication")]
StaleSessionOverwrite,
@@ -68,19 +68,9 @@ pub(crate) enum InitialAuthenticationError {
#[error("Failed to perform registration handshake: {0}")]
HandshakeError(#[from] HandshakeError),
#[error("Provided client address is malformed: {0}")]
// sphinx error is not used here directly as its messaging might be confusing to people
MalformedClientAddress(String),
#[error("Provided encrypted client address is malformed: {0}")]
MalformedEncryptedAddress(#[from] EncryptedAddressConversionError),
#[error("There is already an open connection to this client")]
DuplicateConnection,
#[error("provided authentication IV is malformed: {0}")]
MalformedIV(bs58::decode::Error),
#[error("Only 'Register' or 'Authenticate' requests are allowed")]
InvalidRequest,
@@ -90,6 +80,9 @@ pub(crate) enum InitialAuthenticationError {
#[error("Experienced connection error: {0}")]
ConnectionError(Box<WsError>),
#[error("Attempted to negotiate connection with client using incompatible protocol version. Ours is {current} and the client reports {client}")]
IncompatibleProtocol { client: u8, current: u8 },
#[error("failed to send authentication response: {source}")]
ResponseSendFailure {
#[source]
@@ -192,7 +185,7 @@ impl<R, S> FreshHandler<R, S> {
async fn perform_registration_handshake(
&mut self,
init_msg: Vec<u8>,
requested_protocol: Option<GatewayProtocolVersion>,
requested_protocol: GatewayProtocolVersion,
) -> Result<HandshakeResult, HandshakeError>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
@@ -279,7 +272,7 @@ impl<R, S> FreshHandler<R, S> {
#[allow(clippy::panic)]
pub(crate) async fn push_packets_to_client(
&mut self,
shared_keys: &SharedGatewayKey,
shared_keys: &SharedSymmetricKey,
packets: Vec<Vec<u8>>,
) -> Result<(), WsError>
where
@@ -337,7 +330,7 @@ impl<R, S> FreshHandler<R, S> {
async fn push_stored_messages_to_client(
&mut self,
client_address: DestinationAddressBytes,
shared_keys: &SharedGatewayKey,
shared_keys: &SharedSymmetricKey,
) -> Result<(), InitialAuthenticationError>
where
S: AsyncRead + AsyncWrite + Unpin,
@@ -391,34 +384,6 @@ impl<R, S> FreshHandler<R, S> {
Ok(Some(keys))
}
/// Checks whether the stored shared keys match the received data, i.e. whether the upon decryption
/// the provided encrypted address matches the expected unencrypted address.
///
/// Returns the retrieved shared keys if the check was successful.
///
/// # Arguments
///
/// * `client_address`: address of the client.
/// * `encrypted_address`: encrypted address of the client, presumably encrypted using the shared keys.
/// * `iv`: nonce/iv created for this particular encryption.
async fn auth_v1_verify_stored_shared_key(
&self,
client_address: DestinationAddressBytes,
encrypted_address: EncryptedAddressBytes,
nonce: &[u8],
) -> Result<Option<KeyWithAuthTimestamp>, InitialAuthenticationError> {
let Some(keys) = self.retrieve_shared_key(client_address).await? else {
return Ok(None);
};
// LEGACY ISSUE: we're not verifying HMAC key
if encrypted_address.verify(&client_address, &keys.key, nonce) {
Ok(Some(keys))
} else {
Ok(None)
}
}
async fn handle_duplicate_client(
&mut self,
address: DestinationAddressBytes,
@@ -508,24 +473,31 @@ impl<R, S> FreshHandler<R, S> {
fn negotiate_proposed_protocol(
&self,
client_protocol_version: Option<GatewayProtocolVersion>,
) -> Option<GatewayProtocolVersion> {
client_protocol_version: GatewayProtocolVersion,
) -> Result<GatewayProtocolVersion, InitialAuthenticationError> {
let incompatible_err = InitialAuthenticationError::IncompatibleProtocol {
client: client_protocol_version,
current: CURRENT_PROTOCOL_VERSION,
};
debug!("client protocol: {client_protocol_version}, ours: {CURRENT_PROTOCOL_VERSION}");
// gateway will reject any requests from clients that do not support auth v2 or aes256gcm
if !client_protocol_version.supports_authenticate_v2()
|| !client_protocol_version.supports_aes256_gcm_siv()
{
error!("{incompatible_err}");
return Err(incompatible_err);
}
// we can't handle clients with higher protocol than ours
// (perhaps we could try to negotiate downgrade on our end? sounds like a nice future improvement)
if client_protocol_version.is_future_version() {
// this should never happen in a non-malicious client as it should use at most whatever version this gateway has announced
warn!("client has announced protocol version greater than one known by this gateway (v{client_protocol_version:?} vs v{}). attempting to downgrade.", GatewayProtocolVersion::CURRENT);
// we just reply with our current version, and it's up to the client to accept it or terminate the connection
Some(GatewayProtocolVersion::CURRENT)
error!("{incompatible_err}");
Err(incompatible_err)
} else {
// #####
// On backwards compat:
// Currently it is the case that gateways will understand all previous protocol versions
// and will downgrade accordingly, but this will not always be the case.
// For example, once we remove downgrade on legacy auth, anything below version 4 will be rejected
// #####
debug!(
"using the protocol version proposed by the client: v{client_protocol_version:?}"
);
client_protocol_version
debug!("the client is using exactly the same (or older) protocol version as we are. We're good to continue!");
Ok(CURRENT_PROTOCOL_VERSION)
}
}
@@ -558,90 +530,6 @@ impl<R, S> FreshHandler<R, S> {
}
}
/// Tries to handle the received authentication request by checking correctness of the received data.
///
/// # Arguments
///
/// * `client_address`: address of the client wishing to authenticate.
/// * `encrypted_address`: ciphertext of the address of the client wishing to authenticate.
/// * `iv`: fresh IV received with the request.
#[instrument(skip_all
fields(
address = %address,
)
)]
async fn handle_legacy_authenticate(
&mut self,
client_protocol_version: Option<GatewayProtocolVersion>,
address: String,
enc_address: String,
raw_nonce: String,
) -> Result<InitialAuthResult, InitialAuthenticationError>
where
S: AsyncRead + AsyncWrite + Unpin,
{
debug!("handling client authentication (v1)");
let negotiated_protocol = self.negotiate_proposed_protocol(client_protocol_version);
// populate the negotiated protocol for future uses
self.negotiated_protocol = negotiated_protocol;
let address = DestinationAddressBytes::try_from_base58_string(address)
.map_err(|err| InitialAuthenticationError::MalformedClientAddress(err.to_string()))?;
let encrypted_address = EncryptedAddressBytes::try_from_base58_string(enc_address)?;
let nonce = bs58::decode(&raw_nonce)
.into_vec()
.map_err(InitialAuthenticationError::MalformedIV)?;
// validate the shared key
let Some(shared_keys) = self
.auth_v1_verify_stored_shared_key(address, encrypted_address, &nonce)
.await?
else {
// it feels weird to be returning an 'Ok' here, but I didn't want to change the existing behaviour
return Ok(InitialAuthResult::new_legacy_failed(negotiated_protocol));
};
// in v1 we don't have explicit data so we have to use current timestamp
// (which does nothing but just allows us to use the same codepath)
let session_request_start = OffsetDateTime::now_utc();
// Check for duplicate clients
if let Some(remote_client_data) = self
.shared_state
.active_clients_store
.get_remote_client(address)
{
warn!("Detected duplicate connection for client: {address}");
self.handle_duplicate_client(address, remote_client_data, session_request_start)
.await?;
}
let client_id = shared_keys.client_id;
// if applicable, push stored messages
self.push_stored_messages_to_client(address, &shared_keys.key)
.await?;
// check the bandwidth
let bandwidth_remaining = self.authenticated_bandwidth_bytes(client_id).await?;
Ok(InitialAuthResult::new(
Some(ClientDetails::new(
client_id,
address,
shared_keys.key,
session_request_start,
)),
ServerResponse::Authenticate {
protocol_version: negotiated_protocol,
status: true,
bandwidth_remaining,
upgrade_mode: self.upgrade_mode_enabled(),
},
))
}
async fn handle_authenticate_v2(
&mut self,
request: Box<AuthenticateRequest>,
@@ -652,9 +540,9 @@ impl<R, S> FreshHandler<R, S> {
debug!("handling client authentication (v2)");
let negotiated_protocol =
self.negotiate_proposed_protocol(Some(request.content.protocol_version));
self.negotiate_proposed_protocol(request.content.protocol_version)?;
// populate the negotiated protocol for future uses
self.negotiated_protocol = negotiated_protocol;
self.negotiated_protocol = Some(negotiated_protocol);
let address = request.content.client_identity.derive_destination_address();
@@ -733,7 +621,7 @@ impl<R, S> FreshHandler<R, S> {
async fn register_client(
&mut self,
client_address: DestinationAddressBytes,
client_shared_keys: &SharedGatewayKey,
client_shared_keys: &SharedSymmetricKey,
) -> Result<i64, InitialAuthenticationError>
where
S: AsyncRead + AsyncWrite + Unpin,
@@ -777,7 +665,7 @@ impl<R, S> FreshHandler<R, S> {
/// * `init_data`: init payload of the registration handshake.
async fn handle_register(
&mut self,
client_protocol_version: Option<GatewayProtocolVersion>,
client_protocol_version: GatewayProtocolVersion,
init_data: Vec<u8>,
) -> Result<InitialAuthResult, InitialAuthenticationError>
where
@@ -821,7 +709,7 @@ impl<R, S> FreshHandler<R, S> {
Ok(InitialAuthResult::new(
Some(client_details),
ServerResponse::Register {
protocol_version: self.negotiated_protocol,
protocol_version: handshake_result.negotiated_protocol,
status: true,
upgrade_mode,
},
@@ -857,14 +745,8 @@ impl<R, S> FreshHandler<R, S> {
{
// we can handle stateless client requests without prior authentication, like `ClientControlRequest::SupportedProtocol`
let auth_result = match request {
ClientControlRequest::Authenticate {
protocol_version,
address,
enc_address,
iv,
} => {
self.handle_legacy_authenticate(protocol_version, address, enc_address, iv)
.await
ClientControlRequest::Authenticate { .. } => {
return Err(InitialAuthenticationError::UnsupportedLegacyAuthentication)
}
ClientControlRequest::AuthenticateV2(req) => self.handle_authenticate_v2(req).await,
ClientControlRequest::RegisterHandshakeInitRequest {
@@ -2,14 +2,14 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::node::client_handling::websocket::connection_handler::fresh::InitialAuthenticationError;
use nym_gateway_requests::SharedGatewayKey;
use nym_gateway_requests::SharedSymmetricKey;
use nym_gateway_storage::models::PersistedSharedKeys;
use nym_sphinx::DestinationAddressBytes;
use time::OffsetDateTime;
pub(crate) struct KeyWithAuthTimestamp {
pub(crate) client_id: i64,
pub(crate) key: SharedGatewayKey,
pub(crate) key: SharedSymmetricKey,
pub(crate) last_used_authentication: Option<OffsetDateTime>,
}
@@ -21,7 +21,7 @@ impl KeyWithAuthTimestamp {
let last_used_authentication = stored_shared_keys.last_used_authentication;
let client_id = stored_shared_keys.client_id;
let key = SharedGatewayKey::try_from(stored_shared_keys).map_err(|source| {
let key = SharedSymmetricKey::try_from(stored_shared_keys).map_err(|source| {
InitialAuthenticationError::MalformedStoredSharedKey {
client_id: client.as_base58_string(),
source,
@@ -3,7 +3,7 @@
use crate::config::Config;
use nym_credential_verification::BandwidthFlushingBehaviourConfig;
use nym_gateway_requests::shared_key::SharedGatewayKey;
use nym_gateway_requests::shared_key::SharedSymmetricKey;
use nym_gateway_requests::ServerResponse;
use nym_sphinx::DestinationAddressBytes;
use rand::{CryptoRng, Rng};
@@ -44,7 +44,7 @@ impl<S> SocketStream<S> {
pub(crate) struct ClientDetails {
pub(crate) address: DestinationAddressBytes,
pub(crate) id: i64,
pub(crate) shared_keys: SharedGatewayKey,
pub(crate) shared_keys: SharedSymmetricKey,
// note, this does **NOT ALWAYS** indicate timestamp of when client connected
// it is (for v2 auth) timestamp the client **signed** when it created the request
pub(crate) session_request_timestamp: OffsetDateTime,
@@ -54,7 +54,7 @@ impl ClientDetails {
pub(crate) fn new(
id: i64,
address: DestinationAddressBytes,
shared_keys: SharedGatewayKey,
shared_keys: SharedSymmetricKey,
session_request_timestamp: OffsetDateTime,
) -> Self {
ClientDetails {
@@ -78,20 +78,6 @@ impl InitialAuthResult {
server_response,
}
}
fn new_legacy_failed(protocol_version: Option<u8>) -> Self {
InitialAuthResult {
client_details: None,
server_response: ServerResponse::Authenticate {
protocol_version,
status: false,
bandwidth_remaining: 0,
// given this response is given only to legacy clients,
// we use the default value as clients wouldn't deserialise it anyway
upgrade_mode: false,
},
}
}
}
// imo there's no point in including the peer address in anything higher than debug
+25 -14
View File
@@ -14,10 +14,10 @@ use nym_bandwidth_controller::BandwidthController;
use nym_credential_storage::persistent_storage::PersistentStorage;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_client::client::config::GatewayClientConfig;
use nym_gateway_client::client::GatewayConfig;
use nym_gateway_client::client::{GatewayConfig, GatewayListeners};
use nym_gateway_client::error::GatewayClientError;
use nym_gateway_client::{
AcknowledgementReceiver, GatewayClient, MixnetMessageReceiver, PacketRouter, SharedGatewayKey,
AcknowledgementReceiver, GatewayClient, MixnetMessageReceiver, PacketRouter, SharedSymmetricKey,
};
use nym_sphinx::forwarding::packet::MixPacket;
use nym_task::ShutdownToken;
@@ -30,6 +30,7 @@ use std::sync::Arc;
use std::task::Poll;
use std::time::Duration;
use tracing::{debug, error, info, trace, warn};
use url::Url;
const TIME_CHUNK_SIZE: Duration = Duration::from_millis(50);
@@ -58,14 +59,17 @@ impl GatewayPackets {
}
}
pub(crate) fn gateway_config(&self) -> Option<GatewayConfig> {
self.clients_address
.clone()
.map(|gateway_listener| GatewayConfig {
pub(crate) fn gateway_config(&self) -> Result<Option<GatewayConfig>, url::ParseError> {
match self.clients_address.as_ref() {
Some(gateway_listener) => Ok(Some(GatewayConfig {
gateway_identity: self.pub_key,
gateway_owner: None,
gateway_listener,
})
gateway_listeners: GatewayListeners {
primary: Url::parse(gateway_listener)?,
fallback: None,
},
})),
None => Ok(None),
}
}
pub(crate) fn empty(clients_address: Option<String>, pub_key: ed25519::PublicKey) -> Self {
@@ -96,7 +100,7 @@ struct FreshGatewayClientData {
gateway_response_timeout: Duration,
bandwidth_controller: BandwidthController<nyxd::Client, PersistentStorage>,
disabled_credentials_mode: bool,
gateways_key_cache: DashMap<ed25519::PublicKey, Arc<SharedGatewayKey>>,
gateways_key_cache: DashMap<ed25519::PublicKey, Arc<SharedSymmetricKey>>,
}
impl FreshGatewayClientData {
@@ -267,7 +271,7 @@ impl PacketSender {
connection_timeout: Duration,
bandwidth_claim_timeout: Duration,
client: &mut GatewayClientHandle,
) -> Option<Arc<SharedGatewayKey>> {
) -> Option<Arc<SharedSymmetricKey>> {
let gateway_identity = client.gateway_identity();
// 1. attempt to authenticate
@@ -365,9 +369,16 @@ impl PacketSender {
) -> Option<GatewayClientHandle> {
let identity = packets.pub_key;
let Some(gateway_config) = packets.gateway_config() else {
warn!("gateway {identity} didn't provide valid entry information");
return None;
let gateway_config = match packets.gateway_config() {
Ok(Some(gateway_config)) => gateway_config,
Ok(None) => {
warn!("gateway {identity} didn't provide valid entry information");
return None;
}
Err(e) => {
warn!("Error while parsing entry information for gateway {identity} : {e}");
return None;
}
};
let (mut client, gateway_channels) =
+73 -11
View File
@@ -48,7 +48,7 @@ pub struct BuilderConfig {
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct MixnetClientConfig {
/// Disable Poission process rate limiting of outbound traffic.
pub disable_poisson_rate: bool,
pub disable_real_traffic_poisson_process: bool,
/// Disable constant rate background loop cover traffic
pub disable_background_cover_traffic: bool,
@@ -58,8 +58,16 @@ pub struct MixnetClientConfig {
/// The minimum performance of gateways to use.
pub min_gateway_performance: Option<u8>,
}
/// Setting optionally the poisson rate for cover traffic stream
pub loop_cover_traffic_average_delay: Option<Duration>,
/// Average packet delay in milliseconds.
pub average_packet_delay: Option<Duration>,
/// Average message sending delay in milliseconds.
pub message_sending_average_delay: Option<Duration>,
}
impl BuilderConfig {
pub fn mixnet_client_debug_config(&self) -> DebugConfig {
if self.two_hops {
@@ -113,12 +121,14 @@ impl BuilderConfig {
RememberMe::new_mixnet()
};
let identity = self.entry_node.node.identity.to_string();
let builder = builder
.with_user_agent(self.user_agent)
.request_gateway(self.entry_node.node.identity.to_string())
.request_gateway(identity.clone())
.network_details(self.network_env)
.debug_config(debug_config)
.credentials_mode(true)
.no_hostname(true)
.with_remember_me(remember_me)
.custom_topology_provider(self.custom_topology_provider);
@@ -127,9 +137,13 @@ impl BuilderConfig {
builder
.build()
.inspect(|_| tracing::debug!("successfully built reg client for {}", identity))
.inspect_err(|e| tracing::debug!("failed to build reg client for {}: {e}", identity))
.map_err(|err| RegistrationClientError::BuildMixnetClient(Box::new(err)))?
.connect_to_mixnet()
.await
.inspect(|_| tracing::debug!("successfully connected reg client for {}", identity))
.inspect_err(|e| tracing::debug!("failed to connect reg client for {}: {e}", identity))
.map_err(|err| RegistrationClientError::ConnectToMixnet(Box::new(err)))
}
}
@@ -164,13 +178,6 @@ fn mixnet_debug_config(mixnet_client_config: &MixnetClientConfig) -> DebugConfig
let mut debug_config = DebugConfig::default();
debug_config.traffic.average_packet_delay = VPN_AVERAGE_PACKET_DELAY;
debug_config
.traffic
.disable_main_poisson_packet_distribution = mixnet_client_config.disable_poisson_rate;
debug_config.cover_traffic.disable_loop_cover_traffic_stream =
mixnet_client_config.disable_background_cover_traffic;
if let Some(min_mixnode_performance) = mixnet_client_config.min_mixnode_performance {
debug_config.topology.minimum_mixnode_performance = min_mixnode_performance;
}
@@ -178,6 +185,35 @@ fn mixnet_debug_config(mixnet_client_config: &MixnetClientConfig) -> DebugConfig
if let Some(min_gateway_performance) = mixnet_client_config.min_gateway_performance {
debug_config.topology.minimum_gateway_performance = min_gateway_performance;
}
if let Some(avg_packet_ms) = mixnet_client_config.average_packet_delay {
debug_config.traffic.average_packet_delay = avg_packet_ms;
debug_config.acknowledgements.average_ack_delay = avg_packet_ms;
}
// Disable real-traffic Poisson if explicitly disabled OR delay is 0ms
debug_config
.traffic
.disable_main_poisson_packet_distribution = mixnet_client_config
.disable_real_traffic_poisson_process
|| mixnet_client_config
.message_sending_average_delay
.is_some_and(|d| d.is_zero());
if let Some(delay) = mixnet_client_config.message_sending_average_delay {
debug_config.traffic.message_sending_average_delay = delay;
}
// Disable loop cover traffic if explicitly disabled OR delay is 0ms
debug_config.cover_traffic.disable_loop_cover_traffic_stream = mixnet_client_config
.disable_background_cover_traffic
|| mixnet_client_config
.loop_cover_traffic_average_delay
.is_some_and(|d| d.is_zero());
if let Some(delay) = mixnet_client_config.loop_cover_traffic_average_delay {
debug_config.cover_traffic.loop_cover_traffic_average_delay = delay;
}
log_mixnet_client_config(&debug_config);
debug_config
}
@@ -206,6 +242,32 @@ fn log_mixnet_client_config(debug_config: &DebugConfig) {
"mixnet client minimum gateway performance: {}",
debug_config.topology.minimum_gateway_performance,
);
if !debug_config.cover_traffic.disable_loop_cover_traffic_stream {
tracing::info!(
"mixnet client loop cover traffic average delay: {} ms",
debug_config
.cover_traffic
.loop_cover_traffic_average_delay
.as_millis()
);
}
tracing::info!(
"mixnet client average packet delay: {} ms",
debug_config.traffic.average_packet_delay.as_millis()
);
if !debug_config
.traffic
.disable_main_poisson_packet_distribution
{
tracing::info!(
"mixnet client message sending average delay: {} ms",
debug_config
.traffic
.message_sending_average_delay
.as_millis()
);
}
}
fn true_to_disabled(val: bool) -> &'static str {
@@ -219,7 +281,7 @@ mod tests {
#[test]
fn test_mixnet_client_config_default_values() {
let config = MixnetClientConfig::default();
assert!(!config.disable_poisson_rate);
assert!(!config.disable_real_traffic_poisson_process);
assert!(!config.disable_background_cover_traffic);
assert_eq!(config.min_mixnode_performance, None);
assert_eq!(config.min_gateway_performance, None);
+19 -4
View File
@@ -38,6 +38,7 @@ impl RegistrationClientBuilder {
let (event_tx, event_rx) = mpsc::unbounded();
let nyxd_client = get_nyxd_client(&self.config.network_env)?;
let mixnet_client_startup_timeout = self.config.mixnet_client_startup_timeout;
let (mixnet_client, bandwidth_controller): (
MixnetClient,
@@ -46,20 +47,34 @@ impl RegistrationClientBuilder {
let builder = MixnetClientBuilder::new_with_storage(mixnet_client_storage)
.event_tx(EventSender(event_tx));
let mixnet_client = tokio::time::timeout(
self.config.mixnet_client_startup_timeout,
mixnet_client_startup_timeout,
self.config.build_and_connect_mixnet_client(builder),
)
.await??;
.await
.inspect_err(|_| {
tracing::warn!(
"mixnet client connection timed out after {:?}",
mixnet_client_startup_timeout
)
})?
.inspect_err(|e| tracing::warn!("mixnet build/connect error: {e}"))?;
let bandwidth_controller =
Box::new(BandwidthController::new(credential_storage, nyxd_client));
(mixnet_client, bandwidth_controller)
} else {
let builder = MixnetClientBuilder::new_ephemeral().event_tx(EventSender(event_tx));
let mixnet_client = tokio::time::timeout(
self.config.mixnet_client_startup_timeout,
mixnet_client_startup_timeout,
self.config.build_and_connect_mixnet_client(builder),
)
.await??;
.await
.inspect_err(|_| {
tracing::warn!(
"mixnet client connection timed out after {:?}",
mixnet_client_startup_timeout
)
})?
.inspect_err(|e| tracing::warn!("mixnet build/connect error: {e}"))?;
let bandwidth_controller = Box::new(BandwidthController::new(
EphemeralCredentialStorage::default(),
nyxd_client,
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO report_v2(\n received_at,\n source_ip,\n from_mixnet,\n country_code,\n report_version,\n device_id,\n os_type,\n os_version,\n architecture,\n app_version,\n user_agent,\n start_day_utc,\n connection_time_ms,\n tunnel_type,\n retry_attempt,\n session_duration_min,\n disconnection_time_ms,\n exit_id,\n follow_up_id,\n error)\n VALUES ($1::timestamptz, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)",
"query": "INSERT INTO report_v2(\n received_at,\n source_ip,\n from_mixnet,\n country_code,\n report_version,\n device_id,\n os_type,\n os_version,\n architecture,\n app_version,\n user_agent,\n start_day_utc,\n connection_time_ms,\n tunnel_type,\n retry_attempt,\n session_duration_min,\n disconnection_time_ms,\n exit_id,\n exit_cc,\n follow_up_id,\n error)\n VALUES ($1::timestamptz, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)",
"describe": {
"columns": [],
"parameters": {
@@ -24,10 +24,11 @@
"Int4",
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "14d75cdd34201313e34ae7f0b931c9df43603232e3be42b0573013cd74226518"
"hash": "5a6025e1b0d55dbfae098fdaa564e5a59642cec59947fabcb6f514d24423553c"
}
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "nym-statistics-api"
version = "0.3.0"
version = "0.3.1"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -0,0 +1,31 @@
-- IMPORTANT : At the time of writing this, there are no instances of the Stats API with data in that table. Dropping it to modify is therefore fine
DROP TABLE report_v2;
CREATE TABLE report_v2 (
-- some info about the report, inferred from when/from where we got it
received_at TIMESTAMP WITH TIME ZONE NOT NULL,
source_ip TEXT,
from_mixnet BOOLEAN,
country_code TEXT,
report_version TEXT,
-- some infos about the device sending the report
device_id TEXT NOT NULL,
os_type TEXT,
os_version TEXT,
architecture TEXT,
app_version TEXT,
user_agent TEXT,
-- session info
start_day_utc DATE,
connection_time_ms INTEGER,
tunnel_type TEXT,
retry_attempt INTEGER,
session_duration_min INTEGER,
disconnection_time_ms INTEGER,
exit_id TEXT,
exit_cc TEXT, -- new column
follow_up_id TEXT,
error TEXT
);
+51 -1
View File
@@ -1,7 +1,9 @@
use axum::{Json, Router, extract::State};
use axum_client_ip::InsecureClientIp;
use axum_extra::{TypedHeader, headers::UserAgent};
use nym_statistics_common::report::vpn_client::{VpnClientStatsReport, VpnClientStatsReportV2};
use nym_statistics_common::report::vpn_client::{
ActiveDeviceReport, StaticInformationReport, VpnClientStatsReport, VpnClientStatsReportV2,
};
use tracing::debug;
use crate::{
@@ -15,6 +17,7 @@ use crate::{
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/report", axum::routing::post(submit_stats_report))
.route("/active_device", axum::routing::post(submit_active_device))
.route("/session", axum::routing::post(submit_session_report))
}
@@ -76,6 +79,53 @@ async fn submit_stats_report(
Ok(Json(()))
}
#[utoipa::path(
post,
request_body = StaticInformationReport,
tag = "Stats",
path = "/active_device",
context_path = "/v1/stats",
responses(
(status = 200)
)
)]
#[tracing::instrument(level = "info", skip_all)]
async fn submit_active_device(
State(mut state): State<AppState>,
TypedHeader(user_agent): TypedHeader<UserAgent>,
insecure_ip_addr: InsecureClientIp,
Json(report): Json<ActiveDeviceReport>,
) -> HttpResult<Json<()>> {
let now = time::OffsetDateTime::now_utc();
let gateway_record = state
.network_view()
.get_country_by_ip(&insecure_ip_addr.0)
.await;
let from_mixnet = gateway_record.is_some();
if from_mixnet {
debug!("Received an active device ping from the network");
} else {
debug!("Received an active device ping from outside of the network");
}
let active_device = DailyActiveDeviceDto::from_active_device_report(
now,
&report,
user_agent.clone(),
from_mixnet,
);
state
.storage()
.store_active_device(active_device)
.await
.map_err(HttpError::internal_with_logging)?;
Ok(Json(()))
}
#[utoipa::path(
post,
request_body = VpnClientStatsReportV2,
+3 -1
View File
@@ -152,9 +152,10 @@ impl StatisticsStorage {
session_duration_min,
disconnection_time_ms,
exit_id,
exit_cc,
follow_up_id,
error)
VALUES ($1::timestamptz, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"#,
VALUES ($1::timestamptz, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)"#,
report_v2.received_at as time::OffsetDateTime,
report_v2.received_from,
report_v2.from_mixnet,
@@ -173,6 +174,7 @@ impl StatisticsStorage {
report_v2.session_duration_min,
report_v2.disconnection_time_ms,
report_v2.exit_id,
report_v2.exit_cc,
report_v2.follow_up_id,
report_v2.error
)
+23 -1
View File
@@ -2,7 +2,9 @@ use std::net::IpAddr;
use axum_extra::headers::UserAgent;
use celes::Country;
use nym_statistics_common::report::vpn_client::{VpnClientStatsReport, VpnClientStatsReportV2};
use nym_statistics_common::report::vpn_client::{
ActiveDeviceReport, VpnClientStatsReport, VpnClientStatsReportV2,
};
use time::{Date, OffsetDateTime};
pub type StatsId = String;
@@ -54,6 +56,24 @@ impl DailyActiveDeviceDto {
from_mixnet,
}
}
pub(crate) fn from_active_device_report(
received_at: OffsetDateTime,
report: &ActiveDeviceReport,
user_agent: UserAgent,
from_mixnet: bool,
) -> Self {
Self {
day: received_at.date(),
stats_id: report.stats_id.clone(),
os_type: report.static_information.os_type.clone(),
os_version: report.static_information.os_version.clone(),
os_arch: report.static_information.os_arch.clone(),
app_version: report.static_information.app_version.clone(),
user_agent: user_agent.to_string(),
from_mixnet,
}
}
}
#[derive(Debug, Clone, sqlx::FromRow)]
@@ -129,6 +149,7 @@ pub(crate) struct StatsReportV2Dto {
pub(crate) session_duration_min: i32,
pub(crate) disconnection_time_ms: i32,
pub(crate) exit_id: String,
pub(crate) exit_cc: Option<String>,
pub(crate) follow_up_id: Option<String>,
pub(crate) error: Option<String>,
}
@@ -161,6 +182,7 @@ impl StatsReportV2Dto {
session_duration_min: stats_report.session_report.session_duration_min,
disconnection_time_ms: stats_report.session_report.disconnection_time_ms,
exit_id: stats_report.session_report.exit_id.clone(),
exit_cc: stats_report.session_report.exit_cc.clone(),
follow_up_id: stats_report.session_report.follow_up_id.clone(),
error: stats_report.session_report.error.clone(),
}
+2 -2
View File
@@ -2929,9 +2929,9 @@ dependencies = [
[[package]]
name = "hickory-resolver"
version = "0.25.1"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a128410b38d6f931fcc6ca5c107a3b02cabd6c05967841269a4ad65d23c44331"
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
dependencies = [
"cfg-if",
"futures-util",
@@ -1,8 +1,8 @@
use nym_crypto::asymmetric::ed25519::PublicKey;
use nym_gateway_requests::SharedSymmetricKey;
use nym_client_core::client::base_client::storage::gateways_storage::GatewayPublishedData;
use nym_sdk::mixnet::{
self, ActiveGateway, BadGateway, ClientKeys, EmptyReplyStorage, EphemeralCredentialStorage,
GatewayRegistration, GatewaysDetailsStore, KeyStore, MixnetClientStorage, MixnetMessageSender,
self, ed25519, ActiveGateway, BadGateway, ClientKeys, EmptyReplyStorage,
EphemeralCredentialStorage, GatewayRegistration, GatewaysDetailsStore, KeyStore,
MixnetClientStorage, MixnetMessageSender,
};
use nym_topology::provider_trait::async_trait;
@@ -166,14 +166,14 @@ impl GatewaysDetailsStore for MockGatewayDetailsStore {
Ok(())
}
async fn upgrade_stored_remote_gateway_key(
async fn update_gateway_published_data(
&self,
gateway_id: PublicKey,
_updated_key: &SharedSymmetricKey,
_gateway_id: &ed25519::PublicKey,
_details: &GatewayPublishedData,
) -> Result<(), Self::StorageError> {
println!("upgrading gateway key for {gateway_id}");
println!("updating gateway details");
Err(MyError)
Ok(())
}
async fn remove_gateway_details(&self, _gateway_id: &str) -> Result<(), Self::StorageError> {
+40 -1
View File
@@ -9,6 +9,7 @@ use crate::GatewayTransceiver;
use crate::NymNetworkDetails;
use crate::{Error, Result};
use log::{debug, warn};
use nym_client_core::client::base_client::storage::gateways_storage::GatewayRegistration;
use nym_client_core::client::base_client::storage::helpers::{
get_active_gateway_identity, get_all_registered_identities, has_gateway_details,
set_active_gateway,
@@ -24,8 +25,8 @@ use nym_client_core::client::{
use nym_client_core::config::{DebugConfig, ForgetMe, RememberMe, StatsReporting};
use nym_client_core::error::ClientCoreError;
use nym_client_core::init::helpers::gateways_for_init;
use nym_client_core::init::setup_gateway;
use nym_client_core::init::types::{GatewaySelectionSpecification, GatewaySetup};
use nym_client_core::init::{refresh_gateway_published_data, setup_gateway};
use nym_credentials_interface::TicketType;
use nym_crypto::hkdf::DerivationMaterial;
use nym_socks5_client_core::config::Socks5;
@@ -56,6 +57,7 @@ pub struct MixnetClientBuilder<S: MixnetClientStorage = Ephemeral> {
custom_shutdown: Option<ShutdownTracker>,
event_tx: Option<EventSender>,
force_tls: bool,
no_hostname: bool,
user_agent: Option<UserAgent>,
#[cfg(unix)]
connection_fd_callback: Option<Arc<dyn Fn(std::os::fd::RawFd) + Send + Sync>>,
@@ -101,6 +103,7 @@ impl MixnetClientBuilder<OnDiskPersistent> {
event_tx: None,
custom_gateway_transceiver: None,
force_tls: false,
no_hostname: false,
user_agent: None,
#[cfg(unix)]
connection_fd_callback: None,
@@ -134,6 +137,7 @@ where
custom_shutdown: None,
event_tx: None,
force_tls: false,
no_hostname: false,
user_agent: None,
#[cfg(unix)]
connection_fd_callback: None,
@@ -158,6 +162,7 @@ where
custom_shutdown: self.custom_shutdown,
event_tx: self.event_tx,
force_tls: self.force_tls,
no_hostname: self.no_hostname,
user_agent: self.user_agent,
#[cfg(unix)]
connection_fd_callback: self.connection_fd_callback,
@@ -229,6 +234,13 @@ where
self
}
/// Attempt to only choose a gateway with its IP address only, ignored if force_tls is set
#[must_use]
pub fn no_hostname(mut self, no_hostname: bool) -> Self {
self.no_hostname = no_hostname;
self
}
/// Enable paid coconut bandwidth credentials mode.
#[must_use]
pub fn enable_credentials_mode(mut self) -> Self {
@@ -341,6 +353,7 @@ where
client.custom_shutdown = self.custom_shutdown;
client.wait_for_gateway = self.wait_for_gateway;
client.force_tls = self.force_tls;
client.no_hostname = self.no_hostname;
client.user_agent = self.user_agent;
#[cfg(unix)]
if self.connection_fd_callback.is_some() {
@@ -393,6 +406,9 @@ where
/// Force the client to connect using wss protocol with the gateway.
force_tls: bool,
/// Force the client to pick gateway IP and not hostname, ignored if force_tls is set
no_hostname: bool,
/// Allows passing an externally controlled shutdown handle.
custom_shutdown: Option<ShutdownTracker>,
@@ -461,6 +477,7 @@ where
custom_gateway_transceiver: None,
wait_for_gateway: false,
force_tls: false,
no_hostname: false,
custom_shutdown: None,
event_tx,
user_agent: None,
@@ -580,6 +597,7 @@ where
self.config.user_chosen_gateway.clone(),
None,
self.force_tls,
self.no_hostname,
);
let available_gateways = self.available_gateways().await?;
@@ -592,6 +610,21 @@ where
})
}
async fn refresh_gateway_published_data(
&mut self,
gateway_registration: GatewayRegistration,
) -> Result<(), ClientCoreError> {
let available_gateways = self.available_gateways().await?;
refresh_gateway_published_data(
self.storage.gateway_details_store(),
gateway_registration,
available_gateways,
self.force_tls,
self.no_hostname,
)
.await
}
/// Register with a gateway. If a gateway is provided in the config then that will try to be
/// used. If none is specified, a gateway at random will be picked. The used gateway is saved
/// as the active gateway.
@@ -647,6 +680,12 @@ where
)
.await?;
// update gateway setup if needed
if init_results.exipred_details() {
self.refresh_gateway_published_data(init_results.gateway_registration.clone())
.await?;
}
set_active_gateway(
self.storage.gateway_details_store(),
&init_results.gateway_id().to_base58_string(),
+3 -10
View File
@@ -26,7 +26,6 @@ use tsify::Tsify;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::future_to_promise;
use wasm_client_core::client::base_client::storage::gateways_storage::GatewayDetails;
use wasm_client_core::client::base_client::storage::GatewaysDetailsStore;
use wasm_client_core::client::mix_traffic::transceiver::PacketRouter;
use wasm_client_core::helpers::{
current_network_topology_async, setup_from_topology, EphemeralCredentialStorage,
@@ -200,8 +199,7 @@ impl NymNodeTesterBuilder {
} else {
let cfg = GatewayConfig::new(
gateway_info.gateway_id,
gateway_info.gateway_owner_address.map(|a| a.to_string()),
gateway_info.gateway_listener.to_string(),
gateway_info.published_data.listeners,
);
GatewayClient::new(
GatewayClientConfig::new_default().with_disabled_credentials_mode(true),
@@ -215,13 +213,8 @@ impl NymNodeTesterBuilder {
)
};
let auth_res = gateway_client.perform_initial_authentication().await?;
if auth_res.requires_key_upgrade {
let updated_key = gateway_client.upgrade_key_authenticated().await?;
client_store
.upgrade_stored_remote_gateway_key(gateway_identity, &updated_key)
.await?;
}
let _auth_res = gateway_client.perform_initial_authentication().await?;
gateway_client.claim_initial_bandwidth().await?;
gateway_client.start_listening_for_mixnet_messages()?;