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:
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"
+ ));
+ }
+}