lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 1611e13de71d0a47717dd07153459bab68bb81e2
parent bd60788825c51d8bbe35b58235d3cd49383231c9
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 06:05:16 +0000

simplex: add short invitation link parsing

Diffstat:
MCargo.lock | 1+
Mcrates/simplex_agent_proto/Cargo.toml | 1+
Mcrates/simplex_agent_proto/src/error.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/simplex_agent_proto/src/lib.rs | 11++++++++++-
Acrates/simplex_agent_proto/src/short_link.rs | 476+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 557 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4577,6 +4577,7 @@ dependencies = [ name = "radroots_simplex_agent_proto" version = "0.1.0-alpha.2" dependencies = [ + "base64 0.22.1", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", ] diff --git a/crates/simplex_agent_proto/Cargo.toml b/crates/simplex_agent_proto/Cargo.toml @@ -17,5 +17,6 @@ default = ["std"] std = ["radroots_simplex_smp_crypto/std", "radroots_simplex_smp_proto/std"] [dependencies] +base64 = { workspace = true } radroots_simplex_smp_crypto = { workspace = true, default-features = false } radroots_simplex_smp_proto = { workspace = true, default-features = false } diff --git a/crates/simplex_agent_proto/src/error.rs b/crates/simplex_agent_proto/src/error.rs @@ -3,6 +3,19 @@ use core::fmt; use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError; #[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexAgentUnsupportedLinkKind { + FullContactLink, + ContactAddress, + Group, + Channel, + Relay, + File, + Xrcp, + Bot, + Unknown(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum RadrootsSimplexAgentProtoError { Proto(RadrootsSimplexSmpProtoError), UnexpectedEof, @@ -13,6 +26,22 @@ pub enum RadrootsSimplexAgentProtoError { InvalidBoolEncoding(u8), InvalidRatchetHeader(String), InvalidE2eParameters(String), + InvalidBase64Url { + field: &'static str, + value: String, + }, + InvalidLink(String), + InvalidLinkFieldLength { + field: &'static str, + expected: usize, + actual: usize, + }, + InvalidLinkParameter { + key: String, + reason: String, + }, + InvalidPort(String), + UnsupportedLink(RadrootsSimplexAgentUnsupportedLinkKind), TrailingBytes, } @@ -44,10 +73,50 @@ impl fmt::Display for RadrootsSimplexAgentProtoError { Self::InvalidE2eParameters(error) => { write!(f, "invalid SimpleX agent E2E parameters: {error}") } + Self::InvalidBase64Url { field, value } => { + write!( + f, + "invalid SimpleX agent base64url value for `{field}`: `{value}`" + ) + } + Self::InvalidLink(link) => write!(f, "invalid SimpleX agent link: {link}"), + Self::InvalidLinkFieldLength { + field, + expected, + actual, + } => { + write!( + f, + "invalid SimpleX agent link `{field}` length {actual}, expected {expected}" + ) + } + Self::InvalidLinkParameter { key, reason } => { + write!(f, "invalid SimpleX agent link parameter `{key}`: {reason}") + } + Self::InvalidPort(port) => write!(f, "invalid SimpleX agent link port `{port}`"), + Self::UnsupportedLink(kind) => { + write!(f, "unsupported SimpleX agent link kind `{kind}`") + } Self::TrailingBytes => write!(f, "trailing bytes after SimpleX agent decode"), } } } +impl fmt::Display for RadrootsSimplexAgentUnsupportedLinkKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FullContactLink => write!(f, "full-contact-link"), + Self::ContactAddress => write!(f, "contact-address"), + Self::Group => write!(f, "group"), + Self::Channel => write!(f, "channel"), + Self::Relay => write!(f, "relay"), + Self::File => write!(f, "file"), + Self::Xrcp => write!(f, "xrcp"), + Self::Bot => write!(f, "bot"), + Self::Unknown(value) => write!(f, "unknown:{value}"), + } + } +} + #[cfg(feature = "std")] impl std::error::Error for RadrootsSimplexAgentProtoError {} diff --git a/crates/simplex_agent_proto/src/lib.rs b/crates/simplex_agent_proto/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; pub mod codec; pub mod error; pub mod model; +pub mod short_link; pub mod prelude { pub use crate::codec::{ @@ -13,7 +14,9 @@ pub mod prelude { decode_envelope, encode_agent_message_frame, encode_connection_link, encode_decrypted_message, encode_envelope, }; - pub use crate::error::RadrootsSimplexAgentProtoError; + pub use crate::error::{ + RadrootsSimplexAgentProtoError, RadrootsSimplexAgentUnsupportedLinkKind, + }; pub use crate::model::{ RADROOTS_SIMPLEX_AGENT_CURRENT_VERSION, RadrootsSimplexAgentConnectionLink, RadrootsSimplexAgentConnectionMode, RadrootsSimplexAgentConnectionStatus, @@ -24,6 +27,12 @@ pub mod prelude { RadrootsSimplexAgentQueueAddress, RadrootsSimplexAgentQueueDescriptor, RadrootsSimplexAgentQueueUseDecision, }; + pub use crate::short_link::{ + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH, RADROOTS_SIMPLEX_AGENT_SHORT_LINK_KEY_LENGTH, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_SERVER_KEY_HASH_LENGTH, + RadrootsSimplexAgentShortInvitationLink, RadrootsSimplexAgentShortLinkScheme, + parse_short_invitation_link, + }; pub use radroots_simplex_smp_crypto::prelude::{ RadrootsSimplexOfficialX3dhParams, RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetState, decode_official_x3dh_params_uri, diff --git a/crates/simplex_agent_proto/src/short_link.rs b/crates/simplex_agent_proto/src/short_link.rs @@ -0,0 +1,476 @@ +use crate::error::{RadrootsSimplexAgentProtoError, RadrootsSimplexAgentUnsupportedLinkKind}; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use base64::Engine as _; +use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; +use core::fmt; +use core::str::FromStr; + +pub const RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH: usize = 24; +pub const RADROOTS_SIMPLEX_AGENT_SHORT_LINK_KEY_LENGTH: usize = 32; +pub const RADROOTS_SIMPLEX_AGENT_SHORT_LINK_SERVER_KEY_HASH_LENGTH: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsSimplexAgentShortLinkScheme { + Simplex, + Https, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexAgentShortInvitationLink { + pub scheme: RadrootsSimplexAgentShortLinkScheme, + pub hosts: Vec<String>, + pub port: Option<u16>, + pub server_key_hash: Option<Vec<u8>>, + pub link_id: Vec<u8>, + pub link_key: Vec<u8>, +} + +impl RadrootsSimplexAgentShortInvitationLink { + pub fn render(&self) -> Result<String, RadrootsSimplexAgentProtoError> { + validate_field_length( + "link_id", + &self.link_id, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH, + )?; + validate_field_length( + "link_key", + &self.link_key, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_KEY_LENGTH, + )?; + let link_id = URL_SAFE_NO_PAD.encode(&self.link_id); + let link_key = URL_SAFE_NO_PAD.encode(&self.link_key); + let mut output = match self.scheme { + RadrootsSimplexAgentShortLinkScheme::Simplex => { + format!("simplex:/i#{link_id}/{link_key}") + } + RadrootsSimplexAgentShortLinkScheme::Https => { + let host = + self.hosts + .first() + .ok_or(RadrootsSimplexAgentProtoError::InvalidLink( + "https short invitation link requires a primary host".to_string(), + ))?; + validate_host(host)?; + format!("https://{host}/i#{link_id}/{link_key}") + } + }; + + let mut query = Vec::<String>::new(); + let query_hosts = match self.scheme { + RadrootsSimplexAgentShortLinkScheme::Simplex => self.hosts.as_slice(), + RadrootsSimplexAgentShortLinkScheme::Https => self.hosts.get(1..).unwrap_or(&[]), + }; + if !query_hosts.is_empty() { + for host in query_hosts { + validate_host(host)?; + } + query.push(format!("h={}", query_hosts.join(","))); + } + if let Some(port) = self.port { + query.push(format!("p={port}")); + } + if let Some(server_key_hash) = self.server_key_hash.as_ref() { + validate_field_length( + "server_key_hash", + server_key_hash, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_SERVER_KEY_HASH_LENGTH, + )?; + query.push(format!("c={}", URL_SAFE_NO_PAD.encode(server_key_hash))); + } + if !query.is_empty() { + output.push('?'); + output.push_str(&query.join("&")); + } + Ok(output) + } +} + +impl fmt::Display for RadrootsSimplexAgentShortInvitationLink { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.render().map_err(|_| fmt::Error)?.fmt(f) + } +} + +impl FromStr for RadrootsSimplexAgentShortInvitationLink { + type Err = RadrootsSimplexAgentProtoError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + parse_short_invitation_link(value) + } +} + +pub fn parse_short_invitation_link( + value: &str, +) -> Result<RadrootsSimplexAgentShortInvitationLink, RadrootsSimplexAgentProtoError> { + let value = value.trim(); + if value.is_empty() { + return Err(RadrootsSimplexAgentProtoError::InvalidLink( + "empty short invitation link".to_string(), + )); + } + + if let Some(rest) = value.strip_prefix("simplex:/") { + return parse_scheme_link( + RadrootsSimplexAgentShortLinkScheme::Simplex, + None, + rest, + value, + ); + } + if let Some(rest) = value.strip_prefix("https://") { + let (authority, path) = rest + .split_once('/') + .ok_or_else(|| RadrootsSimplexAgentProtoError::InvalidLink(value.to_string()))?; + if authority.is_empty() || authority.contains('@') { + return Err(RadrootsSimplexAgentProtoError::InvalidLink( + value.to_string(), + )); + } + validate_host(authority)?; + return parse_scheme_link( + RadrootsSimplexAgentShortLinkScheme::Https, + Some(authority), + path, + value, + ); + } + + Err(RadrootsSimplexAgentProtoError::InvalidLink( + value.to_string(), + )) +} + +fn parse_scheme_link( + scheme: RadrootsSimplexAgentShortLinkScheme, + primary_host: Option<&str>, + rest: &str, + original: &str, +) -> Result<RadrootsSimplexAgentShortInvitationLink, RadrootsSimplexAgentProtoError> { + let (raw_path, fragment_and_query) = rest + .split_once('#') + .ok_or_else(|| RadrootsSimplexAgentProtoError::InvalidLink(original.to_string()))?; + let path = raw_path.strip_suffix('/').unwrap_or(raw_path); + if path != "i" { + return Err(RadrootsSimplexAgentProtoError::UnsupportedLink( + unsupported_path_kind(path), + )); + } + + let (fragment, query) = fragment_and_query + .split_once('?') + .map_or((fragment_and_query, None), |(fragment, query)| { + (fragment, Some(query)) + }); + let (link_id_raw, link_key_raw) = fragment + .split_once('/') + .ok_or_else(|| RadrootsSimplexAgentProtoError::InvalidLink(original.to_string()))?; + if link_id_raw.is_empty() || link_key_raw.is_empty() || link_key_raw.contains('/') { + return Err(RadrootsSimplexAgentProtoError::InvalidLink( + original.to_string(), + )); + } + + let mut hosts = primary_host + .map(|host| alloc::vec![host.to_string()]) + .unwrap_or_default(); + let mut port = None; + let mut server_key_hash = None; + + if let Some(query) = query { + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (key, raw_value) = pair.split_once('=').ok_or_else(|| { + RadrootsSimplexAgentProtoError::InvalidLinkParameter { + key: pair.to_string(), + reason: "parameter must use key=value form".to_string(), + } + })?; + match key { + "h" => { + if hosts.len() > primary_host.iter().count() { + return Err(duplicate_param("h")); + } + let parsed_hosts = parse_hosts(raw_value)?; + hosts.extend(parsed_hosts); + } + "p" => { + if port.replace(parse_port(raw_value)?).is_some() { + return Err(duplicate_param("p")); + } + } + "c" => { + if server_key_hash + .replace(decode_sized_base64url( + "server_key_hash", + raw_value, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_SERVER_KEY_HASH_LENGTH, + )?) + .is_some() + { + return Err(duplicate_param("c")); + } + } + _ => { + return Err(RadrootsSimplexAgentProtoError::InvalidLinkParameter { + key: key.to_string(), + reason: "unsupported short-link parameter".to_string(), + }); + } + } + } + } + + Ok(RadrootsSimplexAgentShortInvitationLink { + scheme, + hosts, + port, + server_key_hash, + link_id: decode_sized_base64url( + "link_id", + link_id_raw, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH, + )?, + link_key: decode_sized_base64url( + "link_key", + link_key_raw, + RADROOTS_SIMPLEX_AGENT_SHORT_LINK_KEY_LENGTH, + )?, + }) +} + +fn unsupported_path_kind(path: &str) -> RadrootsSimplexAgentUnsupportedLinkKind { + match path { + "contact" => RadrootsSimplexAgentUnsupportedLinkKind::FullContactLink, + "a" | "address" => RadrootsSimplexAgentUnsupportedLinkKind::ContactAddress, + "g" | "group" => RadrootsSimplexAgentUnsupportedLinkKind::Group, + "c" | "channel" => RadrootsSimplexAgentUnsupportedLinkKind::Channel, + "r" | "relay" => RadrootsSimplexAgentUnsupportedLinkKind::Relay, + "f" | "file" => RadrootsSimplexAgentUnsupportedLinkKind::File, + "x" | "xrcp" => RadrootsSimplexAgentUnsupportedLinkKind::Xrcp, + "b" | "bot" => RadrootsSimplexAgentUnsupportedLinkKind::Bot, + _ => RadrootsSimplexAgentUnsupportedLinkKind::Unknown(path.to_string()), + } +} + +fn decode_base64url( + field: &'static str, + value: &str, +) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + URL_SAFE_NO_PAD + .decode(value.as_bytes()) + .or_else(|_| URL_SAFE.decode(value.as_bytes())) + .map_err(|_| RadrootsSimplexAgentProtoError::InvalidBase64Url { + field, + value: value.to_string(), + }) +} + +fn decode_sized_base64url( + field: &'static str, + value: &str, + expected: usize, +) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + let bytes = decode_base64url(field, value)?; + validate_field_length(field, &bytes, expected)?; + Ok(bytes) +} + +fn validate_field_length( + field: &'static str, + bytes: &[u8], + expected: usize, +) -> Result<(), RadrootsSimplexAgentProtoError> { + if bytes.len() != expected { + return Err(RadrootsSimplexAgentProtoError::InvalidLinkFieldLength { + field, + expected, + actual: bytes.len(), + }); + } + Ok(()) +} + +fn parse_hosts(value: &str) -> Result<Vec<String>, RadrootsSimplexAgentProtoError> { + if value.is_empty() { + return Err(RadrootsSimplexAgentProtoError::InvalidLinkParameter { + key: "h".to_string(), + reason: "host list cannot be empty".to_string(), + }); + } + let hosts = value + .split(',') + .map(|host| host.trim().to_string()) + .collect::<Vec<_>>(); + for host in &hosts { + validate_host(host)?; + } + Ok(hosts) +} + +fn validate_host(host: &str) -> Result<(), RadrootsSimplexAgentProtoError> { + if host.is_empty() + || host + .chars() + .any(|ch| ch.is_ascii_whitespace() || matches!(ch, '/' | '?' | '#' | '&' | '=' | ',')) + { + return Err(RadrootsSimplexAgentProtoError::InvalidLinkParameter { + key: "h".to_string(), + reason: "host contains an invalid short-link character".to_string(), + }); + } + Ok(()) +} + +fn parse_port(value: &str) -> Result<u16, RadrootsSimplexAgentProtoError> { + value + .parse::<u16>() + .map_err(|_| RadrootsSimplexAgentProtoError::InvalidPort(value.to_string())) +} + +fn duplicate_param(key: &str) -> RadrootsSimplexAgentProtoError { + RadrootsSimplexAgentProtoError::InvalidLinkParameter { + key: key.to_string(), + reason: "duplicate short-link parameter".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_link() -> RadrootsSimplexAgentShortInvitationLink { + RadrootsSimplexAgentShortInvitationLink { + scheme: RadrootsSimplexAgentShortLinkScheme::Simplex, + hosts: alloc::vec!["relay-a.example".to_string(), "relay-b.example".to_string()], + port: Some(5223), + server_key_hash: Some((0_u8..32).collect()), + link_id: (32_u8..56).collect(), + link_key: (64_u8..96).collect(), + } + } + + #[test] + fn renders_and_parses_simplex_invitation_short_link() { + let link = sample_link(); + let rendered = link.render().expect("rendered link"); + + assert!(rendered.starts_with("simplex:/i#")); + assert!(rendered.contains("?h=relay-a.example,relay-b.example&p=5223&c=")); + let fragment = rendered + .split_once('#') + .expect("fragment") + .1 + .split_once('?') + .expect("query") + .0; + assert!(!fragment.contains('=')); + assert_eq!( + parse_short_invitation_link(&rendered).expect("parsed"), + link + ); + } + + #[test] + fn renders_and_parses_https_invitation_short_link() { + let mut link = sample_link(); + link.scheme = RadrootsSimplexAgentShortLinkScheme::Https; + link.hosts = alloc::vec!["relay-a.example".to_string(), "relay-b.example".to_string()]; + + let rendered = link.render().expect("rendered link"); + + assert!(rendered.starts_with("https://relay-a.example/i#")); + assert!(rendered.contains("?h=relay-b.example&p=5223&c=")); + assert_eq!( + parse_short_invitation_link(&rendered).expect("parsed"), + link + ); + } + + #[test] + fn rejects_full_contact_links() { + let error = parse_short_invitation_link("simplex:/contact#/?v=1&smp=ignored&e2e=ignored") + .expect_err("full links fail"); + + assert!(matches!( + error, + RadrootsSimplexAgentProtoError::UnsupportedLink( + RadrootsSimplexAgentUnsupportedLinkKind::FullContactLink + ) + )); + } + + #[test] + fn rejects_unsupported_short_link_kinds() { + let link = sample_link().render().expect("rendered link"); + let (_, fragment) = link.split_once('#').expect("fragment"); + let contact = format!("simplex:/a#{fragment}"); + let group = format!("simplex:/g#{fragment}"); + let channel = format!("simplex:/c#{fragment}"); + + assert!(matches!( + parse_short_invitation_link(&contact), + Err(RadrootsSimplexAgentProtoError::UnsupportedLink( + RadrootsSimplexAgentUnsupportedLinkKind::ContactAddress + )) + )); + assert!(matches!( + parse_short_invitation_link(&group), + Err(RadrootsSimplexAgentProtoError::UnsupportedLink( + RadrootsSimplexAgentUnsupportedLinkKind::Group + )) + )); + assert!(matches!( + parse_short_invitation_link(&channel), + Err(RadrootsSimplexAgentProtoError::UnsupportedLink( + RadrootsSimplexAgentUnsupportedLinkKind::Channel + )) + )); + } + + #[test] + fn rejects_invalid_base64url_parts() { + let error = + parse_short_invitation_link("simplex:/i#***/AAAA").expect_err("invalid link id fails"); + + assert!(matches!( + error, + RadrootsSimplexAgentProtoError::InvalidBase64Url { + field: "link_id", + .. + } + )); + } + + #[test] + fn rejects_wrong_sized_decodable_parts() { + let link_id = URL_SAFE_NO_PAD.encode([1_u8; RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH]); + let link_key = URL_SAFE_NO_PAD.encode([2_u8; 4]); + let error = parse_short_invitation_link(&format!("simplex:/i#{link_id}/{link_key}")) + .expect_err("short link key fails"); + + assert!(matches!( + error, + RadrootsSimplexAgentProtoError::InvalidLinkFieldLength { + field: "link_key", + expected: RADROOTS_SIMPLEX_AGENT_SHORT_LINK_KEY_LENGTH, + actual: 4, + } + )); + } + + #[test] + fn rejects_unknown_query_parameters() { + let link = sample_link().render().expect("rendered link"); + let error = parse_short_invitation_link(&format!("{link}&z=1")) + .expect_err("unknown parameter fails"); + + assert!(matches!( + error, + RadrootsSimplexAgentProtoError::InvalidLinkParameter { key, .. } if key == "z" + )); + } +}