Fixed: anonymous handshake goes out as v8

- replies are accepted as v8 or v9, and the earlier strict client-side mismatch / dropped v9 non-stream behaviour is addressed
This commit is contained in:
Tommy Verrall
2026-05-01 08:50:37 +02:00
parent 46c7eaf4e5
commit 0d64e342da
5 changed files with 123 additions and 28 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ impl IprClientConnect {
}
async fn send_connect_request(&self, ip_packet_router_address: Recipient) -> Result<u64> {
let (request, request_id) = nym_ip_packet_requests::v9::new_connect_request(None);
let (request, request_id) = IpPacketRequest::new_connect_request(None);
// We use 20 surbs for the connect request because typically the IPR is configured to have
// a min threshold of 10 surbs that it reserves for itself to request additional surbs.
+60 -16
View File
@@ -1,24 +1,68 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_ip_packet_requests::response_helpers::IprResponseError;
use nym_sdk::mixnet::ReconstructedMessage;
use crate::{current::VERSION as CURRENT_VERSION, error::Result};
use crate::error::{Error, Result};
pub(crate) fn check_ipr_message_version(message: &ReconstructedMessage) -> Result<()> {
nym_ip_packet_requests::response_helpers::check_ipr_message_version(
&message.message,
CURRENT_VERSION,
)
.map_err(|e| match e {
IprResponseError::NoVersionByte => crate::Error::NoVersionInMessage,
IprResponseError::VersionMismatch { expected, received } if received < expected => {
crate::Error::ReceivedResponseWithOldVersion { expected, received }
}
IprResponseError::VersionMismatch { expected, received } => {
crate::Error::ReceivedResponseWithNewVersion { expected, received }
}
_ => crate::Error::NoVersionInMessage,
fn check_ipr_wire_reply_version(version: u8) -> Result<()> {
if version == 8 || version == 9 {
return Ok(());
}
if version < 8 {
return Err(Error::ReceivedResponseWithOldVersion {
expected: 8,
received: version,
});
}
Err(Error::ReceivedResponseWithNewVersion {
expected: 9,
received: version,
})
}
/// IPR responses on the wire may be v8 or v9 (identical payload layout; version byte differs).
pub(crate) fn check_ipr_message_version(message: &ReconstructedMessage) -> Result<()> {
let version = message
.message
.first()
.copied()
.ok_or(Error::NoVersionInMessage)?;
check_ipr_wire_reply_version(version)
}
#[cfg(test)]
mod tests {
use super::check_ipr_wire_reply_version;
use crate::Error;
#[test]
fn wire_reply_accepts_v8_and_v9() {
assert!(check_ipr_wire_reply_version(8).is_ok());
assert!(check_ipr_wire_reply_version(9).is_ok());
}
#[test]
fn wire_reply_rejects_older_than_v8() {
let err = check_ipr_wire_reply_version(7).unwrap_err();
match err {
Error::ReceivedResponseWithOldVersion { expected, received } => {
assert_eq!(expected, 8);
assert_eq!(received, 7);
}
_ => panic!("unexpected error: {err:?}"),
}
}
#[test]
fn wire_reply_rejects_newer_than_v9() {
let err = check_ipr_wire_reply_version(10).unwrap_err();
match err {
Error::ReceivedResponseWithNewVersion { expected, received } => {
assert_eq!(expected, 9);
assert_eq!(received, 10);
}
_ => panic!("unexpected error: {err:?}"),
}
}
}
+7 -2
View File
@@ -1,6 +1,9 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
//! Anonymous IPR connect uses **v8** on the wire so exits that reject non-stream v9 still answer.
//! **v9** is re-exported for code paths that use LP Stream framing. Incoming IPR responses may be **v8 or v9** (same bincode shape).
mod connect;
mod error;
mod helpers;
@@ -10,5 +13,7 @@ pub use connect::IprClientConnect;
pub use error::Error;
pub use listener::{IprListener, MixnetMessageOutcome};
// Re-export the currently used version
pub use nym_ip_packet_requests::v9 as current;
pub use nym_ip_packet_requests::v8;
pub use nym_ip_packet_requests::v9;
pub use v8 as current;
+2 -2
View File
@@ -3,7 +3,7 @@
use bytes::Bytes;
use futures::StreamExt;
use nym_ip_packet_requests::{codec::MultiIpPacketCodec, v8::response::ControlResponse};
use nym_ip_packet_requests::codec::MultiIpPacketCodec;
use nym_sdk::mixnet::ReconstructedMessage;
use tokio_util::codec::FramedRead;
use tracing::{debug, error, info, warn};
@@ -11,7 +11,7 @@ use tracing::{debug, error, info, warn};
use crate::{
current::{
request::{ControlRequest, IpPacketRequest, IpPacketRequestData},
response::{InfoLevel, IpPacketResponse, IpPacketResponseData},
response::{ControlResponse, InfoLevel, IpPacketResponse, IpPacketResponseData},
},
helpers::check_ipr_message_version,
};
@@ -37,6 +37,15 @@ type TunDevice = crate::non_linux_dummy::DummyDevice;
#[cfg(target_os = "linux")]
type TunDevice = tokio_tun::Tun;
/// v9+ on non-stream sphinx is limited to [`ControlRequest::DynamicConnect`] (handshake). Other
/// v9+ payloads must be sent inside LP Stream frames (`stream_id` is `Some` on dispatch).
fn allows_non_stream_v9_ipr_request(request: &IpPacketRequest) -> bool {
matches!(
request,
IpPacketRequest::Control(ControlRequest::DynamicConnect(_))
)
}
// #[cfg(target_os = "linux")]
pub(crate) struct MixnetListener {
// The configuration for the mixnet listener
@@ -560,9 +569,9 @@ impl MixnetListener {
/// # Version / transport enforcement
///
/// - LP Stream frames (`stream_id` is `Some`) **must** carry v9+ payloads.
/// - Non-stream messages (`stream_id` is `None`) **must** be v8 or lower.
///
/// Messages that violate these rules are dropped.
/// - Non-stream sphinx (`stream_id` is `None`): v8 or lower for all messages **except**
/// [`ControlRequest::DynamicConnect`], which may use v9 for the anonymous handshake.
/// - Other non-stream v9+ payloads are dropped.
async fn on_ipr_message(
&mut self,
reconstructed: ReconstructedMessage,
@@ -577,16 +586,14 @@ impl MixnetListener {
req => req,
}?;
// Enforce version/transport consistency:
// - LP Stream frames must carry v9+ payloads
// - Non-stream messages must be v8 or lower
// Enforce version/transport consistency (see `on_ipr_message` doc).
let version_num = request.version().into_u8();
if stream_id.is_some() && version_num < 9 {
log::warn!("LP Stream frame contains v{version_num} payload, expected v9+; dropping",);
return Ok(vec![]);
}
if stream_id.is_none() && version_num >= 9 {
if stream_id.is_none() && version_num >= 9 && !allows_non_stream_v9_ipr_request(&request) {
log::warn!("Non-stream message claims v{version_num}, expected v8 or lower; dropping",);
return Ok(vec![]);
}
@@ -693,6 +700,45 @@ pub(crate) type PacketHandleResult = Result<Option<VersionedResponse>>;
#[cfg(test)]
mod tests {
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use super::allows_non_stream_v9_ipr_request;
use crate::{
clients::ConnectedClientId,
messages::{
ClientVersion,
request::{
ControlRequest, DataRequest, DynamicConnectRequest, IpPacketRequest, PingRequest,
},
},
};
#[test]
fn non_stream_v9_allowed_for_dynamic_connect_only() {
let sent_by = ConnectedClientId::from(AnonymousSenderTag::from_bytes([9u8; 16]));
let dynamic_connect =
IpPacketRequest::Control(ControlRequest::DynamicConnect(DynamicConnectRequest {
version: ClientVersion::V9,
request_id: 1,
sent_by,
buffer_timeout: None,
}));
assert!(allows_non_stream_v9_ipr_request(&dynamic_connect));
let data = IpPacketRequest::Data(DataRequest {
version: ClientVersion::V9,
ip_packets: bytes::Bytes::new(),
});
assert!(!allows_non_stream_v9_ipr_request(&data));
let ping = IpPacketRequest::Control(ControlRequest::Ping(PingRequest {
version: ClientVersion::V9,
request_id: 2,
sent_by: ConnectedClientId::from(AnonymousSenderTag::from_bytes([1u8; 16])),
}));
assert!(!allows_non_stream_v9_ipr_request(&ping));
}
#[test]
fn test_lp_stream_frame_detected() {
use bytes::BytesMut;