commit 8a56141399b3e760162f9dd7e49c1e11a265c7f8
parent 0ea9e063e678e8fb3caea003acec9087d5b88794
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 01:10:16 +0000
simplex: restore legacy queue and new compatibility
- accept official unversioned queue uris and normalize them to canonical versioned output
- support legacy v6-v8 NEW encoding with A-prefixed basic auth and no post-v9 queue fields
- keep the v9, v15, and v17 NEW layouts explicit across the version gates
- add uri and wire tests for legacy parsing plus version-gated NEW compatibility
Diffstat:
2 files changed, 234 insertions(+), 61 deletions(-)
diff --git a/crates/simplex-smp-proto/src/uri.rs b/crates/simplex-smp-proto/src/uri.rs
@@ -1,5 +1,6 @@
use crate::error::RadrootsSimplexSmpProtoError;
use crate::version::{
+ RADROOTS_SIMPLEX_SMP_INITIAL_CLIENT_VERSION,
RADROOTS_SIMPLEX_SMP_SERVER_HOSTNAMES_CLIENT_VERSION,
RADROOTS_SIMPLEX_SMP_SHORT_LINKS_CLIENT_VERSION, RadrootsSimplexSmpVersionRange,
};
@@ -66,66 +67,83 @@ impl RadrootsSimplexSmpQueueUri {
validate_base64_url("sender_id", &sender_id)?;
let (fragment_dh_public_key, query) = parse_fragment_query(fragment, value)?;
- let mut version_range: Option<RadrootsSimplexSmpVersionRange> = None;
+ let mut version_range = if query.is_none() {
+ Some(RadrootsSimplexSmpVersionRange::single(
+ RADROOTS_SIMPLEX_SMP_INITIAL_CLIENT_VERSION,
+ ))
+ } else {
+ None
+ };
let mut recipient_dh_public_key: Option<String> = fragment_dh_public_key;
let mut queue_mode: Option<RadrootsSimplexSmpQueueMode> = None;
let mut extra_hosts: Option<Vec<String>> = None;
- for pair in query.split('&') {
- if pair.is_empty() {
- continue;
- }
+ if let Some(query) = query {
+ version_range = None;
+ for pair in query.split('&') {
+ if pair.is_empty() {
+ continue;
+ }
- let (key, raw_value) = pair
- .split_once('=')
- .ok_or_else(|| RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()))?;
+ let (key, raw_value) = pair
+ .split_once('=')
+ .ok_or_else(|| RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()))?;
- match key {
- "v" => {
- version_range = Some(raw_value.parse()?);
- }
- "dh" => {
- validate_base64_url("recipient_dh_public_key", raw_value)?;
- if recipient_dh_public_key
- .replace(raw_value.to_string())
- .is_some()
- {
- return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()));
+ match key {
+ "v" => {
+ version_range = Some(raw_value.parse()?);
}
- }
- "q" => {
- let next_mode = match raw_value {
- "m" => RadrootsSimplexSmpQueueMode::Messaging,
- "c" => RadrootsSimplexSmpQueueMode::Contact,
- _ => {
+ "dh" => {
+ validate_base64_url("recipient_dh_public_key", raw_value)?;
+ if recipient_dh_public_key
+ .replace(raw_value.to_string())
+ .is_some()
+ {
return Err(RadrootsSimplexSmpProtoError::InvalidUri(
value.to_string(),
));
}
- };
- if queue_mode.replace(next_mode).is_some() {
- return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()));
}
- }
- "k" if raw_value == "s" => {
- if queue_mode
- .replace(RadrootsSimplexSmpQueueMode::Messaging)
- .is_some()
- {
- return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()));
+ "q" => {
+ let next_mode = match raw_value {
+ "m" => RadrootsSimplexSmpQueueMode::Messaging,
+ "c" => RadrootsSimplexSmpQueueMode::Contact,
+ _ => {
+ return Err(RadrootsSimplexSmpProtoError::InvalidUri(
+ value.to_string(),
+ ));
+ }
+ };
+ if queue_mode.replace(next_mode).is_some() {
+ return Err(RadrootsSimplexSmpProtoError::InvalidUri(
+ value.to_string(),
+ ));
+ }
}
- }
- "srv" => {
- if extra_hosts
- .replace(parse_host_list(raw_value, value)?)
- .is_some()
- {
+ "k" if raw_value == "s" => {
+ if queue_mode
+ .replace(RadrootsSimplexSmpQueueMode::Messaging)
+ .is_some()
+ {
+ return Err(RadrootsSimplexSmpProtoError::InvalidUri(
+ value.to_string(),
+ ));
+ }
+ }
+ "srv" => {
+ if extra_hosts
+ .replace(parse_host_list(raw_value, value)?)
+ .is_some()
+ {
+ return Err(RadrootsSimplexSmpProtoError::InvalidUri(
+ value.to_string(),
+ ));
+ }
+ }
+ _ => {
return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()));
}
}
- _ => {
- return Err(RadrootsSimplexSmpProtoError::InvalidUri(value.to_string()));
- }
}
}
@@ -233,18 +251,22 @@ fn parse_server_address(
fn parse_fragment_query<'a>(
fragment: &'a str,
original: &str,
-) -> Result<(Option<String>, &'a str), RadrootsSimplexSmpProtoError> {
+) -> Result<(Option<String>, Option<&'a str>), RadrootsSimplexSmpProtoError> {
let fragment = fragment.strip_prefix('/').unwrap_or(fragment);
if let Some(query) = fragment.strip_prefix('?') {
- return Ok((None, query));
+ return Ok((None, Some(query)));
}
if let Some((dh_public_key, query)) = fragment.split_once("/?") {
validate_base64_url("recipient_dh_public_key", dh_public_key)?;
- return Ok((Some(dh_public_key.to_string()), query));
+ return Ok((Some(dh_public_key.to_string()), Some(query)));
}
if let Some((dh_public_key, query)) = fragment.split_once('?') {
validate_base64_url("recipient_dh_public_key", dh_public_key)?;
- return Ok((Some(dh_public_key.to_string()), query));
+ return Ok((Some(dh_public_key.to_string()), Some(query)));
+ }
+ if !fragment.is_empty() {
+ validate_base64_url("recipient_dh_public_key", fragment)?;
+ return Ok((Some(fragment.to_string()), None));
}
Err(RadrootsSimplexSmpProtoError::InvalidUri(
original.to_string(),
@@ -343,4 +365,19 @@ mod tests {
"smp://YWJjZA@server1.example:5223/cXVldWU#/?v=1-3&dh=ZGhLZXk&k=s&srv=server2.example"
);
}
+
+ #[test]
+ fn parses_legacy_unversioned_queue_uri() {
+ let uri =
+ RadrootsSimplexSmpQueueUri::parse("smp://YWJjZA@server1.example/cXVldWU/#ZGhLZXk")
+ .unwrap();
+
+ assert_eq!(uri.version_range, RadrootsSimplexSmpVersionRange::single(1));
+ assert_eq!(uri.recipient_dh_public_key, "ZGhLZXk");
+ assert_eq!(uri.queue_mode, None);
+ assert_eq!(
+ uri.to_string(),
+ "smp://YWJjZA@server1.example/cXVldWU#/?v=1&dh=ZGhLZXk"
+ );
+ }
}
diff --git a/crates/simplex-smp-proto/src/wire.rs b/crates/simplex-smp-proto/src/wire.rs
@@ -1,7 +1,7 @@
use crate::error::RadrootsSimplexSmpProtoError;
use crate::uri::RadrootsSimplexSmpQueueMode;
use crate::version::{
- RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION,
+ RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION,
RADROOTS_SIMPLEX_SMP_NEW_NOTIFIER_CREDENTIALS_TRANSPORT_VERSION,
RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION,
RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION,
@@ -561,7 +561,7 @@ fn encode_new_request(
request: &RadrootsSimplexSmpNewQueueRequest,
transport_version: u16,
) -> Result<(), RadrootsSimplexSmpProtoError> {
- if transport_version < RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION {
+ if transport_version < RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION {
return Err(RadrootsSimplexSmpProtoError::UnsupportedTransportVersion(
transport_version,
));
@@ -571,10 +571,9 @@ fn encode_new_request(
buffer.push(b' ');
push_short_bytes(buffer, &request.recipient_auth_public_key)?;
push_short_bytes(buffer, &request.recipient_dh_public_key)?;
- push_maybe_string(buffer, request.basic_auth.as_deref())?;
- buffer.push(encode_subscription_mode(request.subscription_mode));
-
if transport_version >= RADROOTS_SIMPLEX_SMP_NEW_NOTIFIER_CREDENTIALS_TRANSPORT_VERSION {
+ push_maybe_string(buffer, request.basic_auth.as_deref())?;
+ buffer.push(encode_subscription_mode(request.subscription_mode));
push_maybe(
buffer,
request.queue_request_data.as_ref(),
@@ -586,13 +585,20 @@ fn encode_new_request(
encode_new_notifier_credentials,
)?;
} else if transport_version >= RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION {
+ push_maybe_string(buffer, request.basic_auth.as_deref())?;
+ buffer.push(encode_subscription_mode(request.subscription_mode));
push_maybe(
buffer,
request.queue_request_data.as_ref(),
encode_queue_request_data,
)?;
- } else {
+ } else if transport_version >= RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION {
+ push_maybe_string(buffer, request.basic_auth.as_deref())?;
+ buffer.push(encode_subscription_mode(request.subscription_mode));
buffer.push(encode_bool(request.sender_can_secure()));
+ } else {
+ push_legacy_basic_auth(buffer, request.basic_auth.as_deref())?;
+ buffer.push(encode_subscription_mode(request.subscription_mode));
}
Ok(())
@@ -602,7 +608,7 @@ fn decode_new_request(
cursor: &mut Cursor<'_>,
transport_version: u16,
) -> Result<RadrootsSimplexSmpNewQueueRequest, RadrootsSimplexSmpProtoError> {
- if transport_version < RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION {
+ if transport_version < RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION {
return Err(RadrootsSimplexSmpProtoError::UnsupportedTransportVersion(
transport_version,
));
@@ -610,24 +616,38 @@ fn decode_new_request(
let recipient_auth_public_key = cursor.read_short_bytes()?;
let recipient_dh_public_key = cursor.read_short_bytes()?;
- let basic_auth = cursor.read_maybe_string()?;
- let subscription_mode = decode_subscription_mode(cursor.read_byte()?)?;
- let (queue_request_data, notifier_credentials) =
+ let (basic_auth, subscription_mode, queue_request_data, notifier_credentials) =
if transport_version >= RADROOTS_SIMPLEX_SMP_NEW_NOTIFIER_CREDENTIALS_TRANSPORT_VERSION {
(
+ cursor.read_maybe_string()?,
+ decode_subscription_mode(cursor.read_byte()?)?,
cursor.read_maybe(decode_queue_request_data)?,
cursor.read_maybe(decode_new_notifier_credentials)?,
)
} else if transport_version >= RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION {
- (cursor.read_maybe(decode_queue_request_data)?, None)
- } else {
+ (
+ cursor.read_maybe_string()?,
+ decode_subscription_mode(cursor.read_byte()?)?,
+ cursor.read_maybe(decode_queue_request_data)?,
+ None,
+ )
+ } else if transport_version >= RADROOTS_SIMPLEX_SMP_SENDER_AUTH_KEY_TRANSPORT_VERSION {
+ let basic_auth = cursor.read_maybe_string()?;
+ let subscription_mode = decode_subscription_mode(cursor.read_byte()?)?;
let sender_can_secure = decode_bool(cursor.read_byte()?)?;
let queue_request_data = Some(if sender_can_secure {
RadrootsSimplexSmpQueueRequestData::Messaging(None)
} else {
RadrootsSimplexSmpQueueRequestData::Contact(None)
});
- (queue_request_data, None)
+ (basic_auth, subscription_mode, queue_request_data, None)
+ } else {
+ (
+ cursor.read_legacy_basic_auth()?,
+ decode_subscription_mode(cursor.read_byte()?)?,
+ None,
+ None,
+ )
};
Ok(RadrootsSimplexSmpNewQueueRequest {
@@ -1110,6 +1130,20 @@ fn push_maybe_string(
}
}
+fn push_legacy_basic_auth(
+ buffer: &mut Vec<u8>,
+ value: Option<&str>,
+) -> Result<(), RadrootsSimplexSmpProtoError> {
+ match value {
+ None => Ok(()),
+ Some(value) => {
+ validate_basic_auth(value)?;
+ buffer.push(b'A');
+ push_short_bytes(buffer, value.as_bytes())
+ }
+ }
+}
+
fn validate_basic_auth(value: &str) -> Result<(), RadrootsSimplexSmpProtoError> {
if value
.bytes()
@@ -1184,6 +1218,22 @@ impl<'a> Cursor<'a> {
})
}
+ fn read_legacy_basic_auth(&mut self) -> Result<Option<String>, RadrootsSimplexSmpProtoError> {
+ match self.bytes.get(self.offset).copied() {
+ Some(b'A') => {
+ self.offset += 1;
+ let value = self.read_short_bytes()?;
+ let string = String::from_utf8(value).map_err(|error| {
+ RadrootsSimplexSmpProtoError::InvalidUtf8(error.to_string())
+ })?;
+ validate_basic_auth(&string)?;
+ Ok(Some(string))
+ }
+ Some(_) => Ok(None),
+ None => Err(RadrootsSimplexSmpProtoError::UnexpectedEof),
+ }
+ }
+
fn read_maybe<T, F>(&mut self, decode: F) -> Result<Option<T>, RadrootsSimplexSmpProtoError>
where
F: FnOnce(&mut Self) -> Result<T, RadrootsSimplexSmpProtoError>,
@@ -1270,6 +1320,33 @@ mod tests {
}
#[test]
+ fn round_trips_v6_new_command_transmission() {
+ let transmission = RadrootsSimplexSmpCommandTransmission {
+ authorization: vec![1, 2, 3],
+ correlation_id: Some(correlation_id(7)),
+ entity_id: Vec::new(),
+ command: RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest {
+ recipient_auth_public_key: vec![0x01, 0x02, 0x03],
+ recipient_dh_public_key: vec![0x04, 0x05],
+ basic_auth: Some("server-pass".to_string()),
+ subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe,
+ queue_request_data: None,
+ notifier_credentials: None,
+ }),
+ };
+
+ let encoded = transmission
+ .encode_for_version(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION)
+ .unwrap();
+ let decoded = RadrootsSimplexSmpCommandTransmission::decode_for_version(
+ RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION,
+ &encoded,
+ )
+ .unwrap();
+ assert_eq!(decoded, transmission);
+ }
+
+ #[test]
fn round_trips_send_command_transmission() {
let transmission = RadrootsSimplexSmpCommandTransmission {
authorization: Vec::new(),
@@ -1290,6 +1367,41 @@ mod tests {
}
#[test]
+ fn round_trips_v15_new_command_transmission() {
+ let transmission = RadrootsSimplexSmpCommandTransmission {
+ authorization: vec![1, 2, 3],
+ correlation_id: Some(correlation_id(7)),
+ entity_id: Vec::new(),
+ command: RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest {
+ recipient_auth_public_key: vec![0x01, 0x02, 0x03],
+ recipient_dh_public_key: vec![0x04, 0x05],
+ basic_auth: Some("server-pass".to_string()),
+ subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe,
+ queue_request_data: Some(RadrootsSimplexSmpQueueRequestData::Messaging(Some(
+ RadrootsSimplexSmpMessagingQueueRequest {
+ sender_id: vec![0x10, 0x11],
+ link_data: RadrootsSimplexSmpQueueLinkData {
+ fixed_data: vec![0xaa, 0xbb],
+ user_data: vec![0xcc, 0xdd, 0xee],
+ },
+ },
+ ))),
+ notifier_credentials: None,
+ }),
+ };
+
+ let encoded = transmission
+ .encode_for_version(RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION)
+ .unwrap();
+ let decoded = RadrootsSimplexSmpCommandTransmission::decode_for_version(
+ RADROOTS_SIMPLEX_SMP_SHORT_LINKS_TRANSPORT_VERSION,
+ &encoded,
+ )
+ .unwrap();
+ assert_eq!(decoded, transmission);
+ }
+
+ #[test]
fn round_trips_current_ids_broker_transmission() {
let transmission = RadrootsSimplexSmpBrokerTransmission {
authorization: Vec::new(),
@@ -1376,4 +1488,28 @@ mod tests {
let decoded = RadrootsSimplexSmpBrokerTransmission::decode(&encoded).unwrap();
assert_eq!(decoded, transmission);
}
+
+ #[test]
+ fn v6_new_command_uses_legacy_basic_auth_layout() {
+ let command = RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest {
+ recipient_auth_public_key: vec![0x01, 0x02, 0x03],
+ recipient_dh_public_key: vec![0x04, 0x05],
+ basic_auth: Some("server-pass".to_string()),
+ subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe,
+ queue_request_data: Some(RadrootsSimplexSmpQueueRequestData::Messaging(None)),
+ notifier_credentials: Some(RadrootsSimplexSmpNewNotifierCredentials {
+ notifier_auth_public_key: vec![0x21, 0x22],
+ recipient_notification_dh_public_key: vec![0x23, 0x24],
+ }),
+ });
+
+ let encoded = command
+ .encode_for_version(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION)
+ .unwrap();
+
+ assert_eq!(
+ encoded,
+ b"NEW \x03\x01\x02\x03\x02\x04\x05A\x0bserver-passS".to_vec()
+ );
+ }
}