diff --git a/Cargo.lock b/Cargo.lock index 1d72eae859..179dc53b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5835,13 +5835,16 @@ dependencies = [ "nym-credentials-interface", "nym-crypto", "nym-pemstore", + "nym-serde-helpers", "nym-sphinx", "nym-task", "rand 0.8.5", "serde", "serde_json", "strum 0.26.3", + "subtle 2.6.1", "thiserror 2.0.11", + "time", "tokio", "tracing", "tungstenite 0.20.1", diff --git a/common/client-libs/gateway-client/src/client/mod.rs b/common/client-libs/gateway-client/src/client/mod.rs index b72c7a033c..88a55dd850 100644 --- a/common/client-libs/gateway-client/src/client/mod.rs +++ b/common/client-libs/gateway-client/src/client/mod.rs @@ -20,8 +20,8 @@ use nym_credentials_interface::TicketType; use nym_crypto::asymmetric::identity; use nym_gateway_requests::registration::handshake::client_handshake; use nym_gateway_requests::{ - BinaryRequest, ClientControlRequest, ClientRequest, SensitiveServerResponse, ServerResponse, - SharedGatewayKey, SharedSymmetricKey, AES_GCM_SIV_PROTOCOL_VERSION, + BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersionExt, + SensitiveServerResponse, ServerResponse, SharedGatewayKey, SharedSymmetricKey, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION, }; use nym_sphinx::forwarding::packet::MixPacket; @@ -563,28 +563,10 @@ impl GatewayClient { Ok(zeroizing_updated_key) } - async fn authenticate(&mut self) -> Result<(), GatewayClientError> { - let Some(shared_key) = self.shared_key.as_ref() else { - return Err(GatewayClientError::NoSharedKeyAvailable); - }; - - if !self.connection.is_established() { - return Err(GatewayClientError::ConnectionNotEstablished); - } - debug!("authenticating with gateway"); - - let self_address = self - .local_identity - .as_ref() - .public_key() - .derive_destination_address(); - - let msg = ClientControlRequest::new_authenticate( - self_address, - shared_key, - self.cfg.bandwidth.require_tickets, - )?; - + async fn send_authenticate_request_and_handle_response( + &mut self, + msg: ClientControlRequest, + ) -> Result<(), GatewayClientError> { match self.send_websocket_message(msg).await? { ServerResponse::Authenticate { protocol_version, @@ -608,6 +590,51 @@ impl GatewayClient { } } + 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_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) -> Result<(), GatewayClientError> { + debug!("using v2 authentication"); + let Some(shared_key) = self.shared_key.as_ref() else { + return Err(GatewayClientError::NoSharedKeyAvailable); + }; + + let msg = ClientControlRequest::new_authenticate_v2(shared_key, &self.local_identity)?; + self.send_authenticate_request_and_handle_response(msg) + .await + } + + async fn authenticate(&mut self, use_v2: bool) -> Result<(), GatewayClientError> { + if !self.connection.is_established() { + return Err(GatewayClientError::ConnectionNotEstablished); + } + debug!("authenticating with gateway"); + + if use_v2 { + self.authenticate_v2().await + } else { + self.authenticate_v1().await + } + } + /// Helper method to either call register or authenticate based on self.shared_key value #[instrument(skip_all, fields( @@ -623,19 +650,25 @@ impl GatewayClient { } // 1. check gateway's protocol version - let supports_aes_gcm_siv = match self.get_gateway_protocol().await { - Ok(protocol) => protocol >= AES_GCM_SIV_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?; - false + None } }; + let supports_aes_gcm_siv = gw_protocol.supports_aes256_gcm_siv(); + let supports_auth_v2 = gw_protocol.supports_authenticate_v2(); + if !supports_aes_gcm_siv { warn!("this gateway is on an old version that doesn't support AES256-GCM-SIV"); } + if !supports_auth_v2 { + warn!("this gateway is on an old version that doesn't support authentication v2") + } if self.authenticated { debug!("Already authenticated"); @@ -650,7 +683,7 @@ impl GatewayClient { } if self.shared_key.is_some() { - self.authenticate().await?; + self.authenticate(supports_auth_v2).await?; if self.authenticated { // if we are authenticated it means we MUST have an associated shared_key @@ -983,7 +1016,8 @@ impl GatewayClient { } // if we're reconnecting, because we lost connection, we need to re-authenticate the connection - self.authenticate().await?; + self.authenticate(self.negotiated_protocol.supports_authenticate_v2()) + .await?; // this call is NON-blocking self.start_listening_for_mixnet_messages()?; diff --git a/common/gateway-requests/Cargo.toml b/common/gateway-requests/Cargo.toml index b461c428c1..3448184eae 100644 --- a/common/gateway-requests/Cargo.toml +++ b/common/gateway-requests/Cargo.toml @@ -20,11 +20,14 @@ serde_json = { workspace = true } strum = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true, features = ["log"] } +time = { workspace = true } +subtle = { workspace = true } zeroize = { workspace = true } nym-crypto = { path = "../crypto", features = ["aead", "hashing"] } nym-pemstore = { path = "../pemstore" } nym-sphinx = { path = "../nymsphinx" } +nym-serde-helpers = { path = "../serde-helpers", features = ["base64"] } nym-task = { path = "../task" } nym-credentials = { path = "../credentials" } diff --git a/common/gateway-requests/src/authentication/encrypted_address.rs b/common/gateway-requests/src/authentication/encrypted_address.rs index 8b81074454..772b8ca91b 100644 --- a/common/gateway-requests/src/authentication/encrypted_address.rs +++ b/common/gateway-requests/src/authentication/encrypted_address.rs @@ -15,6 +15,12 @@ use thiserror::Error; // this is no longer constant size due to the differences in ciphertext between aes128ctr and aes256gcm-siv (inclusion of tag) pub struct EncryptedAddressBytes(Vec); +impl From> for EncryptedAddressBytes { + fn from(encrypted_address: Vec) -> Self { + EncryptedAddressBytes(encrypted_address) + } +} + #[derive(Debug, Error)] pub enum EncryptedAddressConversionError { #[error("Failed to decode the encrypted address - {0}")] diff --git a/common/gateway-requests/src/lib.rs b/common/gateway-requests/src/lib.rs index b7af197cc5..0d300bc4a4 100644 --- a/common/gateway-requests/src/lib.rs +++ b/common/gateway-requests/src/lib.rs @@ -19,7 +19,7 @@ pub use shared_key::{ SharedGatewayKey, SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey, }; -pub const CURRENT_PROTOCOL_VERSION: u8 = AES_GCM_SIV_PROTOCOL_VERSION; +pub const CURRENT_PROTOCOL_VERSION: u8 = AUTHENTICATE_V2_PROTOCOL_VERSION; /// Defines the current version of the communication protocol between gateway and clients. /// It has to be incremented for any breaking change. @@ -27,10 +27,29 @@ pub const CURRENT_PROTOCOL_VERSION: u8 = AES_GCM_SIV_PROTOCOL_VERSION; // 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 pub const INITIAL_PROTOCOL_VERSION: u8 = 1; pub const CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION: u8 = 2; pub const AES_GCM_SIV_PROTOCOL_VERSION: u8 = 3; +pub const AUTHENTICATE_V2_PROTOCOL_VERSION: u8 = 4; // TODO: could using `Mac` trait here for OutputSize backfire? // Should hmac itself be exposed, imported and used instead? pub type LegacyGatewayMacSize = ::OutputSize; + +pub trait GatewayProtocolVersionExt { + fn supports_aes256_gcm_siv(&self) -> bool; + fn supports_authenticate_v2(&self) -> bool; +} + +impl GatewayProtocolVersionExt for Option { + fn supports_aes256_gcm_siv(&self) -> bool { + let Some(protocol) = *self else { return false }; + protocol >= AES_GCM_SIV_PROTOCOL_VERSION + } + + fn supports_authenticate_v2(&self) -> bool { + let Some(protocol) = *self else { return false }; + protocol >= AUTHENTICATE_V2_PROTOCOL_VERSION + } +} diff --git a/common/gateway-requests/src/types/error.rs b/common/gateway-requests/src/types/error.rs index 31d94b75b7..0a785e60ec 100644 --- a/common/gateway-requests/src/types/error.rs +++ b/common/gateway-requests/src/types/error.rs @@ -3,6 +3,7 @@ use crate::SharedKeyUsageError; use nym_credentials_interface::CompactEcashError; +use nym_crypto::asymmetric::ed25519::SignatureError; use nym_sphinx::addressing::nodes::NymNodeRoutingAddressError; use nym_sphinx::forwarding::packet::MixPacketFormattingError; use nym_sphinx::params::packet_sizes::PacketSize; @@ -92,7 +93,34 @@ pub enum GatewayRequestsError { #[error("the provided [v1] credential has invalid number of parameters - {0}")] InvalidNumberOfEmbededParameters(u32), + #[error("failed to authenticate the client: {0}")] + Authentication(#[from] AuthenticationFailure), + // variant to catch legacy errors #[error("{0}")] Other(String), } + +#[derive(Debug, Error)] +pub enum AuthenticationFailure { + #[error(transparent)] + KeyUsageFailure(#[from] SharedKeyUsageError), + + #[error("failed to verify provided address ciphertext")] + MalformedCiphertext, + + #[error("failed to verify request signature")] + InvalidSignature(#[from] SignatureError), + + #[error("provided request timestamp is in the future")] + RequestTimestampInFuture, + + #[error("the client is not registered")] + NotRegistered, + + #[error("the provided request is too stale to process")] + StaleRequest, + + #[error("the provided request timestamp is smaller or equal to a one previously used")] + RequestReuse, +} diff --git a/common/gateway-requests/src/types/text_request/authenticate.rs b/common/gateway-requests/src/types/text_request/authenticate.rs new file mode 100644 index 0000000000..6015beb8ad --- /dev/null +++ b/common/gateway-requests/src/types/text_request/authenticate.rs @@ -0,0 +1,142 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::{AuthenticationFailure, GatewayRequestsError, SharedGatewayKey}; +use nym_crypto::asymmetric::ed25519; +use serde::{Deserialize, Serialize}; +use std::iter; +use std::time::Duration; +use subtle::ConstantTimeEq; +use time::OffsetDateTime; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticateRequest { + #[serde(flatten)] + pub content: AuthenticateRequestContent, + + pub request_signature: ed25519::Signature, +} + +impl AuthenticateRequest { + pub fn new( + protocol_version: u8, + shared_key: &SharedGatewayKey, + identity_keys: &ed25519::KeyPair, + ) -> Result { + let content = AuthenticateRequestContent::new( + protocol_version, + shared_key, + *identity_keys.public_key(), + )?; + let plaintext = content.plaintext(); + let request_signature = identity_keys.private_key().sign(&plaintext); + + Ok(AuthenticateRequest { + content, + request_signature, + }) + } + + pub fn verify_timestamp(&self, max_request_age: Duration) -> Result<(), AuthenticationFailure> { + let now = OffsetDateTime::now_utc(); + if self.content.request_timestamp() + max_request_age < now { + return Err(AuthenticationFailure::StaleRequest); + } + if self.content.request_timestamp() > now { + return Err(AuthenticationFailure::RequestTimestampInFuture); + } + Ok(()) + } + + pub fn ensure_timestamp_not_reused( + &self, + previous: OffsetDateTime, + ) -> Result<(), AuthenticationFailure> { + if self.content.request_timestamp() <= previous { + return Err(AuthenticationFailure::RequestReuse); + } + Ok(()) + } + + pub fn verify_ciphertext( + &self, + shared_key: &SharedGatewayKey, + ) -> Result<(), AuthenticationFailure> { + let expected = shared_key.encrypt( + self.content + .client_identity + .derive_destination_address() + .as_bytes_ref(), + Some(&self.content.nonce), + )?; + + if !bool::from(expected.ct_eq(&self.content.address_ciphertext)) { + return Err(AuthenticationFailure::MalformedCiphertext); + } + Ok(()) + } + + pub fn verify_signature(&self) -> Result<(), AuthenticationFailure> { + let plaintext = self.content.plaintext(); + self.content + .client_identity + .verify(plaintext, &self.request_signature) + .map_err(Into::into) + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticateRequestContent { + pub protocol_version: u8, + + // this is identical to the client's address + pub client_identity: ed25519::PublicKey, + + #[serde(with = "nym_serde_helpers::base64")] + pub address_ciphertext: Vec, + + #[serde(with = "nym_serde_helpers::base64")] + pub nonce: Vec, + + pub request_unix_timestamp: u64, +} + +impl AuthenticateRequestContent { + fn new( + protocol_version: u8, + shared_key: &SharedGatewayKey, + client_identity: ed25519::PublicKey, + ) -> Result { + let nonce = shared_key.random_nonce_or_iv(); + let destination_address = client_identity.derive_destination_address(); + + let address_ciphertext = + shared_key.encrypt(destination_address.as_bytes_ref(), Some(&nonce))?; + let now = OffsetDateTime::now_utc(); + Ok(AuthenticateRequestContent { + protocol_version, + client_identity, + address_ciphertext, + nonce, + request_unix_timestamp: now.unix_timestamp() as u64, // SAFETY: we're running this in post 1970... + }) + } +} + +impl AuthenticateRequestContent { + pub fn plaintext(&self) -> Vec { + iter::once(self.protocol_version) + .chain(self.client_identity.to_bytes()) + .chain(self.address_ciphertext.iter().copied()) + .chain(self.nonce.iter().copied()) + .chain(self.request_unix_timestamp.to_be_bytes()) + .collect() + } + + pub fn request_timestamp(&self) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(self.request_unix_timestamp as i64) + .unwrap_or(OffsetDateTime::UNIX_EPOCH) + } +} diff --git a/common/gateway-requests/src/types/text_request.rs b/common/gateway-requests/src/types/text_request/mod.rs similarity index 89% rename from common/gateway-requests/src/types/text_request.rs rename to common/gateway-requests/src/types/text_request/mod.rs index 0fb864a533..7182dd834e 100644 --- a/common/gateway-requests/src/types/text_request.rs +++ b/common/gateway-requests/src/types/text_request/mod.rs @@ -2,16 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 use crate::models::CredentialSpendingRequest; +use crate::text_request::authenticate::AuthenticateRequest; use crate::{ GatewayRequestsError, SharedGatewayKey, SymmetricKey, AES_GCM_SIV_PROTOCOL_VERSION, - CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION, + AUTHENTICATE_V2_PROTOCOL_VERSION, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, + INITIAL_PROTOCOL_VERSION, }; use nym_credentials_interface::CredentialSpendingData; +use nym_crypto::asymmetric::ed25519; use nym_sphinx::DestinationAddressBytes; use serde::{Deserialize, Serialize}; use std::str::FromStr; use tungstenite::Message; +pub mod authenticate; + // wrapper for all encrypted requests for ease of use #[derive(Serialize, Deserialize, Debug, Clone)] #[non_exhaustive] @@ -68,6 +73,9 @@ pub enum ClientControlRequest { enc_address: String, iv: String, }, + + AuthenticateV2(Box), + #[serde(alias = "handshakePayload")] RegisterHandshakeInitRequest { #[serde(default)] @@ -123,9 +131,22 @@ impl ClientControlRequest { }) } + pub fn new_authenticate_v2( + shared_key: &SharedGatewayKey, + identity_keys: &ed25519::KeyPair, + ) -> Result { + // if we're using v2 authentication, we must announce at least that protocol version + let protocol_version = AUTHENTICATE_V2_PROTOCOL_VERSION; + + Ok(ClientControlRequest::AuthenticateV2(Box::new( + AuthenticateRequest::new(protocol_version, shared_key, identity_keys)?, + ))) + } + pub fn name(&self) -> String { match self { ClientControlRequest::Authenticate { .. } => "Authenticate".to_string(), + ClientControlRequest::AuthenticateV2(..) => "AuthenticateV2".to_string(), ClientControlRequest::RegisterHandshakeInitRequest { .. } => { "RegisterHandshakeInitRequest".to_string() } diff --git a/common/gateway-storage/migrations/20250303120000_add_authentication_timestamp.sql b/common/gateway-storage/migrations/20250303120000_add_authentication_timestamp.sql new file mode 100644 index 0000000000..e211803e9e --- /dev/null +++ b/common/gateway-storage/migrations/20250303120000_add_authentication_timestamp.sql @@ -0,0 +1,7 @@ +/* + * Copyright 2025 - Nym Technologies SA + * SPDX-License-Identifier: GPL-3.0-only + */ + +ALTER TABLE shared_keys + ADD COLUMN last_used_authentication TIMESTAMP WITHOUT TIME ZONE; \ No newline at end of file diff --git a/common/gateway-storage/src/lib.rs b/common/gateway-storage/src/lib.rs index 5f289ec106..2d05d43fe7 100644 --- a/common/gateway-storage/src/lib.rs +++ b/common/gateway-storage/src/lib.rs @@ -200,6 +200,20 @@ impl GatewayStorage { Ok(()) } + pub async fn update_last_used_authentication_timestamp( + &self, + client_id: i64, + last_used_authentication_timestamp: OffsetDateTime, + ) -> Result<(), GatewayStorageError> { + self.shared_key_manager + .update_last_used_authentication_timestamp( + client_id, + last_used_authentication_timestamp, + ) + .await?; + Ok(()) + } + pub async fn get_client(&self, client_id: i64) -> Result, GatewayStorageError> { let client = self.client_manager.get_client(client_id).await?; Ok(client) diff --git a/common/gateway-storage/src/models.rs b/common/gateway-storage/src/models.rs index c623f72602..665b8fee47 100644 --- a/common/gateway-storage/src/models.rs +++ b/common/gateway-storage/src/models.rs @@ -14,13 +14,13 @@ pub struct Client { #[derive(FromRow)] pub struct PersistedSharedKeys { - #[allow(dead_code)] pub client_id: i64, #[allow(dead_code)] pub client_address_bs58: String, pub derived_aes128_ctr_blake3_hmac_keys_bs58: Option, pub derived_aes256_gcm_siv_key: Option>, + pub last_used_authentication: Option, } impl TryFrom for SharedGatewayKey { diff --git a/common/gateway-storage/src/shared_keys.rs b/common/gateway-storage/src/shared_keys.rs index 9d17535fb2..a7ae832cac 100644 --- a/common/gateway-storage/src/shared_keys.rs +++ b/common/gateway-storage/src/shared_keys.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::models::PersistedSharedKeys; +use time::OffsetDateTime; #[derive(Clone)] pub(crate) struct SharedKeysManager { @@ -68,6 +69,22 @@ impl SharedKeysManager { Ok(()) } + pub(crate) async fn update_last_used_authentication_timestamp( + &self, + client_id: i64, + last_used: OffsetDateTime, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE shared_keys SET last_used_authentication = ? WHERE client_id = ?;", + last_used, + client_id + ) + .execute(&self.connection_pool) + .await?; + + Ok(()) + } + /// Tries to retrieve shared keys stored for the particular client. /// /// # Arguments @@ -77,13 +94,10 @@ impl SharedKeysManager { &self, client_address_bs58: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - PersistedSharedKeys, - "SELECT * FROM shared_keys WHERE client_address_bs58 = ?", - client_address_bs58 - ) - .fetch_optional(&self.connection_pool) - .await + sqlx::query_as("SELECT * FROM shared_keys WHERE client_address_bs58 = ?") + .bind(client_address_bs58) + .fetch_optional(&self.connection_pool) + .await } /// Removes from the database shared keys derived with the particular client. diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 66d6180f31..9039dd1102 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -98,6 +98,9 @@ pub struct Debug { pub stale_messages_max_age: Duration, pub zk_nym_tickets: ZkNymTicketHandlerDebug, + + /// Defines the maximum age of a signed authentication request before it's deemed too stale to process. + pub maximum_auth_request_age: Duration, } #[derive(Debug, Clone)] diff --git a/gateway/src/node/client_handling/active_clients.rs b/gateway/src/node/client_handling/active_clients.rs index 4a3e55bef5..eeff95d907 100644 --- a/gateway/src/node/client_handling/active_clients.rs +++ b/gateway/src/node/client_handling/active_clients.rs @@ -6,11 +6,20 @@ use crate::node::client_handling::embedded_clients::LocalEmbeddedClientHandle; use dashmap::DashMap; use nym_sphinx::DestinationAddressBytes; use std::sync::Arc; +use time::OffsetDateTime; use tracing::warn; +#[derive(Clone)] +pub(crate) struct RemoteClientData { + // note, this does **NOT** indicate timestamp of when client connected + // it is (for v2 auth) timestamp the client **signed** when it created the request + pub(crate) session_request_timestamp: OffsetDateTime, + pub(crate) channels: ClientIncomingChannels, +} + enum ActiveClient { /// Handle to a remote client connected via a network socket. - Remote(ClientIncomingChannels), + Remote(RemoteClientData), /// Handle to a locally (inside the same process) running client. Embedded(LocalEmbeddedClientHandle), @@ -19,14 +28,14 @@ enum ActiveClient { impl ActiveClient { fn get_sender_ref(&self) -> &MixMessageSender { match self { - ActiveClient::Remote(remote) => &remote.mix_message_sender, + ActiveClient::Remote(remote) => &remote.channels.mix_message_sender, ActiveClient::Embedded(embedded) => &embedded.mix_message_sender, } } fn get_sender(&self) -> MixMessageSender { match self { - ActiveClient::Remote(remote) => remote.mix_message_sender.clone(), + ActiveClient::Remote(remote) => remote.channels.mix_message_sender.clone(), ActiveClient::Embedded(embedded) => embedded.mix_message_sender.clone(), } } @@ -78,18 +87,18 @@ impl ActiveClientsStore { pub(crate) fn get_remote_client( &self, address: DestinationAddressBytes, - ) -> Option { + ) -> Option { let entry = self.inner.get(&address)?; let handle = entry.value(); - let ActiveClient::Remote(channels) = handle else { + let ActiveClient::Remote(remote) = handle else { warn!("attempted to get a remote handle to a embedded network requester"); return None; }; // if the entry is stale, remove it from the map - if !channels.mix_message_sender.is_closed() { - Some(channels.clone()) + if !remote.channels.mix_message_sender.is_closed() { + Some(remote.clone()) } else { // drop the reference to the map to prevent deadlocks drop(entry); @@ -137,10 +146,14 @@ impl ActiveClientsStore { client: DestinationAddressBytes, handle: MixMessageSender, is_active_request_sender: IsActiveRequestSender, + session_request_timestamp: OffsetDateTime, ) { - let entry = ActiveClient::Remote(ClientIncomingChannels { - mix_message_sender: handle, - is_active_request_sender, + let entry = ActiveClient::Remote(RemoteClientData { + session_request_timestamp, + channels: ClientIncomingChannels { + mix_message_sender: handle, + is_active_request_sender, + }, }); if self.inner.insert(client, entry).is_some() { panic!("inserted a duplicate remote client") diff --git a/gateway/src/node/client_handling/websocket/common_state.rs b/gateway/src/node/client_handling/websocket/common_state.rs index 223c534b26..a571117895 100644 --- a/gateway/src/node/client_handling/websocket/common_state.rs +++ b/gateway/src/node/client_handling/websocket/common_state.rs @@ -9,15 +9,23 @@ use nym_mixnet_client::forwarder::MixForwardingSender; use nym_node_metrics::events::MetricEventsSender; use nym_node_metrics::NymNodeMetrics; use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone)] +pub(crate) struct Config { + pub(crate) enforce_zk_nym: bool, + pub(crate) max_auth_request_age: Duration, + + pub(crate) bandwidth: BandwidthFlushingBehaviourConfig, +} // I can see this being possible expanded with say storage or client store #[derive(Clone)] pub(crate) struct CommonHandlerState { + pub(crate) cfg: Config, pub(crate) ecash_verifier: Arc, pub(crate) storage: GatewayStorage, pub(crate) local_identity: Arc, - pub(crate) only_coconut_credentials: bool, - pub(crate) bandwidth_cfg: BandwidthFlushingBehaviourConfig, pub(crate) metrics: NymNodeMetrics, pub(crate) metrics_sender: MetricEventsSender, pub(crate) outbound_mix_sender: MixForwardingSender, diff --git a/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs b/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs index 6c39b613ce..16c5a42faa 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs @@ -194,8 +194,8 @@ impl AuthenticatedHandler { fresh.shared_state.storage.clone(), ClientBandwidth::new(bandwidth.into()), client.id, - fresh.shared_state.bandwidth_cfg, - fresh.shared_state.only_coconut_credentials, + fresh.shared_state.cfg.bandwidth, + fresh.shared_state.cfg.enforce_zk_nym, ), inner: fresh, client, diff --git a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs index 818aea8fdb..4c0eb81f35 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs @@ -1,11 +1,13 @@ // Copyright 2021-2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::node::client_handling::active_clients::RemoteClientData; use crate::node::client_handling::websocket::common_state::CommonHandlerState; +use crate::node::client_handling::websocket::connection_handler::helpers::KeyWithAuthTimestamp; use crate::node::client_handling::websocket::connection_handler::INITIAL_MESSAGE_TIMEOUT; use crate::node::client_handling::websocket::{ connection_handler::{AuthenticatedHandler, ClientDetails, InitialAuthResult, SocketStream}, - message_receiver::{IsActive, IsActiveRequestSender}, + message_receiver::IsActive, }; use futures::{ channel::{mpsc, oneshot}, @@ -13,14 +15,16 @@ use futures::{ }; use nym_credentials_interface::AvailableBandwidth; use nym_crypto::aes::cipher::crypto_common::rand_core::RngCore; -use nym_crypto::asymmetric::identity; +use nym_crypto::asymmetric::ed25519; +use nym_gateway_requests::authenticate::AuthenticateRequest; use nym_gateway_requests::authentication::encrypted_address::{ EncryptedAddressBytes, EncryptedAddressConversionError, }; use nym_gateway_requests::{ registration::handshake::{error::HandshakeError, gateway_handshake}, types::{ClientControlRequest, ServerResponse}, - BinaryResponse, SharedGatewayKey, CURRENT_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION, + AuthenticationFailure, BinaryResponse, SharedGatewayKey, CURRENT_PROTOCOL_VERSION, + INITIAL_PROTOCOL_VERSION, }; use nym_gateway_storage::error::GatewayStorageError; use nym_node_metrics::events::MetricsEvent; @@ -30,6 +34,7 @@ use rand::CryptoRng; use std::net::SocketAddr; use std::time::Duration; use thiserror::Error; +use time::OffsetDateTime; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::{protocol::Message, Error as WsError}; @@ -37,6 +42,12 @@ use tracing::*; #[derive(Debug, Error)] pub(crate) enum InitialAuthenticationError { + #[error(transparent)] + AuthenticationFailure(#[from] AuthenticationFailure), + + #[error("attempted to overwrite client session with a stale authentication")] + StaleSessionOverwrite, + #[error("Internal gateway storage error")] StorageError(#[from] GatewayStorageError), @@ -290,15 +301,15 @@ impl FreshHandler { // of doing full parse of the init_data elsewhere fn extract_remote_identity_from_register_init( init_data: &[u8], - ) -> Result { - if init_data.len() < identity::PUBLIC_KEY_LENGTH { + ) -> Result { + if init_data.len() < ed25519::PUBLIC_KEY_LENGTH { Err(InitialAuthenticationError::HandshakeError( HandshakeError::MalformedRequest, )) } else { - identity::PublicKey::from_bytes(&init_data[..identity::PUBLIC_KEY_LENGTH]).map_err( - |_| InitialAuthenticationError::HandshakeError(HandshakeError::MalformedRequest), - ) + ed25519::PublicKey::from_bytes(&init_data[..ed25519::PUBLIC_KEY_LENGTH]).map_err(|_| { + InitialAuthenticationError::HandshakeError(HandshakeError::MalformedRequest) + }) } } @@ -351,6 +362,21 @@ impl FreshHandler { Ok(()) } + async fn retrieve_shared_key( + &self, + client: DestinationAddressBytes, + ) -> Result, InitialAuthenticationError> { + let shared_keys = self.shared_state.storage.get_shared_keys(client).await?; + + let Some(stored_shared_keys) = shared_keys else { + return Ok(None); + }; + + let keys = KeyWithAuthTimestamp::try_from_stored(stored_shared_keys, client)?; + + Ok(Some(keys)) + } + /// Checks whether the stored shared keys match the received data, i.e. whether the upon decryption /// the provided encrypted address matches the expected unencrypted address. /// @@ -361,31 +387,18 @@ impl FreshHandler { /// * `client_address`: address of the client. /// * `encrypted_address`: encrypted address of the client, presumably encrypted using the shared keys. /// * `iv`: nonce/iv created for this particular encryption. - async fn verify_stored_shared_key( + async fn auth_v1_verify_stored_shared_key( &self, client_address: DestinationAddressBytes, encrypted_address: EncryptedAddressBytes, nonce: &[u8], - ) -> Result, InitialAuthenticationError> { - let shared_keys = self - .shared_state - .storage - .get_shared_keys(client_address) - .await?; - - let Some(stored_shared_keys) = shared_keys else { + ) -> Result, InitialAuthenticationError> { + let Some(keys) = self.retrieve_shared_key(client_address).await? else { return Ok(None); }; - let keys = SharedGatewayKey::try_from(stored_shared_keys).map_err(|source| { - InitialAuthenticationError::MalformedStoredSharedKey { - client_id: client_address.as_base58_string(), - source, - } - })?; - // LEGACY ISSUE: we're not verifying HMAC key - if encrypted_address.verify(&client_address, &keys, nonce) { + if encrypted_address.verify(&client_address, &keys.key, nonce) { Ok(Some(keys)) } else { Ok(None) @@ -428,49 +441,19 @@ impl FreshHandler { } } - /// Using the received challenge data, i.e. client's address as well the ciphertext of it plus - /// a fresh IV, attempts to authenticate the client by checking whether the ciphertext matches - /// the expected value if encrypted with the shared key. - /// - /// Finally, upon completion, all previously stored messages are pushed back to the client. - /// - /// # Arguments - /// - /// * `client_address`: address of the client wishing to authenticate. - /// * `encrypted_address`: ciphertext of the address of the client wishing to authenticate. - /// * `iv`: fresh nonce/IV received with the request. - async fn authenticate_client( - &mut self, - client_address: DestinationAddressBytes, - encrypted_address: EncryptedAddressBytes, - nonce: &[u8], - ) -> Result, InitialAuthenticationError> - where - S: AsyncRead + AsyncWrite + Unpin, - { - debug!( - "Processing authenticate client request for: {}", - client_address.as_base58_string() - ); - - let shared_keys = self - .verify_stored_shared_key(client_address, encrypted_address, nonce) - .await?; - - if let Some(shared_keys) = shared_keys { - self.push_stored_messages_to_client(client_address, &shared_keys) - .await?; - Ok(Some(shared_keys)) - } else { - Ok(None) - } - } - async fn handle_duplicate_client( &mut self, address: DestinationAddressBytes, - mut is_active_request_tx: IsActiveRequestSender, + remote_client_data: RemoteClientData, + new_session_start: OffsetDateTime, ) -> Result<(), InitialAuthenticationError> { + let mut is_active_request_tx = remote_client_data.channels.is_active_request_sender; + + // new session must **always** be explicitly more recent + if new_session_start <= remote_client_data.session_request_timestamp { + return Err(InitialAuthenticationError::StaleSessionOverwrite); + } + // Ask the other connection to ping if they are still active. // Use a oneshot channel to return the result to us let (ping_result_sender, ping_result_receiver) = oneshot::channel(); @@ -519,6 +502,32 @@ impl FreshHandler { Ok(()) } + #[allow(dead_code)] + async fn get_registered_client_id( + &self, + client_address: DestinationAddressBytes, + ) -> Result { + self.shared_state + .storage + .get_mixnet_client_id(client_address) + .await + .map_err(Into::into) + } + + async fn get_registered_available_bandwidth( + &self, + client_id: i64, + ) -> Result { + let available_bandwidth: AvailableBandwidth = self + .shared_state + .storage + .get_available_bandwidth(client_id) + .await? + .map(From::from) + .unwrap_or_default(); + Ok(available_bandwidth) + } + /// Tries to handle the received authentication request by checking correctness of the received data. /// /// # Arguments @@ -531,7 +540,7 @@ impl FreshHandler { address = %address, ) )] - async fn handle_authenticate( + async fn handle_legacy_authenticate( &mut self, client_protocol_version: Option, address: String, @@ -541,7 +550,7 @@ impl FreshHandler { where S: AsyncRead + AsyncWrite + Unpin, { - debug!("handling client registration"); + debug!("handling client authentication (v1)"); let negotiated_protocol = self.negotiate_client_protocol(client_protocol_version)?; // populate the negotiated protocol for future uses @@ -554,38 +563,38 @@ impl FreshHandler { .into_vec() .map_err(InitialAuthenticationError::MalformedIV)?; - // Check for duplicate clients - if let Some(client_tx) = self - .shared_state - .active_clients_store - .get_remote_client(address) - { - warn!("Detected duplicate connection for client: {address}"); - self.handle_duplicate_client(address, client_tx.is_active_request_sender) - .await?; - } - + // validate the shared key let Some(shared_keys) = self - .authenticate_client(address, encrypted_address, &nonce) + .auth_v1_verify_stored_shared_key(address, encrypted_address, &nonce) .await? else { // it feels weird to be returning an 'Ok' here, but I didn't want to change the existing behaviour return Ok(InitialAuthResult::new_failed(Some(negotiated_protocol))); }; - let client_id = self + // in v1 we don't have explicit data so we have to use current timestamp + // (which does nothing but just allows us to use the same codepath) + let session_request_start = OffsetDateTime::now_utc(); + + // Check for duplicate clients + if let Some(remote_client_data) = self .shared_state - .storage - .get_mixnet_client_id(address) + .active_clients_store + .get_remote_client(address) + { + warn!("Detected duplicate connection for client: {address}"); + self.handle_duplicate_client(address, remote_client_data, session_request_start) + .await?; + } + + let client_id = shared_keys.client_id; + + // if applicable, push stored messages + self.push_stored_messages_to_client(address, &shared_keys.key) .await?; - let available_bandwidth: AvailableBandwidth = self - .shared_state - .storage - .get_available_bandwidth(client_id) - .await? - .map(From::from) - .unwrap_or_default(); + // check the bandwidth + let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?; let bandwidth_remaining = if available_bandwidth.expired() { self.shared_state.storage.reset_bandwidth(client_id).await?; @@ -595,7 +604,98 @@ impl FreshHandler { }; Ok(InitialAuthResult::new( - Some(ClientDetails::new(client_id, address, shared_keys)), + Some(ClientDetails::new( + client_id, + address, + shared_keys.key, + session_request_start, + )), + ServerResponse::Authenticate { + protocol_version: Some(negotiated_protocol), + status: true, + bandwidth_remaining, + }, + )) + } + + async fn handle_authenticate_v2( + &mut self, + request: Box, + ) -> Result + where + S: AsyncRead + AsyncWrite + Unpin, + { + debug!("handling client authentication (v2)"); + + let negotiated_protocol = + self.negotiate_client_protocol(Some(request.content.protocol_version))?; + // populate the negotiated protocol for future uses + self.negotiated_protocol = Some(negotiated_protocol); + + let address = request.content.client_identity.derive_destination_address(); + + // do cheap checks first + // is the provided timestamp relatively recent (and not in the future?) + request.verify_timestamp(self.shared_state.cfg.max_auth_request_age)?; + + // does the message signature verify? + request.verify_signature()?; + + // retrieve the actually stored key and check if the ciphertext matches + let Some(shared_key) = self.retrieve_shared_key(address).await? else { + return Err(AuthenticationFailure::NotRegistered)?; + }; + request.verify_ciphertext(&shared_key.key)?; + + let session_request_start = request.content.request_timestamp(); + + // if the client has already authenticated in the past, make sure this authentication timestamp + // is different and greater than the old one (in case it was replayed) + if let Some(prior_usage) = shared_key.last_used_authentication { + request.ensure_timestamp_not_reused(prior_usage)?; + } + + // check for duplicate clients + if let Some(client_data) = self + .shared_state + .active_clients_store + .get_remote_client(address) + { + warn!("Detected duplicate connection for client: {address}"); + self.handle_duplicate_client(address, client_data, session_request_start) + .await?; + } + + let client_id = shared_key.client_id; + + // update the auth timestamp for future uses + self.shared_state + .storage + .update_last_used_authentication_timestamp(client_id, session_request_start) + .await?; + + // push any old stored messages to the client + // (this will be removed soon) + self.push_stored_messages_to_client(address, &shared_key.key) + .await?; + + // finally check and retrieve client's bandwidth + let available_bandwidth = self.get_registered_available_bandwidth(client_id).await?; + + let bandwidth_remaining = if available_bandwidth.expired() { + self.shared_state.storage.reset_bandwidth(client_id).await?; + 0 + } else { + available_bandwidth.bytes + }; + + Ok(InitialAuthResult::new( + Some(ClientDetails::new( + client_id, + address, + shared_key.key, + session_request_start, + )), ServerResponse::Authenticate { protocol_version: Some(negotiated_protocol), status: true, @@ -688,7 +788,12 @@ impl FreshHandler { debug!(client_id = %client_id, "managed to finalize client registration"); - let client_details = ClientDetails::new(client_id, remote_address, shared_keys); + let client_details = ClientDetails::new( + client_id, + remote_address, + shared_keys, + OffsetDateTime::now_utc(), + ); Ok(InitialAuthResult::new( Some(client_details), @@ -734,9 +839,10 @@ impl FreshHandler { enc_address, iv, } => { - self.handle_authenticate(protocol_version, address, enc_address, iv) + self.handle_legacy_authenticate(protocol_version, address, enc_address, iv) .await } + ClientControlRequest::AuthenticateV2(req) => self.handle_authenticate_v2(req).await, ClientControlRequest::RegisterHandshakeInitRequest { protocol_version, data, @@ -827,6 +933,7 @@ impl FreshHandler { registration_details.address, mix_sender, is_active_request_sender, + registration_details.session_request_timestamp, ); return AuthenticatedHandler::upgrade( diff --git a/gateway/src/node/client_handling/websocket/connection_handler/helpers.rs b/gateway/src/node/client_handling/websocket/connection_handler/helpers.rs new file mode 100644 index 0000000000..5a5d24c5d6 --- /dev/null +++ b/gateway/src/node/client_handling/websocket/connection_handler/helpers.rs @@ -0,0 +1,37 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node::client_handling::websocket::connection_handler::fresh::InitialAuthenticationError; +use nym_gateway_requests::SharedGatewayKey; +use nym_gateway_storage::models::PersistedSharedKeys; +use nym_sphinx::DestinationAddressBytes; +use time::OffsetDateTime; + +pub(crate) struct KeyWithAuthTimestamp { + pub(crate) client_id: i64, + pub(crate) key: SharedGatewayKey, + pub(crate) last_used_authentication: Option, +} + +impl KeyWithAuthTimestamp { + pub(crate) fn try_from_stored( + stored_shared_keys: PersistedSharedKeys, + client: DestinationAddressBytes, + ) -> Result { + let last_used_authentication = stored_shared_keys.last_used_authentication; + let client_id = stored_shared_keys.client_id; + + let key = SharedGatewayKey::try_from(stored_shared_keys).map_err(|source| { + InitialAuthenticationError::MalformedStoredSharedKey { + client_id: client.as_base58_string(), + source, + } + })?; + + Ok(KeyWithAuthTimestamp { + client_id, + key, + last_used_authentication, + }) + } +} diff --git a/gateway/src/node/client_handling/websocket/connection_handler/mod.rs b/gateway/src/node/client_handling/websocket/connection_handler/mod.rs index f9f6fc584d..38026fa8be 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/mod.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/mod.rs @@ -8,16 +8,17 @@ use nym_gateway_requests::ServerResponse; use nym_sphinx::DestinationAddressBytes; use rand::{CryptoRng, Rng}; use std::time::Duration; +use time::OffsetDateTime; use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::WebSocketStream; use tracing::{debug, instrument, trace, warn}; -use zeroize::{Zeroize, ZeroizeOnDrop}; pub(crate) use self::authenticated::AuthenticatedHandler; pub(crate) use self::fresh::FreshHandler; pub(crate) mod authenticated; mod fresh; +pub(crate) mod helpers; const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(1_500); const INITIAL_MESSAGE_TIMEOUT: Duration = Duration::from_millis(10_000); @@ -40,12 +41,13 @@ impl SocketStream { } } -#[derive(Zeroize, ZeroizeOnDrop)] pub(crate) struct ClientDetails { - #[zeroize(skip)] pub(crate) address: DestinationAddressBytes, pub(crate) id: i64, pub(crate) shared_keys: SharedGatewayKey, + // note, this does **NOT ALWAYS** indicate timestamp of when client connected + // it is (for v2 auth) timestamp the client **signed** when it created the request + pub(crate) session_request_timestamp: OffsetDateTime, } impl ClientDetails { @@ -53,11 +55,13 @@ impl ClientDetails { id: i64, address: DestinationAddressBytes, shared_keys: SharedGatewayKey, + session_request_timestamp: OffsetDateTime, ) -> Self { ClientDetails { address, id, shared_keys, + session_request_timestamp, } } } diff --git a/gateway/src/node/client_handling/websocket/mod.rs b/gateway/src/node/client_handling/websocket/mod.rs index 12c252feca..2405870a42 100644 --- a/gateway/src/node/client_handling/websocket/mod.rs +++ b/gateway/src/node/client_handling/websocket/mod.rs @@ -8,4 +8,4 @@ pub(crate) mod connection_handler; pub(crate) mod listener; pub(crate) mod message_receiver; -pub(crate) use common_state::CommonHandlerState; +pub(crate) use common_state::{CommonHandlerState, Config}; diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 1dd6f2c217..dd4a866a2c 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -249,11 +249,14 @@ impl GatewayTasksBuilder { active_clients_store: ActiveClientsStore, ) -> Result { let shared_state = websocket::CommonHandlerState { + cfg: websocket::Config { + enforce_zk_nym: self.config.gateway.enforce_zk_nyms, + max_auth_request_age: self.config.debug.maximum_auth_request_age, + bandwidth: (&self.config).into(), + }, ecash_verifier: self.ecash_manager().await?, storage: self.storage.clone(), local_identity: Arc::clone(&self.identity_keypair), - only_coconut_credentials: self.config.gateway.enforce_zk_nyms, - bandwidth_cfg: (&self.config).into(), metrics: self.metrics.clone(), metrics_sender: self.metrics_sender.clone(), outbound_mix_sender: self.mix_packet_sender.clone(), diff --git a/nym-node/src/config/gateway_tasks.rs b/nym-node/src/config/gateway_tasks.rs index 9f814f2bf5..4d83c4632d 100644 --- a/nym-node/src/config/gateway_tasks.rs +++ b/nym-node/src/config/gateway_tasks.rs @@ -47,6 +47,9 @@ pub struct Debug { /// Number of messages from offline client that can be pulled at once (i.e. with a single SQL query) from the storage. pub message_retrieval_limit: i64, + /// Defines the maximum age of a signed authentication request before it's deemed too stale to process. + pub maximum_auth_request_age: Duration, + pub stale_messages: StaleMessageDebug, pub client_bandwidth: ClientBandwidthDebug, @@ -55,13 +58,15 @@ pub struct Debug { } impl Debug { - const DEFAULT_MESSAGE_RETRIEVAL_LIMIT: i64 = 100; + pub const DEFAULT_MESSAGE_RETRIEVAL_LIMIT: i64 = 100; + pub const DEFAULT_MAXIMUM_AUTH_REQUEST_AGE: Duration = Duration::from_secs(30); } impl Default for Debug { fn default() -> Self { Debug { message_retrieval_limit: Self::DEFAULT_MESSAGE_RETRIEVAL_LIMIT, + maximum_auth_request_age: Self::DEFAULT_MAXIMUM_AUTH_REQUEST_AGE, stale_messages: Default::default(), client_bandwidth: Default::default(), zk_nym_tickets: Default::default(), diff --git a/nym-node/src/config/helpers.rs b/nym-node/src/config/helpers.rs index 2afd58301c..aa2da4ae3c 100644 --- a/nym-node/src/config/helpers.rs +++ b/nym-node/src/config/helpers.rs @@ -59,6 +59,7 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config { .zk_nym_tickets .maximum_time_between_redemption, }, + maximum_auth_request_age: config.gateway_tasks.debug.maximum_auth_request_age, }, ) }