lib

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

commit a7857a0bde787f477788eefb7cfdf9700f15a125
parent 40acc9c4d65719b16a98c9eeb8ae896b30559db8
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 22:57:58 +0000

simplex: emit official short-link data

- replace the generated short invitation fixed data with official SimpleX frames

- add official invitation user-data decoding and runtime verification

- decode Ed25519 public-key blobs for signed short-link payload verification

Diffstat:
Mcrates/simplex_agent_proto/src/lib.rs | 3++-
Mcrates/simplex_agent_proto/src/short_link.rs | 502++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/simplex_agent_runtime/src/runtime.rs | 26++++++++++++++++----------
Mcrates/simplex_smp_crypto/src/auth.rs | 12++++++++++++
Mcrates/simplex_smp_crypto/src/lib.rs | 4++--
5 files changed, 492 insertions(+), 55 deletions(-)

diff --git a/crates/simplex_agent_proto/src/lib.rs b/crates/simplex_agent_proto/src/lib.rs @@ -31,7 +31,8 @@ pub mod prelude { RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH, RADROOTS_SIMPLEX_AGENT_SHORT_LINK_KEY_LENGTH, RADROOTS_SIMPLEX_AGENT_SHORT_LINK_SERVER_KEY_HASH_LENGTH, RadrootsSimplexAgentShortInvitationFixedData, RadrootsSimplexAgentShortInvitationLink, - RadrootsSimplexAgentShortLinkScheme, decode_short_invitation_fixed_data, + RadrootsSimplexAgentShortInvitationUserData, RadrootsSimplexAgentShortLinkScheme, + decode_short_invitation_fixed_data, decode_short_invitation_user_data, encode_short_invitation_fixed_data, encode_short_invitation_user_data, parse_short_invitation_link, }; diff --git a/crates/simplex_agent_proto/src/short_link.rs b/crates/simplex_agent_proto/src/short_link.rs @@ -1,4 +1,3 @@ -use crate::codec::{decode_connection_link, encode_connection_link}; use crate::error::{RadrootsSimplexAgentProtoError, RadrootsSimplexAgentUnsupportedLinkKind}; use crate::model::RadrootsSimplexAgentConnectionLink; use alloc::format; @@ -8,11 +7,32 @@ use base64::Engine as _; use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; use core::fmt; use core::str::FromStr; +use radroots_simplex_smp_crypto::prelude::{ + RadrootsSimplexOfficialX3dhParams, decode_ed25519_public_key_x509, + decode_official_x448_public_key_der, decode_x25519_public_key_x509, + encode_ed25519_public_key_x509, encode_official_x448_public_key_der, + encode_x25519_public_key_x509, +}; +use radroots_simplex_smp_proto::prelude::{ + RadrootsSimplexSmpQueueMode, RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpServerAddress, + RadrootsSimplexSmpVersionRange, +}; 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; -const RADROOTS_SIMPLEX_AGENT_SHORT_INVITATION_FIXED_DATA_TAG: &[u8] = b"RRSIF1"; +const SIMPLEX_AGENT_SHORT_LINK_MIN_VERSION: u16 = 2; +const SIMPLEX_AGENT_SHORT_LINK_CURRENT_VERSION: u16 = 7; +const SIMPLEX_CONNECTION_MODE_INVITATION: u8 = b'I'; +const SIMPLEX_QUEUE_MODE_MESSAGING: u8 = b'M'; +const SIMPLEX_QUEUE_MODE_CONTACT: u8 = b'C'; +const SIMPLEX_MAYBE_NOTHING: u8 = b'0'; +const SIMPLEX_MAYBE_JUST: u8 = b'1'; +const SIMPLEX_RATCHET_KEM_PROPOSED: u8 = b'P'; +const SIMPLEX_RATCHET_KEM_ACCEPTED: u8 = b'A'; +const SIMPLEX_USER_LINK_DATA_LARGE_TAG: u8 = u8::MAX; + +type ShortLinkResult<T> = Result<T, RadrootsSimplexAgentProtoError>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsSimplexAgentShortLinkScheme { @@ -32,8 +52,16 @@ pub struct RadrootsSimplexAgentShortInvitationLink { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexAgentShortInvitationFixedData { + pub agent_version_range: RadrootsSimplexSmpVersionRange, pub root_public_signature_key: Vec<u8>, pub invitation: RadrootsSimplexAgentConnectionLink, + pub link_entity_id: Option<Vec<u8>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexAgentShortInvitationUserData { + pub agent_version_range: RadrootsSimplexSmpVersionRange, + pub user_data: Vec<u8>, } impl RadrootsSimplexAgentShortInvitationLink { @@ -100,11 +128,13 @@ pub fn encode_short_invitation_fixed_data( root_public_signature_key: &[u8], invitation: &RadrootsSimplexAgentConnectionLink, ) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { - let encoded_invitation = encode_connection_link(invitation)?; + let agent_version_range = official_agent_version_range()?; + let encoded_root_public_key = encode_ed25519_public_key_x509(root_public_signature_key) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidLink(error.to_string()))?; let mut buffer = Vec::new(); - buffer.extend_from_slice(RADROOTS_SIMPLEX_AGENT_SHORT_INVITATION_FIXED_DATA_TAG); - push_short_bytes(&mut buffer, root_public_signature_key)?; - push_large_bytes(&mut buffer, &encoded_invitation)?; + push_version_range(&mut buffer, agent_version_range); + push_short_bytes(&mut buffer, &encoded_root_public_key)?; + encode_official_invitation_connection_request(&mut buffer, agent_version_range, invitation)?; Ok(buffer) } @@ -112,20 +142,48 @@ pub fn decode_short_invitation_fixed_data( bytes: &[u8], ) -> Result<RadrootsSimplexAgentShortInvitationFixedData, RadrootsSimplexAgentProtoError> { let mut cursor = ShortLinkDataCursor::new(bytes); - cursor.expect_tag(RADROOTS_SIMPLEX_AGENT_SHORT_INVITATION_FIXED_DATA_TAG)?; - let root_public_signature_key = cursor.read_short_bytes()?; - let invitation = decode_connection_link(&cursor.read_large_bytes()?)?; - cursor.finish()?; + let agent_version_range = cursor.read_version_range()?; + let root_public_signature_key = decode_ed25519_public_key_x509(&cursor.read_short_bytes()?) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidLink(error.to_string()))?; + let mut invitation = decode_official_invitation_connection_request(&mut cursor)?; + let link_entity_id = if cursor.remaining().is_empty() { + None + } else { + Some(cursor.read_short_bytes()?) + }; + if let Some(link_entity_id) = link_entity_id.as_ref() { + invitation.connection_id = link_entity_id.clone(); + } Ok(RadrootsSimplexAgentShortInvitationFixedData { + agent_version_range, root_public_signature_key, invitation, + link_entity_id, }) } pub fn encode_short_invitation_user_data( invitation: &RadrootsSimplexAgentConnectionLink, -) -> Vec<u8> { - invitation.connection_id.clone() +) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + let agent_version_range = official_agent_version_range()?; + let mut buffer = Vec::new(); + buffer.push(SIMPLEX_CONNECTION_MODE_INVITATION); + push_version_range(&mut buffer, agent_version_range); + push_user_link_data(&mut buffer, &invitation.connection_id)?; + Ok(buffer) +} + +pub fn decode_short_invitation_user_data( + bytes: &[u8], +) -> Result<RadrootsSimplexAgentShortInvitationUserData, RadrootsSimplexAgentProtoError> { + let mut cursor = ShortLinkDataCursor::new(bytes); + cursor.expect_byte(SIMPLEX_CONNECTION_MODE_INVITATION)?; + let agent_version_range = cursor.read_version_range()?; + let user_data = cursor.read_user_link_data()?; + Ok(RadrootsSimplexAgentShortInvitationUserData { + agent_version_range, + user_data, + }) } impl fmt::Display for RadrootsSimplexAgentShortInvitationLink { @@ -379,6 +437,151 @@ fn duplicate_param(key: &str) -> RadrootsSimplexAgentProtoError { } } +fn official_agent_version_range() -> ShortLinkResult<RadrootsSimplexSmpVersionRange> { + Ok(RadrootsSimplexSmpVersionRange::new( + SIMPLEX_AGENT_SHORT_LINK_MIN_VERSION, + SIMPLEX_AGENT_SHORT_LINK_CURRENT_VERSION, + )?) +} + +fn encode_official_invitation_connection_request( + buffer: &mut Vec<u8>, + agent_version_range: RadrootsSimplexSmpVersionRange, + invitation: &RadrootsSimplexAgentConnectionLink, +) -> Result<(), RadrootsSimplexAgentProtoError> { + buffer.push(SIMPLEX_CONNECTION_MODE_INVITATION); + push_version_range(buffer, agent_version_range); + push_queue_list(buffer, core::slice::from_ref(&invitation.invitation_queue))?; + push_maybe_large_bytes(buffer, None)?; + encode_official_x3dh_params(buffer, &invitation.e2e_ratchet_params) +} + +fn decode_official_invitation_connection_request( + cursor: &mut ShortLinkDataCursor<'_>, +) -> Result<RadrootsSimplexAgentConnectionLink, RadrootsSimplexAgentProtoError> { + cursor.expect_byte(SIMPLEX_CONNECTION_MODE_INVITATION)?; + let _agent_version_range = cursor.read_version_range()?; + let invitation_queues = cursor.read_queue_list()?; + let _client_data = cursor.read_maybe_large_bytes()?; + let e2e_ratchet_params = cursor.read_x3dh_params()?; + let invitation_queue = invitation_queues.into_iter().next().ok_or_else(|| { + RadrootsSimplexAgentProtoError::InvalidLink( + "short invitation connection request has no SMP queues".to_string(), + ) + })?; + Ok(RadrootsSimplexAgentConnectionLink { + invitation_queue, + connection_id: Vec::new(), + e2e_ratchet_params, + contact_address: false, + }) +} + +fn push_version_range(buffer: &mut Vec<u8>, version_range: RadrootsSimplexSmpVersionRange) { + buffer.extend_from_slice(&version_range.min.to_be_bytes()); + buffer.extend_from_slice(&version_range.max.to_be_bytes()); +} + +fn push_queue_list( + buffer: &mut Vec<u8>, + queues: &[RadrootsSimplexSmpQueueUri], +) -> Result<(), RadrootsSimplexAgentProtoError> { + if queues.is_empty() || queues.len() > u8::MAX as usize { + return Err(RadrootsSimplexAgentProtoError::InvalidShortFieldLength( + queues.len(), + )); + } + buffer.push(queues.len() as u8); + for queue in queues { + encode_official_queue_uri(buffer, queue)?; + } + Ok(()) +} + +fn encode_official_queue_uri( + buffer: &mut Vec<u8>, + queue: &RadrootsSimplexSmpQueueUri, +) -> Result<(), RadrootsSimplexAgentProtoError> { + push_version_range(buffer, queue.version_range); + encode_official_server_address(buffer, &queue.server)?; + push_short_bytes(buffer, &decode_base64url("sender_id", &queue.sender_id)?)?; + let queue_public_key = + decode_base64url("recipient_dh_public_key", &queue.recipient_dh_public_key)?; + let queue_public_key = encode_x25519_public_key_x509( + &decode_x25519_public_key_x509(&queue_public_key) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidLink(error.to_string()))?, + ) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidLink(error.to_string()))?; + push_short_bytes(buffer, &queue_public_key)?; + if queue.version_range.min >= 4 { + if let Some(queue_mode) = queue.queue_mode { + buffer.push(match queue_mode { + RadrootsSimplexSmpQueueMode::Messaging => SIMPLEX_QUEUE_MODE_MESSAGING, + RadrootsSimplexSmpQueueMode::Contact => SIMPLEX_QUEUE_MODE_CONTACT, + }); + } + } else if queue.sender_can_secure() { + buffer.push(b'T'); + } + Ok(()) +} + +fn encode_official_server_address( + buffer: &mut Vec<u8>, + server: &RadrootsSimplexSmpServerAddress, +) -> Result<(), RadrootsSimplexAgentProtoError> { + push_string_list(buffer, &server.hosts)?; + let port = server + .port + .map_or_else(String::new, |port| port.to_string()); + push_string(buffer, &port)?; + push_short_bytes( + buffer, + &decode_base64url("server_identity", &server.server_identity)?, + ) +} + +fn encode_official_x3dh_params( + buffer: &mut Vec<u8>, + params: &RadrootsSimplexOfficialX3dhParams, +) -> Result<(), RadrootsSimplexAgentProtoError> { + push_version_range(buffer, params.version_range); + push_short_bytes( + buffer, + &encode_official_x448_public_key_der(&params.key_1).map_err(|error| { + RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()) + })?, + )?; + push_short_bytes( + buffer, + &encode_official_x448_public_key_der(&params.key_2).map_err(|error| { + RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()) + })?, + )?; + buffer.push(SIMPLEX_MAYBE_NOTHING); + Ok(()) +} + +fn push_string_list( + buffer: &mut Vec<u8>, + values: &[String], +) -> Result<(), RadrootsSimplexAgentProtoError> { + if values.is_empty() || values.len() > u8::MAX as usize { + return Err(RadrootsSimplexAgentProtoError::InvalidShortFieldLength( + values.len(), + )); + } + buffer.push(values.len() as u8); + for value in values { + push_string(buffer, value)?; + } + Ok(()) +} + +fn push_string(buffer: &mut Vec<u8>, value: &str) -> Result<(), RadrootsSimplexAgentProtoError> { + push_short_bytes(buffer, value.as_bytes()) +} + fn push_short_bytes( buffer: &mut Vec<u8>, value: &[u8], @@ -393,6 +596,18 @@ fn push_short_bytes( Ok(()) } +fn push_user_link_data( + buffer: &mut Vec<u8>, + value: &[u8], +) -> Result<(), RadrootsSimplexAgentProtoError> { + if value.len() < SIMPLEX_USER_LINK_DATA_LARGE_TAG as usize { + push_short_bytes(buffer, value) + } else { + buffer.push(SIMPLEX_USER_LINK_DATA_LARGE_TAG); + push_large_bytes(buffer, value) + } +} + fn push_large_bytes( buffer: &mut Vec<u8>, value: &[u8], @@ -407,6 +622,22 @@ fn push_large_bytes( Ok(()) } +fn push_maybe_large_bytes( + buffer: &mut Vec<u8>, + value: Option<&[u8]>, +) -> Result<(), RadrootsSimplexAgentProtoError> { + match value { + Some(value) => { + buffer.push(SIMPLEX_MAYBE_JUST); + push_large_bytes(buffer, value) + } + None => { + buffer.push(SIMPLEX_MAYBE_NOTHING); + Ok(()) + } + } +} + struct ShortLinkDataCursor<'a> { bytes: &'a [u8], offset: usize, @@ -417,20 +648,185 @@ impl<'a> ShortLinkDataCursor<'a> { Self { bytes, offset: 0 } } - fn expect_tag(&mut self, tag: &[u8]) -> Result<(), RadrootsSimplexAgentProtoError> { - if self.remaining().len() < tag.len() { - return Err(RadrootsSimplexAgentProtoError::UnexpectedEof); - } - let next = &self.remaining()[..tag.len()]; - if next != tag { + fn expect_byte(&mut self, expected: u8) -> Result<(), RadrootsSimplexAgentProtoError> { + let actual = self.read_byte()?; + if actual != expected { return Err(RadrootsSimplexAgentProtoError::InvalidTag( - String::from_utf8_lossy(next).into_owned(), + String::from_utf8_lossy(&[actual]).into_owned(), )); } - self.offset += tag.len(); Ok(()) } + fn read_version_range( + &mut self, + ) -> Result<RadrootsSimplexSmpVersionRange, RadrootsSimplexAgentProtoError> { + if self.remaining().len() < 4 { + return Err(RadrootsSimplexAgentProtoError::UnexpectedEof); + } + let min = u16::from_be_bytes([self.bytes[self.offset], self.bytes[self.offset + 1]]); + let max = u16::from_be_bytes([self.bytes[self.offset + 2], self.bytes[self.offset + 3]]); + self.offset += 4; + Ok(RadrootsSimplexSmpVersionRange::new(min, max)?) + } + + fn read_queue_list( + &mut self, + ) -> Result<Vec<RadrootsSimplexSmpQueueUri>, RadrootsSimplexAgentProtoError> { + let len = self.read_byte()? as usize; + if len == 0 { + return Err(RadrootsSimplexAgentProtoError::InvalidShortFieldLength(0)); + } + let mut queues = Vec::with_capacity(len); + for _ in 0..len { + queues.push(self.read_queue_uri()?); + } + Ok(queues) + } + + fn read_queue_uri( + &mut self, + ) -> Result<RadrootsSimplexSmpQueueUri, RadrootsSimplexAgentProtoError> { + let version_range = self.read_version_range()?; + let server = self.read_server_address()?; + let sender_id = URL_SAFE.encode(self.read_short_bytes()?); + let recipient_dh_public_key = self.read_short_bytes()?; + let recipient_dh_public_key = encode_x25519_public_key_x509( + &decode_x25519_public_key_x509(&recipient_dh_public_key) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidLink(error.to_string()))?, + ) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidLink(error.to_string()))?; + let recipient_dh_public_key = URL_SAFE.encode(recipient_dh_public_key); + let queue_mode = match self.peek_byte() { + Some(SIMPLEX_QUEUE_MODE_MESSAGING) => { + self.read_byte()?; + Some(RadrootsSimplexSmpQueueMode::Messaging) + } + Some(SIMPLEX_QUEUE_MODE_CONTACT) => { + self.read_byte()?; + Some(RadrootsSimplexSmpQueueMode::Contact) + } + Some(b'T') if version_range.min < 4 => { + self.read_byte()?; + Some(RadrootsSimplexSmpQueueMode::Messaging) + } + Some(b'F') if version_range.min < 4 => { + self.read_byte()?; + Some(RadrootsSimplexSmpQueueMode::Contact) + } + _ => None, + }; + Ok(RadrootsSimplexSmpQueueUri { + server, + sender_id, + version_range, + recipient_dh_public_key, + queue_mode, + }) + } + + fn read_server_address( + &mut self, + ) -> Result<RadrootsSimplexSmpServerAddress, RadrootsSimplexAgentProtoError> { + let hosts = self.read_string_list()?; + let port = match self.read_string()?.as_str() { + "" => None, + value => Some( + value + .parse::<u16>() + .map_err(|_| RadrootsSimplexAgentProtoError::InvalidPort(value.to_string()))?, + ), + }; + let server_identity = URL_SAFE.encode(self.read_short_bytes()?); + Ok(RadrootsSimplexSmpServerAddress { + server_identity, + hosts, + port, + }) + } + + fn read_string_list(&mut self) -> Result<Vec<String>, RadrootsSimplexAgentProtoError> { + let len = self.read_byte()? as usize; + if len == 0 { + return Err(RadrootsSimplexAgentProtoError::InvalidShortFieldLength(0)); + } + let mut values = Vec::with_capacity(len); + for _ in 0..len { + values.push(self.read_string()?); + } + Ok(values) + } + + fn read_string(&mut self) -> Result<String, RadrootsSimplexAgentProtoError> { + String::from_utf8(self.read_short_bytes()?) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidUtf8(error.to_string())) + } + + fn read_x3dh_params( + &mut self, + ) -> Result<RadrootsSimplexOfficialX3dhParams, RadrootsSimplexAgentProtoError> { + let version_range = self.read_version_range()?; + let key_1 = + decode_official_x448_public_key_der(&self.read_short_bytes()?).map_err(|error| { + RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()) + })?; + let key_2 = + decode_official_x448_public_key_der(&self.read_short_bytes()?).map_err(|error| { + RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()) + })?; + let (pq_public_key, pq_ciphertext) = self.read_optional_kem_params()?; + Ok(RadrootsSimplexOfficialX3dhParams { + version_range, + key_1, + key_2, + pq_public_key, + pq_ciphertext, + }) + } + + fn read_optional_kem_params( + &mut self, + ) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), RadrootsSimplexAgentProtoError> { + match self.read_byte()? { + SIMPLEX_MAYBE_NOTHING => Ok((None, None)), + SIMPLEX_MAYBE_JUST => match self.read_byte()? { + SIMPLEX_RATCHET_KEM_PROPOSED => Ok((Some(self.read_large_bytes()?), None)), + SIMPLEX_RATCHET_KEM_ACCEPTED => { + let ciphertext = self.read_large_bytes()?; + let public_key = self.read_large_bytes()?; + Ok((Some(public_key), Some(ciphertext))) + } + tag => Err(RadrootsSimplexAgentProtoError::InvalidTag( + String::from_utf8_lossy(&[tag]).into_owned(), + )), + }, + tag => Err(RadrootsSimplexAgentProtoError::InvalidTag( + String::from_utf8_lossy(&[tag]).into_owned(), + )), + } + } + + fn read_maybe_large_bytes( + &mut self, + ) -> Result<Option<Vec<u8>>, RadrootsSimplexAgentProtoError> { + match self.read_byte()? { + SIMPLEX_MAYBE_NOTHING => Ok(None), + SIMPLEX_MAYBE_JUST => Ok(Some(self.read_large_bytes()?)), + tag => Err(RadrootsSimplexAgentProtoError::InvalidTag( + String::from_utf8_lossy(&[tag]).into_owned(), + )), + } + } + + fn read_user_link_data(&mut self) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + let len = self.read_byte()?; + if len == SIMPLEX_USER_LINK_DATA_LARGE_TAG { + self.read_large_bytes() + } else { + self.read_exact(len as usize) + } + } + fn read_short_bytes(&mut self) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { let len = self.read_byte()? as usize; self.read_exact(len) @@ -455,6 +851,10 @@ impl<'a> ShortLinkDataCursor<'a> { Ok(value) } + fn peek_byte(&self) -> Option<u8> { + self.bytes.get(self.offset).copied() + } + fn read_exact(&mut self, len: usize) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { if self.remaining().len() < len { return Err(RadrootsSimplexAgentProtoError::UnexpectedEof); @@ -467,14 +867,6 @@ impl<'a> ShortLinkDataCursor<'a> { fn remaining(&self) -> &'a [u8] { &self.bytes[self.offset..] } - - fn finish(&self) -> Result<(), RadrootsSimplexAgentProtoError> { - if self.offset == self.bytes.len() { - Ok(()) - } else { - Err(RadrootsSimplexAgentProtoError::TrailingBytes) - } - } } #[cfg(test)] @@ -493,6 +885,18 @@ mod tests { } fn sample_connection_link() -> RadrootsSimplexAgentConnectionLink { + let queue_key = + radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpX25519Keypair::from_seed( + b"rr-synth-short-link-queue-dh", + ); + let server_id = URL_SAFE.encode([7_u8; 32]); + let sender_id = URL_SAFE.encode([9_u8; RADROOTS_SIMPLEX_AGENT_SHORT_LINK_ID_LENGTH]); + let queue_dh = URL_SAFE.encode( + radroots_simplex_smp_crypto::prelude::encode_x25519_public_key_x509( + &queue_key.public_key, + ) + .expect("queue key"), + ); let key_1 = radroots_simplex_smp_crypto::prelude::official_x448_keypair_from_seed( b"rr-synth-short-link-x3dh-1", ); @@ -501,9 +905,9 @@ mod tests { ); RadrootsSimplexAgentConnectionLink { invitation_queue: - radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueUri::parse( - "smp://c2VydmVyLWlk@relay.example/c2VuZGVy#/?v=4&dh=cmVjZWl2ZXI&q=m", - ) + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueUri::parse(&format!( + "smp://{server_id}@relay.example/{sender_id}#/?v=4&dh={queue_dh}&q=m" + )) .expect("queue"), connection_id: b"conn-synth-short-link".to_vec(), e2e_ratchet_params: @@ -650,25 +1054,39 @@ mod tests { let encoded = encode_short_invitation_fixed_data(&root_public_key, &invitation).expect("encoded"); let decoded = decode_short_invitation_fixed_data(&encoded).expect("decoded"); + let encoded_user_data = encode_short_invitation_user_data(&invitation).expect("user data"); + let decoded_user_data = + decode_short_invitation_user_data(&encoded_user_data).expect("decoded user data"); + assert_ne!(&encoded[..6], b"RRSIF1"); + assert_eq!(decoded.agent_version_range.min, 2); + assert_eq!(decoded.agent_version_range.max, 7); assert_eq!(decoded.root_public_signature_key, root_public_key); - assert_eq!(decoded.invitation, invitation); + assert_eq!(decoded.link_entity_id, None); + assert!(decoded.invitation.connection_id.is_empty()); + assert_eq!( + decoded.invitation.invitation_queue, + invitation.invitation_queue + ); assert_eq!( - encode_short_invitation_user_data(&decoded.invitation), + decoded.invitation.e2e_ratchet_params, + invitation.e2e_ratchet_params + ); + assert_eq!(decoded_user_data.agent_version_range.min, 2); + assert_eq!(decoded_user_data.agent_version_range.max, 7); + assert_eq!( + decoded_user_data.user_data, b"conn-synth-short-link".to_vec() ); } #[test] - fn rejects_short_invitation_fixed_data_with_trailing_bytes() { - let mut encoded = - encode_short_invitation_fixed_data(&[42_u8; 32], &sample_connection_link()) - .expect("encoded"); - encoded.push(0); + fn rejects_legacy_radroots_short_invitation_fixed_data() { + let mut legacy = b"RRSIF1".to_vec(); + legacy.push(32); + legacy.extend_from_slice(&[42_u8; 32]); + legacy.extend_from_slice(&0_u16.to_be_bytes()); - assert!(matches!( - decode_short_invitation_fixed_data(&encoded), - Err(RadrootsSimplexAgentProtoError::TrailingBytes) - )); + assert!(decode_short_invitation_fixed_data(&legacy).is_err()); } } diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -16,8 +16,8 @@ use radroots_simplex_agent_proto::prelude::{ RadrootsSimplexAgentQueueAddress, RadrootsSimplexAgentQueueDescriptor, RadrootsSimplexAgentShortInvitationLink, RadrootsSimplexAgentShortLinkScheme, decode_decrypted_message, decode_envelope, decode_short_invitation_fixed_data, - encode_decrypted_message, encode_envelope, encode_short_invitation_fixed_data, - encode_short_invitation_user_data, + decode_short_invitation_user_data, encode_decrypted_message, encode_envelope, + encode_short_invitation_fixed_data, encode_short_invitation_user_data, }; use radroots_simplex_agent_store::prelude::{ RadrootsSimplexAgentOutboundMessage, RadrootsSimplexAgentPendingCommand, @@ -110,18 +110,22 @@ pub fn decrypt_short_invitation_link_data( } let fixed_payload = &signed_link_data.fixed_data[RADROOTS_SIMPLEX_SMP_SHORT_LINK_SIGNATURE_LENGTH..]; - let fixed_data = decode_short_invitation_fixed_data(fixed_payload)?; + let mut fixed_data = decode_short_invitation_fixed_data(fixed_payload)?; let verified = verify_signed_short_link_data( &invitation.link_key, &fixed_data.root_public_signature_key, &signed_link_data, ) .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; - if verified.user_data != encode_short_invitation_user_data(&fixed_data.invitation) { + let user_data = decode_short_invitation_user_data(&verified.user_data)?; + if !fixed_data.invitation.connection_id.is_empty() + && fixed_data.invitation.connection_id != user_data.user_data + { return Err(RadrootsSimplexAgentRuntimeError::Runtime( "SimpleX short invitation user data does not match the fixed connection link".into(), )); } + fixed_data.invitation.connection_id = user_data.user_data; Ok(fixed_data.invitation) } @@ -2565,7 +2569,7 @@ fn prepare_short_invitation_link_data( let root_keypair = RadrootsSimplexSmpEd25519Keypair::generate() .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; let fixed_data = encode_short_invitation_fixed_data(&root_keypair.public_key, invitation)?; - let user_data = encode_short_invitation_user_data(invitation); + let user_data = encode_short_invitation_user_data(invitation)?; let (link_key, signed_link_data) = sign_short_link_data(&root_keypair, &fixed_data, &user_data) .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; let link_data_key = derive_invitation_short_link_data_key(&link_key) @@ -3265,11 +3269,13 @@ mod tests { decoded.root_public_signature_key, short_link.link_public_signature_key ); - assert_eq!( - decoded.invitation.connection_id, - created.as_bytes().to_vec() - ); - assert_eq!(verified.user_data, created.as_bytes().to_vec()); + assert!(decoded.invitation.connection_id.is_empty()); + let decoded_user_data = + radroots_simplex_agent_proto::prelude::decode_short_invitation_user_data( + &verified.user_data, + ) + .unwrap(); + assert_eq!(decoded_user_data.user_data, created.as_bytes().to_vec()); let decrypted_invitation = decrypt_short_invitation_link_data(invitation, &stored_link_data).unwrap(); assert_eq!( diff --git a/crates/simplex_smp_crypto/src/auth.rs b/crates/simplex_smp_crypto/src/auth.rs @@ -171,6 +171,18 @@ pub fn encode_ed25519_public_key_x509( Ok(encoded) } +pub fn decode_ed25519_public_key_x509( + encoded: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let raw = encoded.strip_prefix(ED25519_SPKI_DER_PREFIX).ok_or( + RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(encoded.len()), + )?; + let key: [u8; 32] = raw + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(encoded.len()))?; + Ok(key.to_vec()) +} + pub fn encode_x25519_public_key_x509( public_key: &[u8], ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { diff --git a/crates/simplex_smp_crypto/src/lib.rs b/crates/simplex_smp_crypto/src/lib.rs @@ -14,8 +14,8 @@ pub mod prelude { pub use crate::auth::{ RadrootsSimplexSmpCommandAuthorization, RadrootsSimplexSmpEd25519Keypair, RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, - decode_x25519_public_key_x509, encode_ed25519_public_key_x509, - encode_x25519_public_key_x509, verify_signature, + decode_ed25519_public_key_x509, decode_x25519_public_key_x509, + encode_ed25519_public_key_x509, encode_x25519_public_key_x509, verify_signature, }; pub use crate::error::RadrootsSimplexSmpCryptoError; pub use crate::message::{