lib

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

commit 91448e424f8a78d569c671261e2dcc6101b5f86d
parent 5d473c5a7419a572f165656ac355f9970030b068
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 00:59:37 +0000

simplex: add SMP transport and crypto foundations

- add radroots-simplex-smp-transport with fixed-block framing and tls handshake policy
- add radroots-simplex-smp-crypto with queue authorization helpers and ratchet state
- register the new crates in the rr-rs workspace and refresh Cargo.lock
- cover framing, handshake, auth, and pq transition behavior with slice-local tests

Diffstat:
MCargo.lock | 15+++++++++++++++
MCargo.toml | 4++++
Acrates/simplex-smp-crypto/Cargo.toml | 22++++++++++++++++++++++
Acrates/simplex-smp-crypto/src/auth.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-smp-crypto/src/error.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-smp-crypto/src/lib.rs | 19+++++++++++++++++++
Acrates/simplex-smp-crypto/src/ratchet.rs | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-smp-transport/Cargo.toml | 21+++++++++++++++++++++
Acrates/simplex-smp-transport/src/error.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-smp-transport/src/frame.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-smp-transport/src/handshake.rs | 393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex-smp-transport/src/lib.rs | 24++++++++++++++++++++++++
12 files changed, 1434 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2426,6 +2426,14 @@ dependencies = [ ] [[package]] +name = "radroots-simplex-smp-crypto" +version = "0.1.0-alpha.1" +dependencies = [ + "radroots-simplex-smp-proto", + "sha2", +] + +[[package]] name = "radroots-simplex-smp-proto" version = "0.1.0-alpha.1" dependencies = [ @@ -2433,6 +2441,13 @@ dependencies = [ ] [[package]] +name = "radroots-simplex-smp-transport" +version = "0.1.0-alpha.1" +dependencies = [ + "radroots-simplex-smp-proto", +] + +[[package]] name = "radroots-sql-core" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -18,7 +18,9 @@ members = [ "crates/nostr-runtime", "crates/runtime", "crates/simplex-chat-proto", + "crates/simplex-smp-crypto", "crates/simplex-smp-proto", + "crates/simplex-smp-transport", "crates/sql-wasm-bridge", "crates/sql-wasm-core", "crates/sql-core", @@ -61,7 +63,9 @@ radroots-net = { path = "crates/net", version = "0.1.0-alpha.1", default-feature radroots-net-core = { path = "crates/net-core", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-runtime = { path = "crates/nostr-runtime", version = "0.1.0-alpha.1", default-features = false } radroots-simplex-chat-proto = { path = "crates/simplex-chat-proto", version = "0.1.0-alpha.1", default-features = false } +radroots-simplex-smp-crypto = { path = "crates/simplex-smp-crypto", version = "0.1.0-alpha.1", default-features = false } radroots-simplex-smp-proto = { path = "crates/simplex-smp-proto", version = "0.1.0-alpha.1", default-features = false } +radroots-simplex-smp-transport = { path = "crates/simplex-smp-transport", version = "0.1.0-alpha.1", default-features = false } radroots-sql-wasm-bridge = { path = "crates/sql-wasm-bridge", version = "0.1.0-alpha.1" } radroots-sql-wasm-core = { path = "crates/sql-wasm-core", version = "0.1.0-alpha.1", default-features = false } radroots-sql-core = { path = "crates/sql-core", version = "0.1.0-alpha.1" } diff --git a/crates/simplex-smp-crypto/Cargo.toml b/crates/simplex-smp-crypto/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "radroots-simplex-smp-crypto" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "simplex messaging queue authorization and ratchet state for the radroots sdk" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-simplex-smp-crypto" +readme.workspace = true + +[features] +default = ["std"] +std = ["radroots-simplex-smp-proto/std", "sha2/std"] + +[dependencies] +radroots-simplex-smp-proto = { workspace = true, default-features = false } +sha2 = { workspace = true, default-features = false } diff --git a/crates/simplex-smp-crypto/src/auth.rs b/crates/simplex-smp-crypto/src/auth.rs @@ -0,0 +1,172 @@ +use crate::error::RadrootsSimplexSmpCryptoError; +use alloc::vec::Vec; +use radroots_simplex_smp_proto::prelude::{ + RadrootsSimplexSmpBrokerMessage, RadrootsSimplexSmpCommand, RadrootsSimplexSmpCorrelationId, +}; +use sha2::{Digest, Sha512}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpQueueAuthorizationScope { + pub session_identifier: Vec<u8>, + pub correlation_id: RadrootsSimplexSmpCorrelationId, + pub entity_id: Vec<u8>, +} + +impl RadrootsSimplexSmpQueueAuthorizationScope { + pub fn new( + session_identifier: Vec<u8>, + correlation_id: RadrootsSimplexSmpCorrelationId, + entity_id: Vec<u8>, + ) -> Result<Self, RadrootsSimplexSmpCryptoError> { + validate_short_field(&session_identifier)?; + validate_short_field(&entity_id)?; + Ok(Self { + session_identifier, + correlation_id, + entity_id, + }) + } + + pub fn encode_authorized_frame( + &self, + frame: &[u8], + ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let mut buffer = Vec::new(); + push_short_bytes(&mut buffer, &self.session_identifier)?; + push_short_bytes(&mut buffer, self.correlation_id.as_bytes())?; + push_short_bytes(&mut buffer, &self.entity_id)?; + buffer.extend_from_slice(frame); + Ok(buffer) + } + + pub fn authorized_command_body( + &self, + command: &RadrootsSimplexSmpCommand, + transport_version: u16, + ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let frame = command.encode_for_version(transport_version)?; + self.encode_authorized_frame(&frame) + } + + pub fn authorized_broker_body( + &self, + message: &RadrootsSimplexSmpBrokerMessage, + transport_version: u16, + ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let frame = message.encode_for_version(transport_version)?; + self.encode_authorized_frame(&frame) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpQueueAuthorizationMaterial { + pub authorized_body: Vec<u8>, + pub authorized_digest: [u8; 64], + pub nonce: [u8; 24], + pub queue_key_material: Vec<u8>, + pub server_session_key: Vec<u8>, +} + +impl RadrootsSimplexSmpQueueAuthorizationMaterial { + pub fn for_command( + scope: &RadrootsSimplexSmpQueueAuthorizationScope, + command: &RadrootsSimplexSmpCommand, + transport_version: u16, + queue_key_material: Vec<u8>, + server_session_key: Vec<u8>, + ) -> Result<Self, RadrootsSimplexSmpCryptoError> { + let authorized_body = scope.authorized_command_body(command, transport_version)?; + Ok(Self::new( + authorized_body, + scope.correlation_id, + queue_key_material, + server_session_key, + )) + } + + pub fn for_broker_message( + scope: &RadrootsSimplexSmpQueueAuthorizationScope, + message: &RadrootsSimplexSmpBrokerMessage, + transport_version: u16, + queue_key_material: Vec<u8>, + server_session_key: Vec<u8>, + ) -> Result<Self, RadrootsSimplexSmpCryptoError> { + let authorized_body = scope.authorized_broker_body(message, transport_version)?; + Ok(Self::new( + authorized_body, + scope.correlation_id, + queue_key_material, + server_session_key, + )) + } + + fn new( + authorized_body: Vec<u8>, + correlation_id: RadrootsSimplexSmpCorrelationId, + queue_key_material: Vec<u8>, + server_session_key: Vec<u8>, + ) -> Self { + let digest = Sha512::digest(&authorized_body); + let mut authorized_digest = [0_u8; 64]; + authorized_digest.copy_from_slice(&digest); + + Self { + authorized_body, + authorized_digest, + nonce: *correlation_id.as_bytes(), + queue_key_material, + server_session_key, + } + } +} + +fn validate_short_field(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { + if value.len() > u8::MAX as usize { + return Err(RadrootsSimplexSmpCryptoError::InvalidShortFieldLength( + value.len(), + )); + } + Ok(()) +} + +fn push_short_bytes( + buffer: &mut Vec<u8>, + value: &[u8], +) -> Result<(), RadrootsSimplexSmpCryptoError> { + validate_short_field(value)?; + buffer.push(value.len() as u8); + buffer.extend_from_slice(value); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_simplex_smp_proto::prelude::{ + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RadrootsSimplexSmpCommand, + }; + + #[test] + fn builds_authorization_material_for_command_scope() { + let scope = RadrootsSimplexSmpQueueAuthorizationScope::new( + b"tls-unique".to_vec(), + RadrootsSimplexSmpCorrelationId::new([5_u8; 24]), + b"queue-id".to_vec(), + ) + .unwrap(); + + let material = RadrootsSimplexSmpQueueAuthorizationMaterial::for_command( + &scope, + &RadrootsSimplexSmpCommand::Ping, + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + b"queue-private".to_vec(), + b"server-session".to_vec(), + ) + .unwrap(); + + assert_eq!(material.nonce, [5_u8; 24]); + assert_eq!(material.authorized_body[0], b"tls-unique".len() as u8); + assert_eq!(material.authorized_body[11], 24); + assert_eq!(material.authorized_digest.len(), 64); + } +} diff --git a/crates/simplex-smp-crypto/src/error.rs b/crates/simplex-smp-crypto/src/error.rs @@ -0,0 +1,61 @@ +use alloc::string::String; +use core::fmt; +use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexSmpCryptoError { + Proto(RadrootsSimplexSmpProtoError), + InvalidShortFieldLength(usize), + MissingRatchetKey(&'static str), + IncompletePqHeader, + RatchetMessageRegression { received: u32, current: u32 }, + InvalidSharedSecretLength(usize), + InvalidCiphertextLength(usize), + InvalidPublicKeyLength(usize), + InvalidSessionIdentifier(String), +} + +impl From<RadrootsSimplexSmpProtoError> for RadrootsSimplexSmpCryptoError { + fn from(value: RadrootsSimplexSmpProtoError) -> Self { + Self::Proto(value) + } +} + +impl fmt::Display for RadrootsSimplexSmpCryptoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Proto(error) => write!(f, "{error}"), + Self::InvalidShortFieldLength(length) => { + write!(f, "invalid SMP short field length {length}") + } + Self::MissingRatchetKey(field) => write!(f, "missing SMP ratchet key `{field}`"), + Self::IncompletePqHeader => { + write!( + f, + "SMP PQ ratchet header must include both key and ciphertext" + ) + } + Self::RatchetMessageRegression { received, current } => { + write!( + f, + "SMP ratchet message regression: received {received}, current {current}" + ) + } + Self::InvalidSharedSecretLength(length) => { + write!(f, "invalid SMP shared secret length {length}") + } + Self::InvalidCiphertextLength(length) => { + write!(f, "invalid SMP ciphertext length {length}") + } + Self::InvalidPublicKeyLength(length) => { + write!(f, "invalid SMP public key length {length}") + } + Self::InvalidSessionIdentifier(value) => { + write!(f, "invalid SMP session identifier `{value}`") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsSimplexSmpCryptoError {} diff --git a/crates/simplex-smp-crypto/src/lib.rs b/crates/simplex-smp-crypto/src/lib.rs @@ -0,0 +1,19 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +pub mod auth; +pub mod error; +pub mod ratchet; + +pub mod prelude { + pub use crate::auth::{ + RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, + }; + pub use crate::error::RadrootsSimplexSmpCryptoError; + pub use crate::ratchet::{ + RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetRole, + RadrootsSimplexSmpRatchetState, + }; +} diff --git a/crates/simplex-smp-crypto/src/ratchet.rs b/crates/simplex-smp-crypto/src/ratchet.rs @@ -0,0 +1,280 @@ +use crate::error::RadrootsSimplexSmpCryptoError; +use alloc::vec::Vec; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsSimplexSmpRatchetRole { + Initiator, + Responder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpRatchetHeader { + pub previous_sending_chain_length: u32, + pub message_number: u32, + pub dh_public_key: Vec<u8>, + pub pq_public_key: Option<Vec<u8>>, + pub pq_ciphertext: Option<Vec<u8>>, +} + +impl RadrootsSimplexSmpRatchetHeader { + pub fn validate(&self) -> Result<(), RadrootsSimplexSmpCryptoError> { + if self.dh_public_key.is_empty() { + return Err(RadrootsSimplexSmpCryptoError::MissingRatchetKey( + "dh_public_key", + )); + } + if self.pq_public_key.is_some() != self.pq_ciphertext.is_some() { + return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpRatchetState { + pub role: RadrootsSimplexSmpRatchetRole, + pub root_epoch: u64, + pub previous_sending_chain_length: u32, + pub sending_chain_length: u32, + pub receiving_chain_length: u32, + pub local_dh_public_key: Vec<u8>, + pub remote_dh_public_key: Vec<u8>, + pub current_pq_public_key: Option<Vec<u8>>, + pub remote_pq_public_key: Option<Vec<u8>>, + pub pending_outbound_pq_ciphertext: Option<Vec<u8>>, + pub pending_inbound_pq_ciphertext: Option<Vec<u8>>, + pub current_pq_shared_secret: Option<Vec<u8>>, +} + +impl RadrootsSimplexSmpRatchetState { + pub fn initiator( + local_dh_public_key: Vec<u8>, + remote_dh_public_key: Vec<u8>, + remote_pq_public_key: Option<Vec<u8>>, + ) -> Result<Self, RadrootsSimplexSmpCryptoError> { + validate_public_key(&local_dh_public_key)?; + validate_public_key(&remote_dh_public_key)?; + if let Some(key) = remote_pq_public_key.as_deref() { + validate_public_key(key)?; + } + + Ok(Self { + role: RadrootsSimplexSmpRatchetRole::Initiator, + root_epoch: 0, + previous_sending_chain_length: 0, + sending_chain_length: 0, + receiving_chain_length: 0, + local_dh_public_key, + remote_dh_public_key, + current_pq_public_key: None, + remote_pq_public_key, + pending_outbound_pq_ciphertext: None, + pending_inbound_pq_ciphertext: None, + current_pq_shared_secret: None, + }) + } + + pub fn responder( + local_dh_public_key: Vec<u8>, + remote_dh_public_key: Vec<u8>, + local_pq_public_key: Option<Vec<u8>>, + ) -> Result<Self, RadrootsSimplexSmpCryptoError> { + validate_public_key(&local_dh_public_key)?; + validate_public_key(&remote_dh_public_key)?; + if let Some(key) = local_pq_public_key.as_deref() { + validate_public_key(key)?; + } + + Ok(Self { + role: RadrootsSimplexSmpRatchetRole::Responder, + root_epoch: 0, + previous_sending_chain_length: 0, + sending_chain_length: 0, + receiving_chain_length: 0, + local_dh_public_key, + remote_dh_public_key, + current_pq_public_key: local_pq_public_key, + remote_pq_public_key: None, + pending_outbound_pq_ciphertext: None, + pending_inbound_pq_ciphertext: None, + current_pq_shared_secret: None, + }) + } + + pub fn stage_outbound_pq_step( + &mut self, + pq_public_key: Vec<u8>, + pq_ciphertext: Vec<u8>, + shared_secret: Vec<u8>, + ) -> Result<(), RadrootsSimplexSmpCryptoError> { + validate_public_key(&pq_public_key)?; + if pq_ciphertext.is_empty() { + return Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(0)); + } + if shared_secret.is_empty() { + return Err(RadrootsSimplexSmpCryptoError::InvalidSharedSecretLength(0)); + } + + self.current_pq_public_key = Some(pq_public_key); + self.pending_outbound_pq_ciphertext = Some(pq_ciphertext); + self.current_pq_shared_secret = Some(shared_secret); + self.root_epoch = self.root_epoch.saturating_add(1); + Ok(()) + } + + pub fn next_outbound_header( + &mut self, + ) -> Result<RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpCryptoError> { + validate_public_key(&self.local_dh_public_key)?; + let header = RadrootsSimplexSmpRatchetHeader { + previous_sending_chain_length: self.previous_sending_chain_length, + message_number: self.sending_chain_length, + dh_public_key: self.local_dh_public_key.clone(), + pq_public_key: self.current_pq_public_key.clone(), + pq_ciphertext: self.pending_outbound_pq_ciphertext.clone(), + }; + header.validate()?; + self.sending_chain_length = self.sending_chain_length.saturating_add(1); + Ok(header) + } + + pub fn apply_inbound_header( + &mut self, + header: &RadrootsSimplexSmpRatchetHeader, + next_local_dh_public_key: Option<Vec<u8>>, + ) -> Result<bool, RadrootsSimplexSmpCryptoError> { + header.validate()?; + let dh_advanced = header.dh_public_key != self.remote_dh_public_key; + + if dh_advanced { + self.previous_sending_chain_length = self.sending_chain_length; + self.sending_chain_length = 0; + self.remote_dh_public_key = header.dh_public_key.clone(); + if let Some(next_local_key) = next_local_dh_public_key { + validate_public_key(&next_local_key)?; + self.local_dh_public_key = next_local_key; + } + self.root_epoch = self.root_epoch.saturating_add(1); + } else if header.message_number < self.receiving_chain_length { + return Err(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: header.message_number, + current: self.receiving_chain_length, + }); + } + + self.receiving_chain_length = header.message_number.saturating_add(1); + if let Some(public_key) = header.pq_public_key.as_ref() { + self.remote_pq_public_key = Some(public_key.clone()); + } + if let Some(ciphertext) = header.pq_ciphertext.as_ref() { + self.pending_inbound_pq_ciphertext = Some(ciphertext.clone()); + } + + Ok(dh_advanced) + } + + pub fn complete_inbound_pq_step( + &mut self, + shared_secret: Vec<u8>, + ) -> Result<(), RadrootsSimplexSmpCryptoError> { + if shared_secret.is_empty() { + return Err(RadrootsSimplexSmpCryptoError::InvalidSharedSecretLength(0)); + } + self.current_pq_shared_secret = Some(shared_secret); + self.pending_inbound_pq_ciphertext = None; + self.root_epoch = self.root_epoch.saturating_add(1); + Ok(()) + } +} + +fn validate_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { + if value.is_empty() { + return Err(RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(0)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stages_outbound_pq_state_and_emits_header() { + let mut state = RadrootsSimplexSmpRatchetState::responder( + b"bob-dh".to_vec(), + b"alice-dh".to_vec(), + Some(b"bob-pq".to_vec()), + ) + .unwrap(); + state + .stage_outbound_pq_step( + b"bob-pq-next".to_vec(), + b"ciphertext".to_vec(), + b"shared-secret".to_vec(), + ) + .unwrap(); + + let header = state.next_outbound_header().unwrap(); + assert_eq!(header.message_number, 0); + assert_eq!(header.pq_public_key, Some(b"bob-pq-next".to_vec())); + assert_eq!(header.pq_ciphertext, Some(b"ciphertext".to_vec())); + assert_eq!(state.sending_chain_length, 1); + } + + #[test] + fn applies_inbound_dh_and_pq_transition() { + let mut state = RadrootsSimplexSmpRatchetState::initiator( + b"alice-dh".to_vec(), + b"bob-dh".to_vec(), + Some(b"bob-pq".to_vec()), + ) + .unwrap(); + state.sending_chain_length = 4; + + let advanced = state + .apply_inbound_header( + &RadrootsSimplexSmpRatchetHeader { + previous_sending_chain_length: 2, + message_number: 0, + dh_public_key: b"bob-dh-next".to_vec(), + pq_public_key: Some(b"bob-pq-next".to_vec()), + pq_ciphertext: Some(b"ciphertext".to_vec()), + }, + Some(b"alice-dh-next".to_vec()), + ) + .unwrap(); + + assert!(advanced); + assert_eq!(state.previous_sending_chain_length, 4); + assert_eq!(state.sending_chain_length, 0); + assert_eq!(state.receiving_chain_length, 1); + assert_eq!(state.remote_pq_public_key, Some(b"bob-pq-next".to_vec())); + assert_eq!( + state.pending_inbound_pq_ciphertext, + Some(b"ciphertext".to_vec()) + ); + + state + .complete_inbound_pq_step(b"shared-secret".to_vec()) + .unwrap(); + assert_eq!( + state.current_pq_shared_secret, + Some(b"shared-secret".to_vec()) + ); + assert_eq!(state.pending_inbound_pq_ciphertext, None); + } + + #[test] + fn rejects_incomplete_pq_header() { + let header = RadrootsSimplexSmpRatchetHeader { + previous_sending_chain_length: 0, + message_number: 0, + dh_public_key: b"dh".to_vec(), + pq_public_key: Some(b"pq".to_vec()), + pq_ciphertext: None, + }; + + let error = header.validate().unwrap_err(); + assert_eq!(error, RadrootsSimplexSmpCryptoError::IncompletePqHeader); + } +} diff --git a/crates/simplex-smp-transport/Cargo.toml b/crates/simplex-smp-transport/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "radroots-simplex-smp-transport" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "simplex messaging transport framing and handshake policy for the radroots sdk" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-simplex-smp-transport" +readme.workspace = true + +[features] +default = ["std"] +std = ["radroots-simplex-smp-proto/std"] + +[dependencies] +radroots-simplex-smp-proto = { workspace = true, default-features = false } diff --git a/crates/simplex-smp-transport/src/error.rs b/crates/simplex-smp-transport/src/error.rs @@ -0,0 +1,107 @@ +use alloc::string::String; +use core::fmt; +use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexSmpTransportError { + Proto(RadrootsSimplexSmpProtoError), + InvalidPaddedBlockLength { expected: usize, actual: usize }, + TransportPayloadTooLarge(usize), + EmptyTransportBlock, + TransmissionCountOverflow(usize), + TransmissionTooLarge(usize), + InvalidPadding { index: usize, value: u8 }, + UnexpectedTransmissionCount { declared: u8, actual: usize }, + TrailingTransportBytes(usize), + MissingHandshakeField(&'static str), + InvalidSessionIdentifierLength(usize), + MissingServerProof, + InvalidCertificateChainLength(usize), + UnsupportedAlpn(String), + SessionResumptionNotAllowed, + ServerIdentityMismatch { expected: String, actual: String }, + MissingChannelBinding, + SessionBindingMismatch, + NoMutualTransportVersion { offered: String, supported: String }, +} + +impl From<RadrootsSimplexSmpProtoError> for RadrootsSimplexSmpTransportError { + fn from(value: RadrootsSimplexSmpProtoError) -> Self { + Self::Proto(value) + } +} + +impl fmt::Display for RadrootsSimplexSmpTransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Proto(error) => write!(f, "{error}"), + Self::InvalidPaddedBlockLength { expected, actual } => { + write!( + f, + "invalid SMP padded block length {actual}, expected {expected}" + ) + } + Self::TransportPayloadTooLarge(length) => { + write!(f, "SMP transport payload too large: {length} bytes") + } + Self::EmptyTransportBlock => write!(f, "empty SMP transport block"), + Self::TransmissionCountOverflow(count) => { + write!(f, "too many SMP transmissions for one block: {count}") + } + Self::TransmissionTooLarge(length) => { + write!(f, "SMP transmission too large for word16 framing: {length}") + } + Self::InvalidPadding { index, value } => { + write!( + f, + "invalid SMP transport padding byte {value:#04x} at index {index}" + ) + } + Self::UnexpectedTransmissionCount { declared, actual } => { + write!( + f, + "declared {declared} SMP transmissions but decoded {actual}" + ) + } + Self::TrailingTransportBytes(length) => { + write!(f, "trailing SMP transport bytes after decode: {length}") + } + Self::MissingHandshakeField(field) => { + write!(f, "missing required SMP handshake field `{field}`") + } + Self::InvalidSessionIdentifierLength(length) => { + write!(f, "invalid SMP session identifier length {length}") + } + Self::MissingServerProof => write!(f, "missing SMP server proof in handshake"), + Self::InvalidCertificateChainLength(length) => { + write!(f, "invalid SMP certificate chain length {length}") + } + Self::UnsupportedAlpn(alpn) => write!(f, "unsupported SMP ALPN `{alpn}`"), + Self::SessionResumptionNotAllowed => { + write!(f, "SMP TLS session resumption is not allowed") + } + Self::ServerIdentityMismatch { expected, actual } => { + write!( + f, + "SMP server identity mismatch: expected `{expected}`, got `{actual}`" + ) + } + Self::MissingChannelBinding => write!(f, "missing SMP tls-unique channel binding"), + Self::SessionBindingMismatch => { + write!( + f, + "SMP session identifier does not match tls-unique binding" + ) + } + Self::NoMutualTransportVersion { offered, supported } => { + write!( + f, + "no mutual SMP transport version between `{offered}` and `{supported}`" + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsSimplexSmpTransportError {} diff --git a/crates/simplex-smp-transport/src/frame.rs b/crates/simplex-smp-transport/src/frame.rs @@ -0,0 +1,316 @@ +use crate::error::RadrootsSimplexSmpTransportError; +use alloc::vec::Vec; +use radroots_simplex_smp_proto::prelude::{ + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RadrootsSimplexSmpBrokerTransmission, + RadrootsSimplexSmpCommandTransmission, +}; + +pub const RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE: usize = 16_384; +pub const RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE: u8 = b'#'; +const PADDED_PAYLOAD_PREFIX_LEN: usize = 2; +const MAX_TRANSPORT_PAYLOAD_LEN: usize = + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE - PADDED_PAYLOAD_PREFIX_LEN; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpTransportBlock { + pub transmissions: Vec<Vec<u8>>, +} + +impl RadrootsSimplexSmpTransportBlock { + pub fn new(transmissions: Vec<Vec<u8>>) -> Result<Self, RadrootsSimplexSmpTransportError> { + if transmissions.is_empty() { + return Err(RadrootsSimplexSmpTransportError::EmptyTransportBlock); + } + if transmissions.len() > u8::MAX as usize { + return Err(RadrootsSimplexSmpTransportError::TransmissionCountOverflow( + transmissions.len(), + )); + } + + let mut payload_len = 1_usize; + for transmission in &transmissions { + if transmission.len() > u16::MAX as usize { + return Err(RadrootsSimplexSmpTransportError::TransmissionTooLarge( + transmission.len(), + )); + } + payload_len = payload_len.checked_add(2 + transmission.len()).ok_or( + RadrootsSimplexSmpTransportError::TransportPayloadTooLarge(usize::MAX), + )?; + } + if payload_len > MAX_TRANSPORT_PAYLOAD_LEN { + return Err(RadrootsSimplexSmpTransportError::TransportPayloadTooLarge( + payload_len, + )); + } + + Ok(Self { transmissions }) + } + + pub fn encode(&self) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let payload = encode_transport_payload(&self.transmissions)?; + encode_padded_bytes( + &payload, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, + RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + ) + } + + pub fn decode(bytes: &[u8]) -> Result<Self, RadrootsSimplexSmpTransportError> { + let payload = decode_padded_bytes( + bytes, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, + RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + )?; + let transmissions = decode_transport_payload(&payload)?; + Self::new(transmissions) + } + + pub fn from_command_transmissions( + transmissions: &[RadrootsSimplexSmpCommandTransmission], + transport_version: u16, + ) -> Result<Self, RadrootsSimplexSmpTransportError> { + let encoded = transmissions + .iter() + .map(|transmission| transmission.encode_for_version(transport_version)) + .collect::<Result<Vec<_>, _>>()?; + Self::new(encoded) + } + + pub fn from_current_command_transmissions( + transmissions: &[RadrootsSimplexSmpCommandTransmission], + ) -> Result<Self, RadrootsSimplexSmpTransportError> { + Self::from_command_transmissions( + transmissions, + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + ) + } + + pub fn decode_command_transmissions( + &self, + transport_version: u16, + ) -> Result<Vec<RadrootsSimplexSmpCommandTransmission>, RadrootsSimplexSmpTransportError> { + self.transmissions + .iter() + .map(|transmission| { + RadrootsSimplexSmpCommandTransmission::decode_for_version( + transport_version, + transmission, + ) + .map_err(Into::into) + }) + .collect() + } + + pub fn from_broker_transmissions( + transmissions: &[RadrootsSimplexSmpBrokerTransmission], + transport_version: u16, + ) -> Result<Self, RadrootsSimplexSmpTransportError> { + let encoded = transmissions + .iter() + .map(|transmission| transmission.encode_for_version(transport_version)) + .collect::<Result<Vec<_>, _>>()?; + Self::new(encoded) + } + + pub fn decode_broker_transmissions( + &self, + transport_version: u16, + ) -> Result<Vec<RadrootsSimplexSmpBrokerTransmission>, RadrootsSimplexSmpTransportError> { + self.transmissions + .iter() + .map(|transmission| { + RadrootsSimplexSmpBrokerTransmission::decode_for_version( + transport_version, + transmission, + ) + .map_err(Into::into) + }) + .collect() + } +} + +pub fn encode_padded_bytes( + payload: &[u8], + padded_len: usize, + pad_byte: u8, +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let max_payload_len = padded_len.checked_sub(PADDED_PAYLOAD_PREFIX_LEN).ok_or( + RadrootsSimplexSmpTransportError::TransportPayloadTooLarge(payload.len()), + )?; + if payload.len() > max_payload_len || payload.len() > u16::MAX as usize { + return Err(RadrootsSimplexSmpTransportError::TransportPayloadTooLarge( + payload.len(), + )); + } + + let mut buffer = Vec::with_capacity(padded_len); + buffer.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + buffer.extend_from_slice(payload); + buffer.resize(padded_len, pad_byte); + Ok(buffer) +} + +pub fn decode_padded_bytes( + bytes: &[u8], + padded_len: usize, + pad_byte: u8, +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + if bytes.len() != padded_len { + return Err(RadrootsSimplexSmpTransportError::InvalidPaddedBlockLength { + expected: padded_len, + actual: bytes.len(), + }); + } + let Some(length_bytes) = bytes.get(..2) else { + return Err(RadrootsSimplexSmpTransportError::InvalidPaddedBlockLength { + expected: padded_len, + actual: bytes.len(), + }); + }; + let payload_len = u16::from_be_bytes([length_bytes[0], length_bytes[1]]) as usize; + let max_payload_len = padded_len - PADDED_PAYLOAD_PREFIX_LEN; + if payload_len > max_payload_len { + return Err(RadrootsSimplexSmpTransportError::TransportPayloadTooLarge( + payload_len, + )); + } + let end = PADDED_PAYLOAD_PREFIX_LEN + payload_len; + for (offset, byte) in bytes[end..].iter().enumerate() { + if *byte != pad_byte { + return Err(RadrootsSimplexSmpTransportError::InvalidPadding { + index: end + offset, + value: *byte, + }); + } + } + Ok(bytes[PADDED_PAYLOAD_PREFIX_LEN..end].to_vec()) +} + +fn encode_transport_payload( + transmissions: &[Vec<u8>], +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + if transmissions.is_empty() { + return Err(RadrootsSimplexSmpTransportError::EmptyTransportBlock); + } + if transmissions.len() > u8::MAX as usize { + return Err(RadrootsSimplexSmpTransportError::TransmissionCountOverflow( + transmissions.len(), + )); + } + + let mut buffer = Vec::new(); + buffer.push(transmissions.len() as u8); + for transmission in transmissions { + if transmission.len() > u16::MAX as usize { + return Err(RadrootsSimplexSmpTransportError::TransmissionTooLarge( + transmission.len(), + )); + } + buffer.extend_from_slice(&(transmission.len() as u16).to_be_bytes()); + buffer.extend_from_slice(transmission); + } + if buffer.len() > MAX_TRANSPORT_PAYLOAD_LEN { + return Err(RadrootsSimplexSmpTransportError::TransportPayloadTooLarge( + buffer.len(), + )); + } + Ok(buffer) +} + +fn decode_transport_payload( + payload: &[u8], +) -> Result<Vec<Vec<u8>>, RadrootsSimplexSmpTransportError> { + let Some((&declared_count, mut remainder)) = payload.split_first() else { + return Err(RadrootsSimplexSmpTransportError::EmptyTransportBlock); + }; + if declared_count == 0 { + return Err(RadrootsSimplexSmpTransportError::EmptyTransportBlock); + } + + let mut transmissions = Vec::with_capacity(declared_count as usize); + for _ in 0..declared_count { + let Some(length_bytes) = remainder.get(..2) else { + return Err( + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError::UnexpectedEof + .into(), + ); + }; + let transmission_len = u16::from_be_bytes([length_bytes[0], length_bytes[1]]) as usize; + let Some(transmission) = remainder.get(2..2 + transmission_len) else { + return Err( + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError::UnexpectedEof + .into(), + ); + }; + transmissions.push(transmission.to_vec()); + remainder = &remainder[2 + transmission_len..]; + } + + if !remainder.is_empty() { + return Err(RadrootsSimplexSmpTransportError::TrailingTransportBytes( + remainder.len(), + )); + } + if transmissions.len() != declared_count as usize { + return Err( + RadrootsSimplexSmpTransportError::UnexpectedTransmissionCount { + declared: declared_count, + actual: transmissions.len(), + }, + ); + } + Ok(transmissions) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_simplex_smp_proto::prelude::{ + RadrootsSimplexSmpCommand, RadrootsSimplexSmpCommandTransmission, + RadrootsSimplexSmpCorrelationId, + }; + + #[test] + fn roundtrips_command_transmissions_through_transport_block() { + let transmissions = vec![ + RadrootsSimplexSmpCommandTransmission { + authorization: b"sig-a".to_vec(), + correlation_id: Some(RadrootsSimplexSmpCorrelationId::new([7_u8; 24])), + entity_id: b"queue-a".to_vec(), + command: RadrootsSimplexSmpCommand::Ping, + }, + RadrootsSimplexSmpCommandTransmission { + authorization: b"sig-b".to_vec(), + correlation_id: Some(RadrootsSimplexSmpCorrelationId::new([9_u8; 24])), + entity_id: b"queue-b".to_vec(), + command: RadrootsSimplexSmpCommand::Get, + }, + ]; + + let block = + RadrootsSimplexSmpTransportBlock::from_current_command_transmissions(&transmissions) + .unwrap(); + let encoded = block.encode().unwrap(); + assert_eq!(encoded.len(), RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE); + + let decoded = RadrootsSimplexSmpTransportBlock::decode(&encoded).unwrap(); + let roundtrip = decoded + .decode_command_transmissions(RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION) + .unwrap(); + assert_eq!(roundtrip, transmissions); + } + + #[test] + fn rejects_invalid_padding() { + let block = RadrootsSimplexSmpTransportBlock::new(vec![b"PING".to_vec()]).unwrap(); + let mut encoded = block.encode().unwrap(); + encoded[RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE - 1] = b'!'; + + let error = RadrootsSimplexSmpTransportBlock::decode(&encoded).unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexSmpTransportError::InvalidPadding { .. } + )); + } +} diff --git a/crates/simplex-smp-transport/src/handshake.rs b/crates/simplex-smp-transport/src/handshake.rs @@ -0,0 +1,393 @@ +use crate::error::RadrootsSimplexSmpTransportError; +use crate::frame::{ + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + decode_padded_bytes, encode_padded_bytes, +}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use radroots_simplex_smp_proto::prelude::{ + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION, + RadrootsSimplexSmpVersionRange, +}; + +pub const RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1: &str = "smp/1"; +pub const RADROOTS_SIMPLEX_SMP_TLS_V1_3_CIPHER_SUITE: &str = "TLS_CHACHA20_POLY1305_SHA256"; +pub const RADROOTS_SIMPLEX_SMP_TLS_SIGNATURE_ALGORITHM: &str = "ed25519"; +pub const RADROOTS_SIMPLEX_SMP_TLS_KEY_EXCHANGE_GROUP: &str = "x25519"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpTransportServerProof { + pub certificate_payload: Vec<u8>, + pub signed_server_key: Vec<u8>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpServerHello { + pub version_range: RadrootsSimplexSmpVersionRange, + pub session_identifier: Vec<u8>, + pub server_proof: Option<RadrootsSimplexSmpTransportServerProof>, + pub ignored_part: Vec<u8>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpClientHello { + pub chosen_version: u16, + pub client_key: Option<Vec<u8>>, + pub ignored_part: Vec<u8>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpTlsPolicy { + pub expected_server_identity: String, + pub supported_versions: RadrootsSimplexSmpVersionRange, + pub require_current_alpn: bool, + pub allow_session_resumption: bool, + pub allowed_certificate_chain_lengths: [usize; 3], + pub require_tls_unique_binding: bool, + pub require_server_proof: bool, +} + +impl RadrootsSimplexSmpTlsPolicy { + pub fn modern(expected_server_identity: impl Into<String>) -> Self { + Self { + expected_server_identity: expected_server_identity.into(), + supported_versions: RadrootsSimplexSmpVersionRange::single( + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + ), + require_current_alpn: true, + allow_session_resumption: false, + allowed_certificate_chain_lengths: [2, 3, 4], + require_tls_unique_binding: true, + require_server_proof: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpTlsHandshakeEvidence { + pub confirmed_alpn: Option<String>, + pub session_resumed: bool, + pub certificate_chain_length: usize, + pub online_certificate_fingerprint: String, + pub tls_unique_channel_binding: Option<Vec<u8>>, +} + +impl RadrootsSimplexSmpServerHello { + pub fn encode(&self) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let mut payload = Vec::new(); + payload.extend_from_slice(&self.version_range.min.to_be_bytes()); + payload.extend_from_slice(&self.version_range.max.to_be_bytes()); + push_short_bytes(&mut payload, &self.session_identifier)?; + if let Some(proof) = &self.server_proof { + payload.extend_from_slice(&(proof.certificate_payload.len() as u16).to_be_bytes()); + payload.extend_from_slice(&proof.certificate_payload); + payload.extend_from_slice(&(proof.signed_server_key.len() as u16).to_be_bytes()); + payload.extend_from_slice(&proof.signed_server_key); + } + payload.extend_from_slice(&self.ignored_part); + encode_padded_bytes( + &payload, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, + RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + ) + } + + pub fn decode(bytes: &[u8]) -> Result<Self, RadrootsSimplexSmpTransportError> { + let payload = decode_padded_bytes( + bytes, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, + RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + )?; + let Some(version_bytes) = payload.get(..4) else { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "smp_version_range", + )); + }; + let min = u16::from_be_bytes([version_bytes[0], version_bytes[1]]); + let max = u16::from_be_bytes([version_bytes[2], version_bytes[3]]); + let version_range = RadrootsSimplexSmpVersionRange::new(min, max) + .map_err(RadrootsSimplexSmpTransportError::from)?; + let (session_identifier, cursor) = read_short_bytes(&payload, 4)?; + if session_identifier.len() > u8::MAX as usize { + return Err( + RadrootsSimplexSmpTransportError::InvalidSessionIdentifierLength( + session_identifier.len(), + ), + ); + } + let (server_proof, ignored_part) = parse_optional_server_proof(&payload[cursor..]); + + Ok(Self { + version_range, + session_identifier, + server_proof, + ignored_part, + }) + } +} + +impl RadrootsSimplexSmpClientHello { + pub fn encode(&self) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let mut payload = Vec::new(); + payload.extend_from_slice(&self.chosen_version.to_be_bytes()); + if let Some(client_key) = &self.client_key { + push_short_bytes(&mut payload, client_key)?; + } + payload.extend_from_slice(&self.ignored_part); + encode_padded_bytes( + &payload, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, + RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + ) + } + + pub fn decode(bytes: &[u8]) -> Result<Self, RadrootsSimplexSmpTransportError> { + let payload = decode_padded_bytes( + bytes, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, + RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + )?; + let Some(version_bytes) = payload.get(..2) else { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "chosen_version", + )); + }; + let chosen_version = u16::from_be_bytes([version_bytes[0], version_bytes[1]]); + let (client_key, ignored_part) = parse_optional_client_key(&payload[2..]); + + Ok(Self { + chosen_version, + client_key, + ignored_part, + }) + } +} + +pub fn negotiate_transport_version( + offered: RadrootsSimplexSmpVersionRange, + supported: RadrootsSimplexSmpVersionRange, + confirmed_alpn: Option<&str>, +) -> Result<u16, RadrootsSimplexSmpTransportError> { + if confirmed_alpn == Some(RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1) { + let min = offered.min.max(supported.min); + let max = offered.max.min(supported.max); + if min > max { + return Err(RadrootsSimplexSmpTransportError::NoMutualTransportVersion { + offered: offered.to_string(), + supported: supported.to_string(), + }); + } + return Ok(max); + } + + if offered.contains(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION) + && supported.contains(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION) + { + return Ok(RADROOTS_SIMPLEX_SMP_INITIAL_TRANSPORT_VERSION); + } + + Err(RadrootsSimplexSmpTransportError::NoMutualTransportVersion { + offered: offered.to_string(), + supported: supported.to_string(), + }) +} + +pub fn validate_tls_handshake( + policy: &RadrootsSimplexSmpTlsPolicy, + server_hello: &RadrootsSimplexSmpServerHello, + evidence: &RadrootsSimplexSmpTlsHandshakeEvidence, +) -> Result<u16, RadrootsSimplexSmpTransportError> { + if policy.require_current_alpn + && evidence.confirmed_alpn.as_deref() != Some(RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1) + { + return Err(RadrootsSimplexSmpTransportError::UnsupportedAlpn( + evidence.confirmed_alpn.clone().unwrap_or_default(), + )); + } + if !policy.allow_session_resumption && evidence.session_resumed { + return Err(RadrootsSimplexSmpTransportError::SessionResumptionNotAllowed); + } + if !policy + .allowed_certificate_chain_lengths + .contains(&evidence.certificate_chain_length) + { + return Err( + RadrootsSimplexSmpTransportError::InvalidCertificateChainLength( + evidence.certificate_chain_length, + ), + ); + } + if evidence.online_certificate_fingerprint != policy.expected_server_identity { + return Err(RadrootsSimplexSmpTransportError::ServerIdentityMismatch { + expected: policy.expected_server_identity.clone(), + actual: evidence.online_certificate_fingerprint.clone(), + }); + } + if policy.require_server_proof && server_hello.server_proof.is_none() { + return Err(RadrootsSimplexSmpTransportError::MissingServerProof); + } + if policy.require_tls_unique_binding { + let Some(binding) = evidence.tls_unique_channel_binding.as_ref() else { + return Err(RadrootsSimplexSmpTransportError::MissingChannelBinding); + }; + if binding.as_slice() != server_hello.session_identifier.as_slice() { + return Err(RadrootsSimplexSmpTransportError::SessionBindingMismatch); + } + } + + negotiate_transport_version( + server_hello.version_range, + policy.supported_versions, + evidence.confirmed_alpn.as_deref(), + ) +} + +fn push_short_bytes( + buffer: &mut Vec<u8>, + bytes: &[u8], +) -> Result<(), RadrootsSimplexSmpTransportError> { + if bytes.len() > u8::MAX as usize { + return Err(RadrootsSimplexSmpTransportError::InvalidSessionIdentifierLength(bytes.len())); + } + buffer.push(bytes.len() as u8); + buffer.extend_from_slice(bytes); + Ok(()) +} + +fn read_short_bytes( + payload: &[u8], + offset: usize, +) -> Result<(Vec<u8>, usize), RadrootsSimplexSmpTransportError> { + let Some(&length) = payload.get(offset) else { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "short_field", + )); + }; + let start = offset + 1; + let end = start + length as usize; + let Some(value) = payload.get(start..end) else { + return Err( + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError::UnexpectedEof.into(), + ); + }; + Ok((value.to_vec(), end)) +} + +fn parse_optional_server_proof( + remainder: &[u8], +) -> (Option<RadrootsSimplexSmpTransportServerProof>, Vec<u8>) { + if remainder.len() < 4 { + return (None, remainder.to_vec()); + } + let cert_len = u16::from_be_bytes([remainder[0], remainder[1]]) as usize; + let cert_end = 2 + cert_len; + if cert_len == 0 || cert_end + 2 > remainder.len() { + return (None, remainder.to_vec()); + } + let key_len = u16::from_be_bytes([remainder[cert_end], remainder[cert_end + 1]]) as usize; + let key_start = cert_end + 2; + let key_end = key_start + key_len; + if key_len == 0 || key_end > remainder.len() { + return (None, remainder.to_vec()); + } + ( + Some(RadrootsSimplexSmpTransportServerProof { + certificate_payload: remainder[2..cert_end].to_vec(), + signed_server_key: remainder[key_start..key_end].to_vec(), + }), + remainder[key_end..].to_vec(), + ) +} + +fn parse_optional_client_key(remainder: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) { + let Some(&length) = remainder.first() else { + return (None, Vec::new()); + }; + let end = 1 + length as usize; + if length == 0 || end > remainder.len() { + return (None, remainder.to_vec()); + } + (Some(remainder[1..end].to_vec()), remainder[end..].to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrips_server_hello_and_validates_binding() { + let hello = RadrootsSimplexSmpServerHello { + version_range: RadrootsSimplexSmpVersionRange::new(6, 17).unwrap(), + session_identifier: b"tls-unique-binding".to_vec(), + server_proof: Some(RadrootsSimplexSmpTransportServerProof { + certificate_payload: b"cert-chain".to_vec(), + signed_server_key: b"signed-key".to_vec(), + }), + ignored_part: b"ignored".to_vec(), + }; + + let decoded = RadrootsSimplexSmpServerHello::decode(&hello.encode().unwrap()).unwrap(); + assert_eq!(decoded, hello); + + let policy = RadrootsSimplexSmpTlsPolicy { + expected_server_identity: "fingerprint".to_string(), + supported_versions: RadrootsSimplexSmpVersionRange::new(6, 17).unwrap(), + require_current_alpn: false, + allow_session_resumption: false, + allowed_certificate_chain_lengths: [2, 3, 4], + require_tls_unique_binding: true, + require_server_proof: true, + }; + let version = validate_tls_handshake( + &policy, + &decoded, + &RadrootsSimplexSmpTlsHandshakeEvidence { + confirmed_alpn: Some(RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1.to_string()), + session_resumed: false, + certificate_chain_length: 3, + online_certificate_fingerprint: "fingerprint".to_string(), + tls_unique_channel_binding: Some(b"tls-unique-binding".to_vec()), + }, + ) + .unwrap(); + assert_eq!(version, 17); + } + + #[test] + fn falls_back_to_initial_transport_version_without_current_alpn() { + let version = negotiate_transport_version( + RadrootsSimplexSmpVersionRange::new(6, 17).unwrap(), + RadrootsSimplexSmpVersionRange::new(6, 17).unwrap(), + None, + ) + .unwrap(); + assert_eq!(version, 6); + } + + #[test] + fn rejects_mismatched_server_identity() { + let hello = RadrootsSimplexSmpServerHello { + version_range: RadrootsSimplexSmpVersionRange::new(6, 17).unwrap(), + session_identifier: b"bind".to_vec(), + server_proof: None, + ignored_part: Vec::new(), + }; + let policy = RadrootsSimplexSmpTlsPolicy::modern("expected"); + let error = validate_tls_handshake( + &policy, + &hello, + &RadrootsSimplexSmpTlsHandshakeEvidence { + confirmed_alpn: Some(RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1.to_string()), + session_resumed: false, + certificate_chain_length: 2, + online_certificate_fingerprint: "actual".to_string(), + tls_unique_channel_binding: Some(b"bind".to_vec()), + }, + ) + .unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexSmpTransportError::ServerIdentityMismatch { .. } + )); + } +} diff --git a/crates/simplex-smp-transport/src/lib.rs b/crates/simplex-smp-transport/src/lib.rs @@ -0,0 +1,24 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +pub mod error; +pub mod frame; +pub mod handshake; + +pub mod prelude { + pub use crate::error::RadrootsSimplexSmpTransportError; + pub use crate::frame::{ + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, + RadrootsSimplexSmpTransportBlock, decode_padded_bytes, encode_padded_bytes, + }; + pub use crate::handshake::{ + RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1, RADROOTS_SIMPLEX_SMP_TLS_KEY_EXCHANGE_GROUP, + RADROOTS_SIMPLEX_SMP_TLS_SIGNATURE_ALGORITHM, RADROOTS_SIMPLEX_SMP_TLS_V1_3_CIPHER_SUITE, + RadrootsSimplexSmpClientHello, RadrootsSimplexSmpServerHello, + RadrootsSimplexSmpTlsHandshakeEvidence, RadrootsSimplexSmpTlsPolicy, + RadrootsSimplexSmpTransportServerProof, negotiate_transport_version, + validate_tls_handshake, + }; +}