lib

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

commit 2e5af48626716b7e4a90bf5cd94be1dbc9b5f90d
parent b455d786a6cc9c5c7895f8b05f50f2dcb438863c
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 06:35:40 +0000

simplex: encode short invitation link data

Diffstat:
Mcrates/simplex_agent_proto/src/lib.rs | 4+++-
Mcrates/simplex_agent_proto/src/short_link.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/simplex_agent_runtime/src/runtime.rs | 23++++++++++++++++-------
3 files changed, 217 insertions(+), 8 deletions(-)

diff --git a/crates/simplex_agent_proto/src/lib.rs b/crates/simplex_agent_proto/src/lib.rs @@ -30,7 +30,9 @@ pub mod prelude { 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, + RadrootsSimplexAgentShortInvitationFixedData, RadrootsSimplexAgentShortInvitationLink, + RadrootsSimplexAgentShortLinkScheme, decode_short_invitation_fixed_data, + encode_short_invitation_fixed_data, encode_short_invitation_user_data, parse_short_invitation_link, }; pub use radroots_simplex_smp_crypto::prelude::{ diff --git a/crates/simplex_agent_proto/src/short_link.rs b/crates/simplex_agent_proto/src/short_link.rs @@ -1,4 +1,6 @@ +use crate::codec::{decode_connection_link, encode_connection_link}; use crate::error::{RadrootsSimplexAgentProtoError, RadrootsSimplexAgentUnsupportedLinkKind}; +use crate::model::RadrootsSimplexAgentConnectionLink; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; @@ -10,6 +12,7 @@ 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; +const RADROOTS_SIMPLEX_AGENT_SHORT_INVITATION_FIXED_DATA_TAG: &[u8] = b"RRSIF1"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsSimplexAgentShortLinkScheme { @@ -27,6 +30,12 @@ pub struct RadrootsSimplexAgentShortInvitationLink { pub link_key: Vec<u8>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexAgentShortInvitationFixedData { + pub root_public_signature_key: Vec<u8>, + pub invitation: RadrootsSimplexAgentConnectionLink, +} + impl RadrootsSimplexAgentShortInvitationLink { pub fn render(&self) -> Result<String, RadrootsSimplexAgentProtoError> { validate_field_length( @@ -87,6 +96,38 @@ impl RadrootsSimplexAgentShortInvitationLink { } } +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 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)?; + Ok(buffer) +} + +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()?; + Ok(RadrootsSimplexAgentShortInvitationFixedData { + root_public_signature_key, + invitation, + }) +} + +pub fn encode_short_invitation_user_data( + invitation: &RadrootsSimplexAgentConnectionLink, +) -> Vec<u8> { + invitation.connection_id.clone() +} + impl fmt::Display for RadrootsSimplexAgentShortInvitationLink { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.render().map_err(|_| fmt::Error)?.fmt(f) @@ -338,6 +379,104 @@ fn duplicate_param(key: &str) -> RadrootsSimplexAgentProtoError { } } +fn push_short_bytes( + buffer: &mut Vec<u8>, + value: &[u8], +) -> Result<(), RadrootsSimplexAgentProtoError> { + if value.len() > u8::MAX as usize { + return Err(RadrootsSimplexAgentProtoError::InvalidShortFieldLength( + value.len(), + )); + } + buffer.push(value.len() as u8); + buffer.extend_from_slice(value); + Ok(()) +} + +fn push_large_bytes( + buffer: &mut Vec<u8>, + value: &[u8], +) -> Result<(), RadrootsSimplexAgentProtoError> { + if value.len() > u16::MAX as usize { + return Err(RadrootsSimplexAgentProtoError::InvalidLargeFieldLength( + value.len(), + )); + } + buffer.extend_from_slice(&(value.len() as u16).to_be_bytes()); + buffer.extend_from_slice(value); + Ok(()) +} + +struct ShortLinkDataCursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> ShortLinkDataCursor<'a> { + const fn new(bytes: &'a [u8]) -> Self { + 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 { + return Err(RadrootsSimplexAgentProtoError::InvalidTag( + String::from_utf8_lossy(next).into_owned(), + )); + } + self.offset += tag.len(); + Ok(()) + } + + fn read_short_bytes(&mut self) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + let len = self.read_byte()? as usize; + self.read_exact(len) + } + + fn read_large_bytes(&mut self) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + if self.remaining().len() < 2 { + return Err(RadrootsSimplexAgentProtoError::UnexpectedEof); + } + let len = + u16::from_be_bytes([self.bytes[self.offset], self.bytes[self.offset + 1]]) as usize; + self.offset += 2; + self.read_exact(len) + } + + fn read_byte(&mut self) -> Result<u8, RadrootsSimplexAgentProtoError> { + if self.offset >= self.bytes.len() { + return Err(RadrootsSimplexAgentProtoError::UnexpectedEof); + } + let value = self.bytes[self.offset]; + self.offset += 1; + Ok(value) + } + + fn read_exact(&mut self, len: usize) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + if self.remaining().len() < len { + return Err(RadrootsSimplexAgentProtoError::UnexpectedEof); + } + let value = self.remaining()[..len].to_vec(); + self.offset += len; + Ok(value) + } + + 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)] mod tests { use super::*; @@ -353,6 +492,36 @@ mod tests { } } + fn sample_connection_link() -> RadrootsSimplexAgentConnectionLink { + let key_1 = radroots_simplex_smp_crypto::prelude::official_x448_keypair_from_seed( + b"rr-synth-short-link-x3dh-1", + ); + let key_2 = radroots_simplex_smp_crypto::prelude::official_x448_keypair_from_seed( + b"rr-synth-short-link-x3dh-2", + ); + RadrootsSimplexAgentConnectionLink { + invitation_queue: + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueUri::parse( + "smp://c2VydmVyLWlk@relay.example/c2VuZGVy#/?v=4&dh=cmVjZWl2ZXI&q=m", + ) + .expect("queue"), + connection_id: b"conn-synth-short-link".to_vec(), + e2e_ratchet_params: + radroots_simplex_smp_crypto::prelude::RadrootsSimplexOfficialX3dhParams { + version_range: + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpVersionRange::new( + 1, 2, + ) + .expect("version range"), + key_1: key_1.public_key, + key_2: key_2.public_key, + pq_public_key: None, + pq_ciphertext: None, + }, + contact_address: false, + } + } + #[test] fn renders_and_parses_simplex_invitation_short_link() { let link = sample_link(); @@ -473,4 +642,33 @@ mod tests { RadrootsSimplexAgentProtoError::InvalidLinkParameter { key, .. } if key == "z" )); } + + #[test] + fn encodes_and_decodes_short_invitation_fixed_data() { + let invitation = sample_connection_link(); + let root_public_key = vec![42_u8; 32]; + let encoded = + encode_short_invitation_fixed_data(&root_public_key, &invitation).expect("encoded"); + let decoded = decode_short_invitation_fixed_data(&encoded).expect("decoded"); + + assert_eq!(decoded.root_public_signature_key, root_public_key); + assert_eq!(decoded.invitation, invitation); + assert_eq!( + encode_short_invitation_user_data(&decoded.invitation), + 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); + + assert!(matches!( + decode_short_invitation_fixed_data(&encoded), + Err(RadrootsSimplexAgentProtoError::TrailingBytes) + )); + } } diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -15,7 +15,8 @@ use radroots_simplex_agent_proto::prelude::{ RadrootsSimplexAgentMessageHeader, RadrootsSimplexAgentMessageReceipt, RadrootsSimplexAgentQueueAddress, RadrootsSimplexAgentQueueDescriptor, RadrootsSimplexAgentShortLinkScheme, decode_decrypted_message, decode_envelope, - encode_connection_link, encode_decrypted_message, encode_envelope, + encode_decrypted_message, encode_envelope, encode_short_invitation_fixed_data, + encode_short_invitation_user_data, }; use radroots_simplex_agent_store::prelude::{ RadrootsSimplexAgentOutboundMessage, RadrootsSimplexAgentPendingCommand, @@ -2079,8 +2080,8 @@ fn prepare_short_invitation_link_data( ) -> Result<SimplexPreparedShortInvitationLinkData, RadrootsSimplexAgentRuntimeError> { let root_keypair = RadrootsSimplexSmpEd25519Keypair::generate() .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; - let fixed_data = encode_connection_link(invitation)?; - let user_data = invitation.connection_id.clone(); + let fixed_data = encode_short_invitation_fixed_data(&root_keypair.public_key, 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) @@ -2635,10 +2636,18 @@ mod tests { }, ) .unwrap(); - let decoded = - radroots_simplex_agent_proto::prelude::decode_connection_link(&verified.fixed_data) - .unwrap(); - assert_eq!(decoded.connection_id, created.as_bytes().to_vec()); + let decoded = radroots_simplex_agent_proto::prelude::decode_short_invitation_fixed_data( + &verified.fixed_data, + ) + .unwrap(); + assert_eq!( + 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_eq!( runtime.store.connection(&joined).unwrap().status,