Compare commits

...

5 Commits

Author SHA1 Message Date
Mark Sinclair 6e62e34ac8 bump version to 1.0.1 2025-12-04 16:07:17 +00:00
Mark Sinclair 18e72c90df run the migrations from the data observatory and not the base chain scraper 2025-12-04 16:06:56 +00:00
Mark Sinclair fd051540aa remove nuke db cli args - only makes sense for sqlite and not pgsql 2025-12-03 13:54:23 +00:00
Simon Wicky 46268edf9c [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
2025-12-03 09:49:23 +01:00
Mark Sinclair f2091cc9d6 Data Observatory (#6172)
* rename nyxd-scraper to sqlite

wip: made storage mostly generic minus modules

changed error types to make modules dyn compatible

implemented traits for sqlite instance

using sqlite instance for rewarder and chain watcher

psql scaffolding

initial postgres support - missing some proto -> json parsing

use postgres in chain scraper

added message registry to block processor

message content parsing in psql

involved addresses

adding null value for logs

Revert "use postgres in chain scraper"

This reverts commit 83c84bfd2d.

using SignerInfo proto definitions for db serialisation

added ibc messages to MessageRegistry

* add the data observatory

* add the data observatory

* move message parsing and change webhook

* handle wasm messages in a module

* formatting and clippy

* copy shared migrations and add comments to ignore file to explain

* update offline queries

* change to clap args and use url::Url to parse args

* tidy up README, startup info, typos

* tidy up validator rewarder

* lock file

* change webhook module from msg to tx handler

* ignore profiler output

* add missing things and make clippy happy

* updated cosmrs version used by the nym wallet

* add glob to workspace dependencies

* rename migration files

* remove copying from shared migrations

* duplicate shared migrations to keep things simple

* add check for manual migration sync that will fail on `cargo build` in CI

* build.rs checks data observatory migrations have content of all shared scraper migrations and errors on changes or new files

* update runner

* add reset target to make file

* process events and logs

* migrations - remove unnecessary columns

* update offline queries

* chore: run cargo fmt

* fix up: inpsect_err instead of map_err

---------

Co-authored-by: Jędrzej Stuczyński <jedrzej.stuczynski@gmail.com>
Co-authored-by: Mark Sinclair <mmsinclair@users.noreply.github.com>
Co-authored-by: benedettadavico <benedetta.davico@gmail.com>
2025-12-02 21:27:22 +00:00
259 changed files with 8439 additions and 2998 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ env:
jobs:
build-container:
runs-on: arc-ubuntu-22.04-dind
runs-on: arc-linux-latest-dind
steps:
- name: Login to Harbor
uses: docker/login-action@v3
+2
View File
@@ -63,3 +63,5 @@ nym-api/redocly/formatted-openapi.json
**/settings.sql
**/enter_db.sh
*.profraw
Generated
+467 -323
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -87,7 +87,9 @@ members = [
"common/nymsphinx/params",
"common/nymsphinx/routing",
"common/nymsphinx/types",
"common/nyxd-scraper",
"common/nyxd-scraper-sqlite",
"common/nyxd-scraper-psql",
"common/nyxd-scraper-shared",
"common/pemstore",
"common/registration",
"common/serde-helpers",
@@ -124,6 +126,7 @@ members = [
"nym-credential-proxy/nym-credential-proxy",
"nym-credential-proxy/nym-credential-proxy-requests",
"nym-credential-proxy/vpn-api-lib-wasm",
"nym-data-observatory",
"nym-ip-packet-client",
"nym-network-monitor",
"nym-node",
@@ -263,6 +266,7 @@ futures = "0.3.31"
futures-util = "0.3"
generic-array = "0.14.7"
getrandom = "0.2.10"
glob = "0.3"
handlebars = "3.5.5"
hex = "0.4.3"
hickory-resolver = "0.25"
@@ -398,7 +402,9 @@ cw-multi-test = "=2.3.2"
bip32 = { version = "0.5.3", default-features = false }
cosmrs = { version = "0.21.1" }
cosmrs = { version = "0.22.0" }
cosmos-sdk-proto = { version = "0.27.0" }
ibc-proto = { version = "0.52.0" }
tendermint = "0.40.4"
tendermint-rpc = "0.40.4"
prost = { version = "0.13", default-features = false }
@@ -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(),
))
}
}
@@ -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>();
}
+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 {
@@ -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,
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO transaction\n (hash, height, index, success, messages, memo, signatures, signer_infos, fee, gas_wanted, gas_used, raw_log, logs, events)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n ON CONFLICT (hash) DO UPDATE\n SET height = excluded.height,\n index = excluded.index,\n success = excluded.success,\n messages = excluded.messages,\n memo = excluded.memo,\n signatures = excluded.signatures,\n signer_infos = excluded.signer_infos,\n fee = excluded.fee,\n gas_wanted = excluded.gas_wanted,\n gas_used = excluded.gas_used,\n raw_log = excluded.raw_log,\n logs = excluded.logs,\n events = excluded.events\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Int4",
"Bool",
"Jsonb",
"Text",
"TextArray",
"Jsonb",
"Jsonb",
"Int8",
"Int8",
"Text",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "08f4e54ac24fccd54f4208797b3749e457f8cd4ba3d7d906a7ab3bf5b4e7dc9c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO validator (consensus_address, consensus_pubkey)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0d3709efacf763b06bf14803bb803b5ee5b27879b0026bb0480b3f2722318a75"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM pre_commit WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "1c2fb0e9ffceca21ef8dbea19b116422b1f723d0a316314b50c43c8b29f8891d"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT height\n FROM block\n ORDER BY height ASC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "2561fb016951ea4cd29e43fb9a4a93e944b0d44ed1f7c1036f306e34372da11c"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE metadata SET last_processed_height = GREATEST(last_processed_height, $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "2679cdf11fa66c7920678cde860c57402119ec7c3aae731b0da831327301466f"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE pruning SET last_pruned_height = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "36ba5941aca6e7b604a10b8b0aba70635028f392fe794d6131827b083e1755e1"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT last_pruned_height FROM pruning\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_pruned_height",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "3bdf81a9db6075f6f77224c30553f419a849d4ec45af40b052a4cbf09b44f3ec"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM message WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "52c27143720ddfdfd0f5644b60f5b67fd9281ce1de0653efa53b9d9b93cf335d"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO pre_commit (validator_address, height, timestamp, voting_power, proposer_priority)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (validator_address, timestamp) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Timestamp",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "62e14613f5ffe692346a79086857a22f0444fbc679db1c06b651fb8b5538b278"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO block (height, hash, num_txs, total_gas, proposer_address, timestamp)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Int4",
"Int8",
"Text",
"Timestamp"
]
},
"nullable": []
},
"hash": "64a484fd46d8ec46797f944a4cced56b6e270ce186f0e49528865d1924343b78"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT height\n FROM block\n WHERE timestamp < $1\n ORDER BY timestamp DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Timestamp"
]
},
"nullable": [
false
]
},
"hash": "7e82426f5dbcadf1631ba1a806e19cc462d04222fb20ad76de2a40f3f4f8fe15"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT height\n FROM block\n WHERE timestamp > $1\n ORDER BY timestamp\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Timestamp"
]
},
"nullable": [
false
]
},
"hash": "9455331f9be5a3be28e2bd399a36b2e2d6a9ad4b225c4c883aafc4e9f0428008"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*) as count FROM pre_commit\n WHERE\n validator_address = $1\n AND height >= $2\n AND height <= $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "bc7795e58ce71893c3f32a19db8e77b7bc0a1af315ffd42c3e68156d6e4ace70"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT * FROM validator\n WHERE EXISTS (\n SELECT 1 FROM pre_commit\n WHERE height = $1\n AND pre_commit.validator_address = validator.consensus_address\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "consensus_address",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "consensus_pubkey",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false
]
},
"hash": "be43d4873911deca784b7be0531ab7bd82ecd68041aa932a56c8ce09623251e4"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT last_processed_height FROM metadata\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_processed_height",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "c88d07fecc3f33deaa6e93db1469ce71582635df47f52dcf3fd1df4e7be6b96d"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO message(transaction_hash, index, type, value, involved_accounts_addresses, height)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (transaction_hash, index) DO UPDATE\n SET height = excluded.height,\n type = excluded.type,\n value = excluded.value,\n involved_accounts_addresses = excluded.involved_accounts_addresses\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Jsonb",
"TextArray",
"Int8"
]
},
"nullable": []
},
"hash": "cc0ae74082d7d8a89f2d3364676890bbf6150ab394c72783114340d4def5f9ef"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM block WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "cdba9b267f143c8a8c6c3d6ed713cf00236490b86779559d84740ec18bcfa3a9"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM transaction WHERE height < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "d89558c37c51e8e6b1e6a9d5a2b13d0598fd856aa019a0cbbae12d7cafb4672f"
}
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "nyxd-scraper-psql"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
itertools = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "time"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing.workspace = true
nyxd-scraper-shared = { path = "../nyxd-scraper-shared" }
# temp due to cosmrs redefinitions for serde
cosmrs = { workspace = true }
[build-dependencies]
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "postgres", "macros", "migrate"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[lints]
workspace = true
+105
View File
@@ -0,0 +1,105 @@
# Makefile for nyxd-scraper-psql database management
# --- Configuration ---
TEST_DATABASE_URL := postgres://testuser:testpass@localhost:5433/nyxd_scraper_test
# Docker compose service names
DB_SERVICE_NAME := postgres-test
DB_CONTAINER_NAME := nyxd_scraper_psql_test
# Default target
.PHONY: default
default: help
# --- Main Targets ---
.PHONY: prepare-pg
prepare-pg: test-db-up test-db-wait test-db-migrate test-db-prepare test-db-down ## Setup PostgreSQL and prepare SQLx offline cache
.PHONY: test-db
test-db: test-db-up test-db-wait test-db-migrate test-db-run test-db-down ## Run tests with PostgreSQL database
.PHONY: dev-db
dev-db: test-db-up test-db-wait test-db-migrate ## Start PostgreSQL for development (keeps running)
@echo "PostgreSQL is running on port 5433"
@echo "Connection string: $(TEST_DATABASE_URL)"
.PHONY: dev-db-restart
dev-db-restart: clean-db dev-db
# --- Docker Compose Targets ---
.PHONY: test-db-up
test-db-up: ## Start the PostgreSQL test database in the background
@echo "Starting PostgreSQL test database..."
docker compose up -d $(DB_SERVICE_NAME)
.PHONY: test-db-wait
test-db-wait: ## Wait for the PostgreSQL database to be healthy
@echo "Waiting for PostgreSQL database..."
@while ! docker inspect --format='{{.State.Health.Status}}' $(DB_CONTAINER_NAME) 2>/dev/null | grep -q 'healthy'; do \
echo -n "."; \
sleep 1; \
done; \
echo " Database is healthy!"
.PHONY: test-db-down
test-db-down: ## Stop and remove the test database
@echo "Stopping PostgreSQL test database..."
docker compose down
# --- SQLx Targets ---
.PHONY: test-db-migrate
test-db-migrate: ## Run database migrations against PostgreSQL
@echo "Running PostgreSQL migrations..."
DATABASE_URL="$(TEST_DATABASE_URL)" sqlx migrate run --source sql_migrations
.PHONY: test-db-prepare
test-db-prepare: ## Run sqlx prepare for compile-time query verification
@echo "Running sqlx prepare for PostgreSQL..."
DATABASE_URL="$(TEST_DATABASE_URL)" cargo sqlx prepare
# --- Build and Test Targets ---
.PHONY: test-db-run
test-db-run: ## Run tests with PostgreSQL feature
@echo "Running tests with PostgreSQL..."
DATABASE_URL="$(TEST_DATABASE_URL)" cargo test --features pg --no-default-features
.PHONY: build-pg
build-pg: ## Build with PostgreSQL feature
@echo "Building with PostgreSQL feature..."
cargo build
.PHONY: check-pg
check-pg: ## Check code with PostgreSQL feature
@echo "Checking code with PostgreSQL feature..."
cargo check
.PHONY: clippy
clippy: clippy-pg
.PHONY: clippy-pg
clippy-pg: ## Run clippy with PostgreSQL feature
@echo "Running clippy with PostgreSQL feature..."
cargo clippy -- -D warnings
# --- Cleanup Targets ---
.PHONY: clean
clean: ## Clean build artifacts and SQLx cache
cargo clean
rm -rf .sqlx
.PHONY: clean-db
clean-db: test-db-down ## Stop database and clean volumes
docker volume rm -f nym-node-status-api_postgres_test_data 2>/dev/null || true
# --- Utility Targets ---
.PHONY: sqlx-cli
sqlx-cli: ## Install sqlx-cli if not already installed
@command -v sqlx >/dev/null 2>&1 || cargo install sqlx-cli --features postgres
.PHONY: psql
psql: ## Connect to the running PostgreSQL database with psql
@docker exec -it $(DB_CONTAINER_NAME) psql -U testuser -d nyxd_scraper_test
.PHONY: help
help: ## Show help for Makefile targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+80
View File
@@ -0,0 +1,80 @@
## Quick Start with PostgreSQL
### 1. Install Prerequisites
```bash
# Install sqlx-cli if not already installed
make sqlx-cli
```
### 2. Prepare PostgreSQL for Development
```bash
# This will:
# - Start PostgreSQL in Docker
# - Run migrations
# - Generate SQLx offline query cache
# - Stop the database
make prepare-pg
```
### 3. Build with PostgreSQL
```bash
# Build with PostgreSQL feature
make build-pg
# Or manually:
cargo build
```
### 4. Run with PostgreSQL
```bash
# Start PostgreSQL for development (keeps running)
make dev-db
# In another terminal, run the application
DATABASE_URL=postgres://testuser:testpass@localhost:5433/nym_node_status_api_test \
cargo run
```
## Makefile Targets
```bash
make help # Show all available targets
make prepare-pg # Setup PostgreSQL and prepare SQLx cache
make dev-db # Start PostgreSQL for development
make test-db # Run tests with PostgreSQL
make build-pg # Build with PostgreSQL
make psql # Connect to running PostgreSQL
make clean # Clean build artifacts
make clean-db # Stop database and clean volumes
make dev-db-restart # Stop database, clean volumes, rebuild test database and restart
```
## Environment Variables
See `.env.example` for all configuration options. Key variable:
```bash
# For PostgreSQL:
DATABASE_URL=postgres://testuser:testpass@localhost:5433/nym_node_status_api_test
```
## Troubleshooting
### SQLx Offline Mode
If you see "no cached data for this query" errors:
1. Ensure PostgreSQL is running: `make dev-db`
2. Run: `make test-db-prepare`
### Connection Refused
If you see "Connection refused" errors:
1. Check Docker is running: `docker ps`
2. Check PostgreSQL container: `docker ps | grep nym_node_status_api_postgres_test`
3. Restart database: `make test-db-down && make dev-db`
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
fn main() {
if let Ok(database_url) = std::env::var("DATABASE_URL") {
println!("cargo::rustc-env=DATABASE_URL={database_url}");
}
}
@@ -0,0 +1,21 @@
services:
postgres-test:
image: postgres:16-alpine
container_name: nyxd_scraper_psql_test
environment:
POSTGRES_DB: nyxd_scraper_test
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- '5433:5432' # Map to 5433 to avoid conflicts with default PostgreSQL
healthcheck:
test: [ 'CMD-SHELL', 'pg_isready -U testuser -d nyxd_scraper_test' ]
interval: 5s
timeout: 5s
retries: 5
# Optional: Add volume for persistent data during development
# volumes:
# - postgres_test_data:/var/lib/postgresql/data
# volumes:
# postgres_test_data:
@@ -0,0 +1,10 @@
/*
* Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: Apache-2.0
*/
CREATE TABLE METADATA
(
id INTEGER PRIMARY KEY CHECK (id = 0),
last_processed_height BIGINT NOT NULL
);
@@ -0,0 +1,127 @@
CREATE TABLE validator
(
consensus_address TEXT NOT NULL PRIMARY KEY, /* Validator consensus address */
consensus_pubkey TEXT NOT NULL UNIQUE /* Validator consensus public key */
);
CREATE TABLE pre_commit
(
validator_address TEXT NOT NULL REFERENCES validator (consensus_address),
height BIGINT NOT NULL,
timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL,
voting_power BIGINT NOT NULL,
proposer_priority BIGINT NOT NULL,
UNIQUE (validator_address, timestamp)
);
CREATE INDEX pre_commit_validator_address_index ON pre_commit (validator_address);
CREATE INDEX pre_commit_height_index ON pre_commit (height);
CREATE TABLE block
(
height BIGINT UNIQUE PRIMARY KEY,
hash TEXT NOT NULL UNIQUE,
num_txs INTEGER DEFAULT 0,
total_gas BIGINT DEFAULT 0,
proposer_address TEXT REFERENCES validator (consensus_address),
timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL
);
CREATE INDEX block_height_index ON block (height);
CREATE INDEX block_hash_index ON block (hash);
CREATE INDEX block_proposer_address_index ON block (proposer_address);
ALTER TABLE block
SET (
autovacuum_vacuum_scale_factor = 0,
autovacuum_analyze_scale_factor = 0,
autovacuum_vacuum_threshold = 10000,
autovacuum_analyze_threshold = 10000
);
CREATE TABLE transaction
(
hash TEXT NOT NULL,
height BIGINT NOT NULL REFERENCES block (height),
"index" INTEGER NOT NULL, -- <<<=== not present in original bdjuno table, but it's quite useful
success BOOLEAN NOT NULL,
/* Body */
messages JSONB NOT NULL DEFAULT '[]'::JSONB,
memo TEXT,
signatures TEXT[] NOT NULL,
/* AuthInfo */
signer_infos JSONB NOT NULL DEFAULT '[]'::JSONB,
fee JSONB NOT NULL DEFAULT '{}'::JSONB,
/* Tx response */
gas_wanted BIGINT DEFAULT 0,
gas_used BIGINT DEFAULT 0,
raw_log TEXT,
logs JSONB,
events JSONB,
CONSTRAINT unique_tx UNIQUE (hash)
);
CREATE INDEX transaction_hash_index ON transaction (hash);
CREATE INDEX transaction_height_index ON transaction (height);
CREATE TYPE COIN AS
(
denom TEXT,
amount TEXT
);
CREATE TABLE message
(
transaction_hash TEXT NOT NULL,
index BIGINT NOT NULL,
type TEXT NOT NULL,
value JSONB NOT NULL,
involved_accounts_addresses TEXT[] NOT NULL,
height BIGINT NOT NULL,
funds COIN[] DEFAULT '{}',
FOREIGN KEY (transaction_hash) REFERENCES transaction (hash),
CONSTRAINT unique_message_per_tx UNIQUE (transaction_hash, index)
);
CREATE INDEX message_transaction_hash_index ON message (transaction_hash);
CREATE INDEX message_type_index ON message (type);
CREATE INDEX message_involved_accounts_index ON message USING GIN (involved_accounts_addresses);
/**
* This function is used to find all the utils that involve any of the given addresses and have
* type that is one of the specified types.
*/
CREATE FUNCTION messages_by_address(
addresses TEXT[],
types TEXT[],
"limit" BIGINT = 100,
"offset" BIGINT = 0)
RETURNS SETOF message AS
$$
SELECT *
FROM message
WHERE (cardinality(types) = 0 OR type = ANY (types))
AND addresses && involved_accounts_addresses
ORDER BY height DESC
LIMIT "limit" OFFSET "offset"
$$ LANGUAGE sql STABLE;
CREATE FUNCTION messages_by_type(
types text[],
"limit" bigint DEFAULT 100,
"offset" bigint DEFAULT 0)
RETURNS SETOF message AS
$$
SELECT *
FROM message
WHERE (cardinality(types) = 0 OR type = ANY (types))
ORDER BY height DESC
LIMIT "limit" OFFSET "offset"
$$ LANGUAGE sql STABLE;
CREATE TABLE pruning
(
last_pruned_height BIGINT NOT NULL
);
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nyxd_scraper_shared::helpers::MalformedDataError;
use nyxd_scraper_shared::storage::NyxdScraperStorageError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PostgresScraperError {
#[error("experienced internal database error: {0}")]
InternalDatabaseError(#[from] sqlx::error::Error),
#[error("failed to perform startup SQL migration: {0}")]
StartupMigrationFailure(#[from] sqlx::migrate::MigrateError),
#[error("failed to begin storage tx: {source}")]
StorageTxBeginFailure {
#[source]
source: sqlx::error::Error,
},
#[error("failed to commit storage tx: {source}")]
StorageTxCommitFailure {
#[source]
source: sqlx::error::Error,
},
#[error(transparent)]
MalformedData(#[from] MalformedDataError),
// TOOD: add struct name
#[error("json serialisation failure: {source}")]
SerialisationFailure {
#[from]
source: serde_json::Error,
},
}
impl From<PostgresScraperError> for NyxdScraperStorageError {
fn from(err: PostgresScraperError) -> Self {
NyxdScraperStorageError::new(err)
}
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::storage::block_storage::PostgresScraperStorage;
use nyxd_scraper_shared::NyxdScraper;
pub use nyxd_scraper_shared::constants;
pub use nyxd_scraper_shared::error::ScraperError;
pub use nyxd_scraper_shared::{
BlockModule, MsgModule, NyxdScraperTransaction, ParsedTransactionResponse, PruningOptions,
PruningStrategy, StartingBlockOpts, TxModule,
};
pub use storage::models;
pub mod error;
pub mod storage;
pub type PostgresNyxdScraper = NyxdScraper<PostgresScraperStorage>;
// TODO: for now just use exactly the same config
pub use nyxd_scraper_shared::Config;
@@ -0,0 +1,243 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::PostgresScraperError;
use crate::models::{CommitSignature, Validator};
use crate::storage::manager::{
StorageManager, prune_blocks, prune_messages, prune_pre_commits, prune_transactions,
update_last_pruned,
};
use crate::storage::transaction::PostgresStorageTransaction;
use async_trait::async_trait;
use nyxd_scraper_shared::storage::helpers::log_db_operation_time;
use nyxd_scraper_shared::storage::{NyxdScraperStorage, NyxdScraperStorageError};
use sqlx::types::time::{OffsetDateTime, PrimitiveDateTime};
use tokio::time::Instant;
use tracing::{debug, error, info, instrument};
#[derive(Clone)]
pub struct PostgresScraperStorage {
pub(crate) manager: StorageManager,
}
impl PostgresScraperStorage {
#[instrument]
pub async fn init(
connection_string: &str,
run_migrations: &bool,
) -> Result<Self, PostgresScraperError> {
debug!("initialising scraper database with '{connection_string}'",);
let connection_pool = match sqlx::PgPool::connect(connection_string).await {
Ok(db) => db,
Err(err) => {
error!("Failed to connect to SQLx database: {err}");
return Err(err.into());
}
};
if *run_migrations {
if let Err(err) = sqlx::migrate!("./sql_migrations")
.run(&connection_pool)
.await
{
return Err(err.into());
}
}
info!("Database migration finished!");
let manager = StorageManager { connection_pool };
manager.set_initial_metadata().await?;
let storage = PostgresScraperStorage { manager };
Ok(storage)
}
#[instrument(skip(self))]
pub async fn prune_storage(
&self,
oldest_to_keep: u32,
current_height: u32,
) -> Result<(), PostgresScraperError> {
let start = Instant::now();
let mut tx = self.begin_processing_tx().await?;
prune_messages(oldest_to_keep.into(), &mut **tx).await?;
prune_transactions(oldest_to_keep.into(), &mut **tx).await?;
prune_pre_commits(oldest_to_keep.into(), &mut **tx).await?;
prune_blocks(oldest_to_keep.into(), &mut **tx).await?;
update_last_pruned(current_height.into(), &mut **tx).await?;
let commit_start = Instant::now();
tx.inner
.commit()
.await
.map_err(|source| PostgresScraperError::StorageTxCommitFailure { source })?;
log_db_operation_time("committing pruning tx", commit_start);
log_db_operation_time("pruning storage", start);
Ok(())
}
#[instrument(skip_all)]
pub async fn begin_processing_tx(
&self,
) -> Result<PostgresStorageTransaction, PostgresScraperError> {
debug!("starting storage tx");
self.manager
.connection_pool
.begin()
.await
.map(|inner| PostgresStorageTransaction { inner })
.map_err(|source| PostgresScraperError::StorageTxBeginFailure { source })
}
pub async fn lowest_block_height(&self) -> Result<Option<i64>, PostgresScraperError> {
Ok(self.manager.get_lowest_block().await?)
}
pub async fn get_first_block_height_after(
&self,
time: OffsetDateTime,
) -> Result<Option<i64>, PostgresScraperError> {
let time = PrimitiveDateTime::new(time.date(), time.time());
Ok(self.manager.get_first_block_height_after(time).await?)
}
pub async fn get_last_block_height_before(
&self,
time: OffsetDateTime,
) -> Result<Option<i64>, PostgresScraperError> {
let time = PrimitiveDateTime::new(time.date(), time.time());
Ok(self.manager.get_last_block_height_before(time).await?)
}
pub async fn get_blocks_between(
&self,
start_time: OffsetDateTime,
end_time: OffsetDateTime,
) -> Result<i64, PostgresScraperError> {
let Some(block_start) = self.get_first_block_height_after(start_time).await? else {
return Ok(0);
};
let Some(block_end) = self.get_last_block_height_before(end_time).await? else {
return Ok(0);
};
Ok(block_end - block_start)
}
pub async fn get_signed_between(
&self,
consensus_address: &str,
start_height: i64,
end_height: i64,
) -> Result<i64, PostgresScraperError> {
Ok(self
.manager
.get_signed_between(consensus_address, start_height, end_height)
.await?)
}
pub async fn get_signed_between_times(
&self,
consensus_address: &str,
start_time: OffsetDateTime,
end_time: OffsetDateTime,
) -> Result<i64, PostgresScraperError> {
let Some(block_start) = self.get_first_block_height_after(start_time).await? else {
return Ok(0);
};
let Some(block_end) = self.get_last_block_height_before(end_time).await? else {
return Ok(0);
};
self.get_signed_between(consensus_address, block_start, block_end)
.await
}
pub async fn get_precommit(
&self,
consensus_address: &str,
height: i64,
) -> Result<Option<CommitSignature>, PostgresScraperError> {
Ok(self
.manager
.get_precommit(consensus_address, height)
.await?)
}
pub async fn get_block_signers(
&self,
height: i64,
) -> Result<Vec<Validator>, PostgresScraperError> {
Ok(self.manager.get_block_validators(height).await?)
}
pub async fn get_all_known_validators(&self) -> Result<Vec<Validator>, PostgresScraperError> {
Ok(self.manager.get_validators().await?)
}
pub async fn get_last_processed_height(&self) -> Result<i64, PostgresScraperError> {
Ok(self.manager.get_last_processed_height().await?)
}
pub async fn get_pruned_height(&self) -> Result<i64, PostgresScraperError> {
Ok(self.manager.get_pruned_height().await?)
}
}
#[async_trait]
impl NyxdScraperStorage for PostgresScraperStorage {
type StorageTransaction = PostgresStorageTransaction;
async fn initialise(
storage: &str,
run_migrations: &bool,
) -> Result<Self, NyxdScraperStorageError> {
PostgresScraperStorage::init(storage, run_migrations)
.await
.map_err(NyxdScraperStorageError::from)
}
async fn begin_processing_tx(
&self,
) -> Result<Self::StorageTransaction, NyxdScraperStorageError> {
self.begin_processing_tx()
.await
.map_err(NyxdScraperStorageError::from)
}
async fn get_last_processed_height(&self) -> Result<i64, NyxdScraperStorageError> {
self.get_last_processed_height()
.await
.map_err(NyxdScraperStorageError::from)
}
async fn get_pruned_height(&self) -> Result<i64, NyxdScraperStorageError> {
self.get_pruned_height()
.await
.map_err(NyxdScraperStorageError::from)
}
async fn lowest_block_height(&self) -> Result<Option<i64>, NyxdScraperStorageError> {
self.lowest_block_height()
.await
.map_err(NyxdScraperStorageError::from)
}
async fn prune_storage(
&self,
oldest_to_keep: u32,
current_height: u32,
) -> Result<(), NyxdScraperStorageError> {
self.prune_storage(oldest_to_keep, current_height)
.await
.map_err(NyxdScraperStorageError::from)
}
}
@@ -0,0 +1,25 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use cosmrs::AccountId;
use itertools::Itertools;
use nyxd_scraper_shared::ParsedTransactionResponse;
use std::str::FromStr;
// replicate behaviour of `CosmosMessageAddressesParser` from juno
pub(crate) fn parse_addresses_from_events(tx: &ParsedTransactionResponse) -> Vec<String> {
let mut addresses: Vec<String> = Vec::new();
for event in &tx.tx_result.events {
for attribute in &event.attributes {
let Ok(value) = attribute.value_str() else {
continue;
};
// Try parsing the address as an account address
if let Ok(address) = AccountId::from_str(value) {
addresses.push(address.to_string());
}
}
}
addresses.into_iter().unique().collect()
}
@@ -0,0 +1,543 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::storage::models::{CommitSignature, Validator};
use nyxd_scraper_shared::storage::helpers::log_db_operation_time;
use sqlx::types::JsonValue;
use sqlx::types::time::PrimitiveDateTime;
use sqlx::{Executor, Postgres};
use tokio::time::Instant;
use tracing::{instrument, trace};
#[derive(Clone)]
pub(crate) struct StorageManager {
pub(crate) connection_pool: sqlx::Pool<Postgres>,
}
impl StorageManager {
pub(crate) async fn set_initial_metadata(&self) -> Result<(), sqlx::Error> {
if sqlx::query("SELECT * from metadata")
.fetch_optional(&self.connection_pool)
.await?
.is_none()
{
sqlx::query("INSERT INTO metadata (id, last_processed_height) VALUES (0, 0)")
.execute(&self.connection_pool)
.await?;
}
Ok(())
}
pub(crate) async fn get_lowest_block(&self) -> Result<Option<i64>, sqlx::Error> {
trace!("get_lowest_block");
let start = Instant::now();
let maybe_record = sqlx::query!(
r#"
SELECT height
FROM block
ORDER BY height ASC
LIMIT 1
"#,
)
.fetch_optional(&self.connection_pool)
.await?;
log_db_operation_time("get_lowest_block", start);
Ok(maybe_record.map(|x| x.height))
}
pub(crate) async fn get_first_block_height_after(
&self,
time: PrimitiveDateTime,
) -> Result<Option<i64>, sqlx::Error> {
trace!("get_first_block_height_after");
let start = Instant::now();
let maybe_record = sqlx::query!(
r#"
SELECT height
FROM block
WHERE timestamp > $1
ORDER BY timestamp
LIMIT 1
"#,
time
)
.fetch_optional(&self.connection_pool)
.await?;
log_db_operation_time("get_first_block_height_after", start);
Ok(maybe_record.map(|x| x.height))
}
pub(crate) async fn get_last_block_height_before(
&self,
time: PrimitiveDateTime,
) -> Result<Option<i64>, sqlx::Error> {
trace!("get_last_block_height_before");
let start = Instant::now();
let maybe_record = sqlx::query!(
r#"
SELECT height
FROM block
WHERE timestamp < $1
ORDER BY timestamp DESC
LIMIT 1
"#,
time
)
.fetch_optional(&self.connection_pool)
.await?;
log_db_operation_time("get_last_block_height_before", start);
Ok(maybe_record.map(|x| x.height))
}
pub(crate) async fn get_signed_between(
&self,
consensus_address: &str,
start_height: i64,
end_height: i64,
) -> Result<i64, sqlx::Error> {
trace!("get_signed_between");
let start = Instant::now();
let count = sqlx::query!(
r#"
SELECT COUNT(*) as count FROM pre_commit
WHERE
validator_address = $1
AND height >= $2
AND height <= $3
"#,
consensus_address,
start_height,
end_height
)
.fetch_one(&self.connection_pool)
.await?
.count;
log_db_operation_time("get_signed_between", start);
Ok(count.unwrap_or(0))
}
pub(crate) async fn get_precommit(
&self,
consensus_address: &str,
height: i64,
) -> Result<Option<CommitSignature>, sqlx::Error> {
trace!("get_precommit");
let start = Instant::now();
let res = sqlx::query_as(
r#"
SELECT * FROM pre_commit
WHERE validator_address = $1
AND height = $2
"#,
)
.bind(consensus_address)
.bind(height)
.fetch_optional(&self.connection_pool)
.await?;
log_db_operation_time("get_precommit", start);
Ok(res)
}
pub(crate) async fn get_block_validators(
&self,
height: i64,
) -> Result<Vec<Validator>, sqlx::Error> {
trace!("get_block_validators");
let start = Instant::now();
let res = sqlx::query_as!(
Validator,
r#"
SELECT * FROM validator
WHERE EXISTS (
SELECT 1 FROM pre_commit
WHERE height = $1
AND pre_commit.validator_address = validator.consensus_address
)
"#,
height
)
.fetch_all(&self.connection_pool)
.await?;
log_db_operation_time("get_block_validators", start);
Ok(res)
}
pub(crate) async fn get_validators(&self) -> Result<Vec<Validator>, sqlx::Error> {
trace!("get_validators");
let start = Instant::now();
let res = sqlx::query_as("SELECT * FROM validator")
.fetch_all(&self.connection_pool)
.await?;
log_db_operation_time("get_validators", start);
Ok(res)
}
pub(crate) async fn get_last_processed_height(&self) -> Result<i64, sqlx::Error> {
trace!("get_last_processed_height");
let start = Instant::now();
let maybe_record = sqlx::query!(
r#"
SELECT last_processed_height FROM metadata
"#
)
.fetch_optional(&self.connection_pool)
.await?;
log_db_operation_time("get_last_processed_height", start);
if let Some(row) = maybe_record {
#[allow(clippy::useless_conversion)]
Ok(row.last_processed_height.into())
} else {
Ok(-1)
}
}
pub(crate) async fn get_pruned_height(&self) -> Result<i64, sqlx::Error> {
trace!("get_pruned_height");
let start = Instant::now();
let maybe_record = sqlx::query!(
r#"
SELECT last_pruned_height FROM pruning
"#
)
.fetch_optional(&self.connection_pool)
.await?;
log_db_operation_time("get_pruned_height", start);
if let Some(row) = maybe_record {
Ok(row.last_pruned_height)
} else {
Ok(-1)
}
}
}
// make those generic over executor so that they could be performed over connection pool and a tx
#[instrument(skip(executor))]
pub(crate) async fn insert_validator<'a, E>(
consensus_address: String,
consensus_pubkey: String,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("insert_validator");
let start = Instant::now();
sqlx::query!(
r#"
INSERT INTO validator (consensus_address, consensus_pubkey)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
consensus_address,
consensus_pubkey
)
.execute(executor)
.await?;
log_db_operation_time("insert_validator", start);
Ok(())
}
#[instrument(skip(executor))]
pub(crate) async fn insert_block<'a, E>(
height: i64,
hash: String,
num_txs: i32,
total_gas: i64,
proposer_address: String,
timestamp: PrimitiveDateTime,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("insert_block");
let start = Instant::now();
sqlx::query!(
r#"
INSERT INTO block (height, hash, num_txs, total_gas, proposer_address, timestamp)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
"#,
height,
hash,
num_txs,
total_gas,
proposer_address,
timestamp
)
.execute(executor)
.await?;
log_db_operation_time("insert_block", start);
Ok(())
}
#[instrument(skip(executor))]
pub(crate) async fn insert_precommit<'a, E>(
validator_address: String,
height: i64,
timestamp: PrimitiveDateTime,
voting_power: i64,
proposer_priority: i64,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("insert_precommit");
let start = Instant::now();
sqlx::query!(
r#"
INSERT INTO pre_commit (validator_address, height, timestamp, voting_power, proposer_priority)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (validator_address, timestamp) DO NOTHING
"#,
validator_address,
height,
timestamp,
voting_power,
proposer_priority
)
.execute(executor)
.await?;
log_db_operation_time("insert_precommit", start);
Ok(())
}
#[instrument(skip(executor))]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_transaction<'a, E>(
hash: String,
height: i64,
index: i32,
success: bool,
messages: JsonValue,
memo: String,
signatures: Vec<String>,
signer_infos: JsonValue,
fee: JsonValue,
gas_wanted: i64,
gas_used: i64,
raw_log: String,
logs: JsonValue,
events: JsonValue,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("insert_transaction");
let start = Instant::now();
sqlx::query!(
r#"
INSERT INTO transaction
(hash, height, index, success, messages, memo, signatures, signer_infos, fee, gas_wanted, gas_used, raw_log, logs, events)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (hash) DO UPDATE
SET height = excluded.height,
index = excluded.index,
success = excluded.success,
messages = excluded.messages,
memo = excluded.memo,
signatures = excluded.signatures,
signer_infos = excluded.signer_infos,
fee = excluded.fee,
gas_wanted = excluded.gas_wanted,
gas_used = excluded.gas_used,
raw_log = excluded.raw_log,
logs = excluded.logs,
events = excluded.events
"#,
hash,
height,
index,
success,
messages,
memo,
&signatures,
signer_infos,
fee,
gas_wanted,
gas_used,
raw_log,
logs,
events,
)
.execute(executor)
.await?;
log_db_operation_time("insert_transaction", start);
Ok(())
}
#[allow(clippy::too_many_arguments)]
#[instrument(skip(executor))]
pub(crate) async fn insert_message<'a, E>(
transaction_hash: String,
index: i64,
typ: String,
value: JsonValue,
involved_account_addresses: Vec<String>,
height: i64,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("insert_message");
let start = Instant::now();
sqlx::query!(
r#"
INSERT INTO message(transaction_hash, index, type, value, involved_accounts_addresses, height)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (transaction_hash, index) DO UPDATE
SET height = excluded.height,
type = excluded.type,
value = excluded.value,
involved_accounts_addresses = excluded.involved_accounts_addresses
"#,
transaction_hash,
index,
typ,
value,
&involved_account_addresses,
height,
)
.execute(executor)
.await?;
log_db_operation_time("insert_message", start);
Ok(())
}
#[instrument(skip(executor))]
pub(crate) async fn update_last_processed<'a, E>(
height: i64,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("update_last_processed");
let start = Instant::now();
sqlx::query!(
"UPDATE metadata SET last_processed_height = GREATEST(last_processed_height, $1)",
height as i32
)
.execute(executor)
.await?;
log_db_operation_time("update_last_processed", start);
Ok(())
}
#[instrument(skip(executor))]
pub(crate) async fn update_last_pruned<'a, E>(height: i64, executor: E) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("update_last_pruned");
let start = Instant::now();
sqlx::query!("UPDATE pruning SET last_pruned_height = $1", height)
.execute(executor)
.await?;
log_db_operation_time("update_last_pruned", start);
Ok(())
}
pub(crate) async fn prune_blocks<'a, E>(oldest_to_keep: i64, executor: E) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("prune_blocks");
let start = Instant::now();
sqlx::query!("DELETE FROM block WHERE height < $1", oldest_to_keep)
.execute(executor)
.await?;
log_db_operation_time("prune_blocks", start);
Ok(())
}
pub(crate) async fn prune_pre_commits<'a, E>(
oldest_to_keep: i64,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("prune_pre_commits");
let start = Instant::now();
sqlx::query!("DELETE FROM pre_commit WHERE height < $1", oldest_to_keep)
.execute(executor)
.await?;
log_db_operation_time("prune_pre_commits", start);
Ok(())
}
pub(crate) async fn prune_transactions<'a, E>(
oldest_to_keep: i64,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("prune_transactions");
let start = Instant::now();
sqlx::query!("DELETE FROM transaction WHERE height < $1", oldest_to_keep)
.execute(executor)
.await?;
log_db_operation_time("prune_transactions", start);
Ok(())
}
pub(crate) async fn prune_messages<'a, E>(
oldest_to_keep: i64,
executor: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = Postgres>,
{
trace!("prune_messages");
let start = Instant::now();
sqlx::query!("DELETE FROM message WHERE height < $1", oldest_to_keep)
.execute(executor)
.await?;
log_db_operation_time("prune_messages", start);
Ok(())
}
@@ -0,0 +1,8 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub mod block_storage;
mod helpers;
mod manager;
pub mod models;
pub mod transaction;
@@ -0,0 +1,47 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use sqlx::types::time::OffsetDateTime;
#[derive(Debug, Clone, Eq, PartialEq, Hash, FromRow)]
pub struct Validator {
pub consensus_address: String,
pub consensus_pubkey: String,
}
#[derive(Debug, Clone, FromRow)]
pub struct Block {
pub height: i64,
pub hash: String,
pub num_txs: u32,
pub total_gas: i64,
pub proposer_address: String,
pub timestamp: OffsetDateTime,
}
#[derive(Debug, Clone, FromRow)]
pub struct CommitSignature {
pub height: i64,
pub validator_address: String,
pub voting_power: i64,
pub proposer_priority: i64,
pub timestamp: OffsetDateTime,
}
#[derive(Debug, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "coin")]
pub struct DbCoin {
pub amount: String,
pub denom: String,
}
impl From<cosmrs::proto::cosmos::base::v1beta1::Coin> for DbCoin {
fn from(coin: cosmrs::proto::cosmos::base::v1beta1::Coin) -> Self {
Self {
amount: coin.amount,
denom: coin.denom,
}
}
}
@@ -0,0 +1,299 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::PostgresScraperError;
use crate::storage::helpers::parse_addresses_from_events;
use crate::storage::manager::{
insert_block, insert_message, insert_precommit, insert_transaction, insert_validator,
update_last_processed,
};
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose;
use cosmrs::proto;
use nyxd_scraper_shared::ParsedTransactionResponse;
use nyxd_scraper_shared::helpers::{
validator_consensus_address, validator_info, validator_pubkey_to_bech32,
};
use nyxd_scraper_shared::storage::validators::Response;
use nyxd_scraper_shared::storage::{
Block, Commit, CommitSig, NyxdScraperStorageError, NyxdScraperTransaction, validators,
};
use serde_json::json;
use sqlx::types::time::{OffsetDateTime, PrimitiveDateTime};
use sqlx::{Postgres, Transaction};
use std::ops::{Deref, DerefMut};
use tracing::{debug, error, trace, warn};
pub struct PostgresStorageTransaction {
pub(super) inner: Transaction<'static, Postgres>,
}
impl Deref for PostgresStorageTransaction {
type Target = Transaction<'static, Postgres>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for PostgresStorageTransaction {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
impl PostgresStorageTransaction {
async fn persist_validators(
&mut self,
validators: &validators::Response,
) -> Result<(), PostgresScraperError> {
debug!("persisting {} validators", validators.total);
for validator in &validators.validators {
let consensus_address = validator_consensus_address(validator.address)?;
let consensus_pubkey = validator_pubkey_to_bech32(validator.pub_key)?;
insert_validator(
consensus_address.to_string(),
consensus_pubkey.to_string(),
self.inner.as_mut(),
)
.await?;
}
Ok(())
}
async fn persist_block_data(
&mut self,
block: &Block,
total_gas: i64,
) -> Result<(), PostgresScraperError> {
let proposer_address =
validator_consensus_address(block.header.proposer_address)?.to_string();
let offset_datetime: OffsetDateTime = block.header.time.into();
let time = PrimitiveDateTime::new(offset_datetime.date(), offset_datetime.time());
insert_block(
block.header.height.into(),
block.header.hash().to_string(),
block.data.len() as i32,
total_gas,
proposer_address,
time,
self.inner.as_mut(),
)
.await?;
Ok(())
}
async fn persist_commits(
&mut self,
commits: &Commit,
validators: &validators::Response,
) -> Result<(), PostgresScraperError> {
debug!("persisting up to {} commits", commits.signatures.len());
let height: i64 = commits.height.into();
for commit_sig in &commits.signatures {
let (validator_id, timestamp, signature) = match commit_sig {
CommitSig::BlockIdFlagAbsent => {
trace!("absent signature");
continue;
}
CommitSig::BlockIdFlagCommit {
validator_address,
timestamp,
signature,
} => (validator_address, timestamp, signature),
CommitSig::BlockIdFlagNil {
validator_address,
timestamp,
signature,
} => (validator_address, timestamp, signature),
};
let validator = validator_info(*validator_id, validators)?;
let validator_address = validator_consensus_address(*validator_id)?;
if signature.is_none() {
warn!("empty signature for {validator_address} at height {height}");
continue;
}
let offset_datetime: OffsetDateTime = (*timestamp).into();
let time = PrimitiveDateTime::new(offset_datetime.date(), offset_datetime.time());
insert_precommit(
validator_address.to_string(),
height,
time,
validator.power.into(),
validator.proposer_priority.value(),
self.inner.as_mut(),
)
.await?;
}
Ok(())
}
async fn persist_txs(
&mut self,
txs: &[ParsedTransactionResponse],
) -> Result<(), PostgresScraperError> {
debug!("persisting {} txs", txs.len());
for chain_tx in txs {
// bdjuno style, base64 encode them
let signatures = chain_tx
.tx
.signatures
.iter()
.map(|sig| general_purpose::STANDARD.encode(sig))
.collect();
let messages = chain_tx
.parsed_messages
.values()
.cloned()
.collect::<Vec<_>>();
let signer_infos = chain_tx
.tx
.auth_info
.signer_infos
.iter()
.map(|info| proto::cosmos::tx::v1beta1::SignerInfo::from(info.clone()))
.collect::<Vec<_>>();
let hash = chain_tx.hash.to_string();
let height = chain_tx.height.into();
let index = chain_tx.index as i32;
let log = serde_json::to_value(chain_tx.tx_result.log.clone())
.inspect_err(|e| error!(hash, height, index, "Failed to parse logs: {e}"))
.unwrap_or_default();
let events = &chain_tx.tx_result.events;
insert_transaction(
hash,
height,
index,
chain_tx.tx_result.code.is_ok(),
serde_json::Value::Array(messages),
chain_tx.tx.body.memo.clone(),
signatures,
serde_json::to_value(signer_infos)?,
serde_json::to_value(&chain_tx.tx.auth_info.fee)?,
chain_tx.tx_result.gas_wanted,
chain_tx.tx_result.gas_used,
chain_tx.tx_result.log.clone(),
json!(log),
json!(events),
self.inner.as_mut(),
)
.await?;
}
Ok(())
}
async fn persist_messages(
&mut self,
txs: &[ParsedTransactionResponse],
) -> Result<(), PostgresScraperError> {
debug!("persisting messages");
for chain_tx in txs {
let involved_addresses = parse_addresses_from_events(chain_tx);
for (index, msg) in chain_tx.tx.body.messages.iter().enumerate() {
let parsed_message = chain_tx.parsed_messages.get(&index);
let value = serde_json::to_value(parsed_message)?;
insert_message(
chain_tx.hash.to_string(),
index as i64,
msg.type_url.clone(),
value,
involved_addresses.clone(),
chain_tx.height.into(),
self.inner.as_mut(),
)
.await?
}
}
Ok(())
}
async fn update_last_processed(&mut self, height: i64) -> Result<(), PostgresScraperError> {
debug!("update_last_processed");
update_last_processed(height, self.inner.as_mut()).await?;
Ok(())
}
}
#[async_trait]
impl NyxdScraperTransaction for PostgresStorageTransaction {
async fn commit(self) -> Result<(), NyxdScraperStorageError> {
self.inner
.commit()
.await
.map_err(PostgresScraperError::from)
.map_err(NyxdScraperStorageError::from)
}
async fn persist_validators(
&mut self,
validators: &Response,
) -> Result<(), NyxdScraperStorageError> {
self.persist_validators(validators)
.await
.map_err(NyxdScraperStorageError::from)
}
async fn persist_block_data(
&mut self,
block: &Block,
total_gas: i64,
) -> Result<(), NyxdScraperStorageError> {
self.persist_block_data(block, total_gas)
.await
.map_err(NyxdScraperStorageError::from)
}
async fn persist_commits(
&mut self,
commits: &Commit,
validators: &Response,
) -> Result<(), NyxdScraperStorageError> {
self.persist_commits(commits, validators)
.await
.map_err(NyxdScraperStorageError::from)
}
async fn persist_txs(
&mut self,
txs: &[ParsedTransactionResponse],
) -> Result<(), NyxdScraperStorageError> {
self.persist_txs(txs)
.await
.map_err(NyxdScraperStorageError::from)
}
async fn persist_messages(
&mut self,
txs: &[ParsedTransactionResponse],
) -> Result<(), NyxdScraperStorageError> {
self.persist_messages(txs)
.await
.map_err(NyxdScraperStorageError::from)
}
async fn update_last_processed(&mut self, height: i64) -> Result<(), NyxdScraperStorageError> {
self.update_last_processed(height)
.await
.map_err(NyxdScraperStorageError::from)
}
}
@@ -1,5 +1,5 @@
[package]
name = "nyxd-scraper"
name = "nyxd-scraper-shared"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
@@ -8,19 +8,22 @@ documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
readme.workspace = true
[dependencies]
async-trait.workspace = true
base64.workspace = true
const_format = { workspace = true }
cosmrs.workspace = true
cosmos-sdk-proto = { workspace = true, features = ["serde", "cosmwasm"] } # we need to explicitly include serde feature
eyre = { workspace = true }
futures.workspace = true
humantime = { workspace = true }
ibc-proto = { workspace = true, features = ["serde"] }
prost = { workspace = true }
sha2 = { workspace = true }
serde = { workspace = true, features = ["derive"] }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate", "time"] }
serde_json = { workspace = true }
tendermint.workspace = true
tendermint-rpc = { workspace = true, features = ["websocket-client", "http-client"] }
thiserror.workspace = true
@@ -32,11 +35,5 @@ tracing.workspace = true
url.workspace = true
# TEMP
#nym-bin-common = { path = "../bin-common", features = ["basic_tracing"]}
[build-dependencies]
anyhow = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[lints]
workspace = true
@@ -8,7 +8,7 @@ use crate::block_requester::BlockRequest;
use crate::error::ScraperError;
use crate::modules::{BlockModule, MsgModule, TxModule};
use crate::rpc_client::RpcClient;
use crate::storage::{ScraperStorage, persist_block};
use crate::storage::{NyxdScraperStorage, NyxdScraperTransaction, persist_block};
use futures::StreamExt;
use std::cmp::max;
use std::collections::{BTreeMap, HashSet, VecDeque};
@@ -77,7 +77,7 @@ impl BlockProcessorConfig {
}
}
pub struct BlockProcessor {
pub struct BlockProcessor<S> {
config: BlockProcessorConfig,
cancel: CancellationToken,
synced: Arc<Notify>,
@@ -90,7 +90,7 @@ pub struct BlockProcessor {
rpc_client: RpcClient,
incoming: UnboundedReceiverStream<BlockToProcess>,
block_requester: Sender<BlockRequest>,
storage: ScraperStorage,
storage: S,
// future work: rather than sending each msg to every msg module,
// let them subscribe based on `type_url` inside the message itself
@@ -101,14 +101,17 @@ pub struct BlockProcessor {
}
#[allow(clippy::too_many_arguments)]
impl BlockProcessor {
impl<S> BlockProcessor<S>
where
S: NyxdScraperStorage,
{
pub async fn new(
config: BlockProcessorConfig,
cancel: CancellationToken,
synced: Arc<Notify>,
incoming: UnboundedReceiver<BlockToProcess>,
block_requester: Sender<BlockRequest>,
storage: ScraperStorage,
storage: S,
rpc_client: RpcClient,
) -> Result<Self, ScraperError> {
let last_processed = storage.get_last_processed_height().await?;
@@ -164,7 +167,11 @@ impl BlockProcessor {
// process the entire block as a transaction so that if anything fails,
// we won't end up with a corrupted storage.
let mut tx = self.storage.begin_processing_tx().await?;
let mut tx = self
.storage
.begin_processing_tx()
.await
.map_err(ScraperError::tx_begin_failure)?;
persist_block(&full_info, &mut tx, self.config.store_precommits).await?;
@@ -192,10 +199,8 @@ impl BlockProcessor {
}
let commit_start = Instant::now();
tx.commit()
.await
.map_err(|source| ScraperError::StorageTxCommitFailure { source })?;
crate::storage::log_db_operation_time("committing processing tx", commit_start);
tx.commit().await.map_err(ScraperError::tx_commit_failure)?;
crate::storage::helpers::log_db_operation_time("committing processing tx", commit_start);
self.last_processed_height = full_info.block.header.height.value() as u32;
self.last_processed_at = Instant::now();

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