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:
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,