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