feature: v2 authentication request (#5537)

* introduced v2 authentication request between clients and gateways

* client to send v2 auth when possible

* added persistence to last used authentication timestamp

* added clients identity to signed plaintext
This commit is contained in:
Jędrzej Stuczyński
2025-03-03 17:51:30 +00:00
committed by GitHub
parent 2a6fe6624d
commit 155c4d37ef
23 changed files with 623 additions and 151 deletions
Generated
+3
View File
@@ -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",
@@ -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<C, St> GatewayClient<C, St> {
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<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_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<C, St> GatewayClient<C, St> {
}
// 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<C, St> GatewayClient<C, St> {
}
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<C, St> GatewayClient<C, St> {
}
// 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()?;
+3
View File
@@ -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" }
@@ -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<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}")]
+20 -1
View File
@@ -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 = <GatewayIntegrityHmacAlgorithm as OutputSizeUser>::OutputSize;
pub trait GatewayProtocolVersionExt {
fn supports_aes256_gcm_siv(&self) -> bool;
fn supports_authenticate_v2(&self) -> bool;
}
impl GatewayProtocolVersionExt for Option<u8> {
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
}
}
@@ -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,
}
@@ -0,0 +1,142 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<AuthenticateRequest, GatewayRequestsError> {
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<u8>,
#[serde(with = "nym_serde_helpers::base64")]
pub nonce: Vec<u8>,
pub request_unix_timestamp: u64,
}
impl AuthenticateRequestContent {
fn new(
protocol_version: u8,
shared_key: &SharedGatewayKey,
client_identity: ed25519::PublicKey,
) -> Result<AuthenticateRequestContent, GatewayRequestsError> {
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<u8> {
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)
}
}
@@ -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<AuthenticateRequest>),
#[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<Self, GatewayRequestsError> {
// 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()
}
@@ -0,0 +1,7 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: GPL-3.0-only
*/
ALTER TABLE shared_keys
ADD COLUMN last_used_authentication TIMESTAMP WITHOUT TIME ZONE;
+14
View File
@@ -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<Option<Client>, GatewayStorageError> {
let client = self.client_manager.get_client(client_id).await?;
Ok(client)
+1 -1
View File
@@ -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<String>,
pub derived_aes256_gcm_siv_key: Option<Vec<u8>>,
pub last_used_authentication: Option<OffsetDateTime>,
}
impl TryFrom<PersistedSharedKeys> for SharedGatewayKey {
+21 -7
View File
@@ -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<Option<PersistedSharedKeys>, 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.
+3
View File
@@ -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)]
@@ -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<ClientIncomingChannels> {
) -> Option<RemoteClientData> {
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")
@@ -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<EcashManager>,
pub(crate) storage: GatewayStorage,
pub(crate) local_identity: Arc<identity::KeyPair>,
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,
@@ -194,8 +194,8 @@ impl<R, S> AuthenticatedHandler<R, S> {
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,
@@ -1,11 +1,13 @@
// Copyright 2021-2024 - Nym Technologies SA <contact@nymtech.net>
// 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<R, S> FreshHandler<R, S> {
// of doing full parse of the init_data elsewhere
fn extract_remote_identity_from_register_init(
init_data: &[u8],
) -> Result<identity::PublicKey, InitialAuthenticationError> {
if init_data.len() < identity::PUBLIC_KEY_LENGTH {
) -> Result<ed25519::PublicKey, InitialAuthenticationError> {
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<R, S> FreshHandler<R, S> {
Ok(())
}
async fn retrieve_shared_key(
&self,
client: DestinationAddressBytes,
) -> Result<Option<KeyWithAuthTimestamp>, 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<R, S> FreshHandler<R, S> {
/// * `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<Option<SharedGatewayKey>, InitialAuthenticationError> {
let shared_keys = self
.shared_state
.storage
.get_shared_keys(client_address)
.await?;
let Some(stored_shared_keys) = shared_keys else {
) -> Result<Option<KeyWithAuthTimestamp>, 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<R, S> FreshHandler<R, S> {
}
}
/// 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<Option<SharedGatewayKey>, 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<R, S> FreshHandler<R, S> {
Ok(())
}
#[allow(dead_code)]
async fn get_registered_client_id(
&self,
client_address: DestinationAddressBytes,
) -> Result<i64, InitialAuthenticationError> {
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<AvailableBandwidth, InitialAuthenticationError> {
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<R, S> FreshHandler<R, S> {
address = %address,
)
)]
async fn handle_authenticate(
async fn handle_legacy_authenticate(
&mut self,
client_protocol_version: Option<u8>,
address: String,
@@ -541,7 +550,7 @@ impl<R, S> FreshHandler<R, S> {
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<R, S> FreshHandler<R, S> {
.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<R, S> FreshHandler<R, S> {
};
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<AuthenticateRequest>,
) -> Result<InitialAuthResult, InitialAuthenticationError>
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<R, S> FreshHandler<R, S> {
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<R, S> FreshHandler<R, S> {
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<R, S> FreshHandler<R, S> {
registration_details.address,
mix_sender,
is_active_request_sender,
registration_details.session_request_timestamp,
);
return AuthenticatedHandler::upgrade(
@@ -0,0 +1,37 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<OffsetDateTime>,
}
impl KeyWithAuthTimestamp {
pub(crate) fn try_from_stored(
stored_shared_keys: PersistedSharedKeys,
client: DestinationAddressBytes,
) -> Result<Self, InitialAuthenticationError> {
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,
})
}
}
@@ -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<S> SocketStream<S> {
}
}
#[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,
}
}
}
@@ -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};
+5 -2
View File
@@ -249,11 +249,14 @@ impl GatewayTasksBuilder {
active_clients_store: ActiveClientsStore,
) -> Result<websocket::Listener, GatewayError> {
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(),
+6 -1
View File
@@ -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(),
+1
View File
@@ -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,
},
)
}