lib

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

commit 69f0e42ceb59bb03b18aaf7b944586b85a214d23
parent 84e28c29959f44d3d9ce2d7a7c730f626ab417a1
Author: triesap <tyson@radroots.org>
Date:   Sun, 29 Mar 2026 01:21:42 +0000

simplex: advance encrypted transport block interop

Diffstat:
MCargo.lock | 10++++++++++
MCargo.toml | 1+
Mcrates/simplex-agent-runtime/src/runtime.rs | 35++++++++++++++++++++++++++---------
Mcrates/simplex-smp-crypto/Cargo.toml | 1+
Mcrates/simplex-smp-crypto/src/auth.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/simplex-smp-crypto/src/error.rs | 8++++++++
Mcrates/simplex-smp-crypto/src/lib.rs | 8+++++---
Mcrates/simplex-smp-crypto/src/message.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/simplex-smp-proto/src/wire.rs | 38++++++++++++++++++++++++++++++++++++--
Mcrates/simplex-smp-transport/src/client.rs | 461++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/simplex-smp-transport/src/frame.rs | 21+++++++++++++++++++--
Mcrates/simplex-smp-transport/src/handshake.rs | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
12 files changed, 866 insertions(+), 54 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1193,6 +1193,15 @@ dependencies = [ ] [[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2653,6 +2662,7 @@ version = "0.1.0-alpha.1" dependencies = [ "ed25519-dalek", "getrandom 0.2.17", + "hkdf", "radroots-simplex-smp-proto", "sha2", "x25519-dalek", diff --git a/Cargo.toml b/Cargo.toml @@ -96,6 +96,7 @@ directories = { version = "6" } ed25519-dalek = { version = "2.1.1", default-features = false } futures = { version = "0.3" } getrandom = { version = "0.2", default-features = false } +hkdf = { version = "0.12", default-features = false } hex = { version = "0.4" } js-sys = { version = "0.3" } keyring = { version = "3.6.3", default-features = false, features = [ diff --git a/crates/simplex-agent-runtime/src/runtime.rs b/crates/simplex-agent-runtime/src/runtime.rs @@ -22,8 +22,9 @@ use radroots_simplex_agent_store::prelude::{ }; use radroots_simplex_smp_crypto::prelude::{ RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RadrootsSimplexSmpCommandAuthorization, - RadrootsSimplexSmpRatchetState, RadrootsSimplexSmpX25519Keypair, decrypt_padded, - derive_shared_secret, encrypt_padded, random_nonce, + RadrootsSimplexSmpRatchetState, RadrootsSimplexSmpX25519Keypair, decode_x25519_public_key_x509, + decrypt_padded, derive_shared_secret, encode_ed25519_public_key_x509, + encode_x25519_public_key_x509, encrypt_padded, random_nonce, }; use radroots_simplex_smp_proto::prelude::{ RADROOTS_SIMPLEX_SMP_CURRENT_CLIENT_VERSION, RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, @@ -782,7 +783,7 @@ impl RadrootsSimplexAgentRuntime { &self, command: &RadrootsSimplexAgentPendingCommand, ) -> Result<RadrootsSimplexSmpTransportRequest, RadrootsSimplexAgentRuntimeError> { - let (queue_address, _entity_id, smp_command) = self.command_transport_parts(command)?; + let (queue_address, entity_id, smp_command) = self.command_transport_parts(command)?; let queue = self .store .queue_record(&command.connection_id, &queue_address)?; @@ -815,7 +816,7 @@ impl RadrootsSimplexAgentRuntime { server: queue.descriptor.queue_uri.server.clone(), transport_version: RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, correlation_id: Some(correlation_id), - entity_id: queue.entity_id, + entity_id, command: smp_command, authorization, }) @@ -850,14 +851,23 @@ impl RadrootsSimplexAgentRuntime { descriptor.queue_address(), Vec::new(), RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest { - recipient_auth_public_key: auth_state.public_key, - recipient_dh_public_key: - RadrootsSimplexSmpX25519Keypair::public_key_from_private( + recipient_auth_public_key: encode_ed25519_public_key_x509( + &auth_state.public_key, + ) + .map_err(|error| { + RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()) + })?, + recipient_dh_public_key: encode_x25519_public_key_x509( + &RadrootsSimplexSmpX25519Keypair::public_key_from_private( &delivery_private_key, ) .map_err(|error| { RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()) })?, + ) + .map_err(|error| { + RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()) + })?, basic_auth: None, subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe, queue_request_data: Some( @@ -881,7 +891,12 @@ impl RadrootsSimplexAgentRuntime { RadrootsSimplexAgentPendingCommandKind::SecureQueue { queue, sender_key } => Ok(( queue.clone(), queue.sender_id.clone(), - RadrootsSimplexSmpCommand::SKey(sender_key.clone().unwrap_or_default()), + RadrootsSimplexSmpCommand::SKey( + encode_ed25519_public_key_x509(sender_key.as_deref().unwrap_or_default()) + .map_err(|error| { + RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()) + })?, + ), )), RadrootsSimplexAgentPendingCommandKind::SendEnvelope { queue, envelope, .. @@ -1095,8 +1110,10 @@ impl RadrootsSimplexAgentRuntime { "SimpleX receive queue missing delivery private key".into(), ) })?; + let server_dh_public_key = decode_x25519_public_key_x509(&ids.server_dh_public_key) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; queue.delivery_shared_secret = Some( - derive_shared_secret(&delivery_private_key, &ids.server_dh_public_key).map_err( + derive_shared_secret(&delivery_private_key, &server_dh_public_key).map_err( |error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()), )?, ); diff --git a/crates/simplex-smp-crypto/Cargo.toml b/crates/simplex-smp-crypto/Cargo.toml @@ -26,6 +26,7 @@ std = [ [dependencies] ed25519-dalek = { workspace = true, default-features = false, features = ["alloc"] } getrandom = { workspace = true, default-features = false } +hkdf = { workspace = true, default-features = false } radroots-simplex-smp-proto = { workspace = true, default-features = false } sha2 = { workspace = true, default-features = false } xsalsa20poly1305 = { workspace = true, default-features = false } diff --git a/crates/simplex-smp-crypto/src/auth.rs b/crates/simplex-smp-crypto/src/auth.rs @@ -4,6 +4,17 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpBrokerMessage, RadrootsSimplexSmpCommand, RadrootsSimplexSmpCorrelationId, }; +use x25519_dalek::PublicKey as X25519PublicKey; + +const ED25519_SPKI_DER_PREFIX: &[u8] = &[ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, +]; +const X25519_SPKI_DER_PREFIX: &[u8] = &[ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00, +]; +const X25519_SPKI_DER_PREFIX_WRAPPED: &[u8] = &[ + 0x30, 0x2c, 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00, +]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpEd25519Keypair { @@ -148,6 +159,49 @@ impl RadrootsSimplexSmpQueueAuthorizationMaterial { } } +pub fn encode_ed25519_public_key_x509( + public_key: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let _: [u8; 32] = public_key + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(public_key.len()))?; + let mut encoded = Vec::with_capacity(ED25519_SPKI_DER_PREFIX.len() + public_key.len()); + encoded.extend_from_slice(ED25519_SPKI_DER_PREFIX); + encoded.extend_from_slice(public_key); + Ok(encoded) +} + +pub fn encode_x25519_public_key_x509( + public_key: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let _: [u8; 32] = public_key + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(public_key.len()))?; + let mut encoded = Vec::with_capacity(X25519_SPKI_DER_PREFIX.len() + public_key.len()); + encoded.extend_from_slice(X25519_SPKI_DER_PREFIX); + encoded.extend_from_slice(public_key); + Ok(encoded) +} + +pub fn decode_x25519_public_key_x509( + encoded: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + if encoded.len() == 32 { + let key: [u8; 32] = encoded + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(encoded.len()))?; + return Ok(X25519PublicKey::from(key).as_bytes().to_vec()); + } + let raw = encoded + .strip_prefix(X25519_SPKI_DER_PREFIX) + .or_else(|| encoded.strip_prefix(X25519_SPKI_DER_PREFIX_WRAPPED)) + .ok_or_else(|| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(encoded.len()))?; + let key: [u8; 32] = raw + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(encoded.len()))?; + Ok(X25519PublicKey::from(key).as_bytes().to_vec()) +} + pub fn verify_signature( payload: &[u8], public_key: &[u8], @@ -242,4 +296,34 @@ mod tests { assert!(material.authorization.is_empty()); } + + #[test] + fn ed25519_public_key_x509_roundtrips_shape() { + let keypair = RadrootsSimplexSmpEd25519Keypair::generate().unwrap(); + let encoded = encode_ed25519_public_key_x509(&keypair.public_key).unwrap(); + + assert_eq!( + &encoded[..ED25519_SPKI_DER_PREFIX.len()], + ED25519_SPKI_DER_PREFIX + ); + assert_eq!( + &encoded[ED25519_SPKI_DER_PREFIX.len()..], + keypair.public_key + ); + } + + #[test] + fn x25519_public_key_x509_roundtrips_and_accepts_raw() { + let keypair = crate::message::RadrootsSimplexSmpX25519Keypair::generate().unwrap(); + let encoded = encode_x25519_public_key_x509(&keypair.public_key).unwrap(); + + assert_eq!( + decode_x25519_public_key_x509(&encoded).unwrap(), + keypair.public_key + ); + assert_eq!( + decode_x25519_public_key_x509(&keypair.public_key).unwrap(), + keypair.public_key + ); + } } diff --git a/crates/simplex-smp-crypto/src/error.rs b/crates/simplex-smp-crypto/src/error.rs @@ -19,6 +19,8 @@ pub enum RadrootsSimplexSmpCryptoError { InvalidSignatureLength(usize), SignatureVerificationFailed, InvalidSessionIdentifier(String), + InvalidKeyDerivationLength(usize), + InvalidSecretBoxChainKeyLength(usize), } impl From<RadrootsSimplexSmpProtoError> for RadrootsSimplexSmpCryptoError { @@ -80,6 +82,12 @@ impl fmt::Display for RadrootsSimplexSmpCryptoError { Self::InvalidSessionIdentifier(value) => { write!(f, "invalid SMP session identifier `{value}`") } + Self::InvalidKeyDerivationLength(length) => { + write!(f, "invalid SMP key derivation length {length}") + } + Self::InvalidSecretBoxChainKeyLength(length) => { + write!(f, "invalid SMP secretbox chain key length {length}") + } } } } diff --git a/crates/simplex-smp-crypto/src/lib.rs b/crates/simplex-smp-crypto/src/lib.rs @@ -12,13 +12,15 @@ pub mod prelude { pub use crate::auth::{ RadrootsSimplexSmpCommandAuthorization, RadrootsSimplexSmpEd25519Keypair, RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, - verify_signature, + decode_x25519_public_key_x509, encode_ed25519_public_key_x509, + encode_x25519_public_key_x509, verify_signature, }; pub use crate::error::RadrootsSimplexSmpCryptoError; pub use crate::message::{ RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH, - RadrootsSimplexSmpX25519Keypair, decrypt_no_pad, decrypt_padded, derive_shared_secret, - encrypt_no_pad, encrypt_padded, random_nonce, + RadrootsSimplexSmpSecretBoxChainKey, RadrootsSimplexSmpX25519Keypair, + advance_secretbox_chain, decrypt_no_pad, decrypt_padded, derive_shared_secret, + encrypt_no_pad, encrypt_padded, init_secretbox_chain, random_nonce, }; pub use crate::ratchet::{ RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetRole, diff --git a/crates/simplex-smp-crypto/src/message.rs b/crates/simplex-smp-crypto/src/message.rs @@ -1,7 +1,8 @@ use crate::error::RadrootsSimplexSmpCryptoError; use alloc::vec::Vec; use getrandom::getrandom; -use sha2::{Digest, Sha256}; +use hkdf::Hkdf; +use sha2::{Digest, Sha256, Sha512}; use x25519_dalek::{PublicKey, StaticSecret}; use xsalsa20poly1305::aead::{AeadInPlace, KeyInit}; use xsalsa20poly1305::{Tag, XSalsa20Poly1305}; @@ -9,6 +10,11 @@ use xsalsa20poly1305::{Tag, XSalsa20Poly1305}; pub const RADROOTS_SIMPLEX_SMP_NONCE_LENGTH: usize = 24; pub const RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH: usize = 32; const RADROOTS_SIMPLEX_SMP_AUTH_TAG_LENGTH: usize = 16; +const RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_INIT_INFO: &[u8] = b"SimpleXSbChainInit"; +const RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_INFO: &[u8] = b"SimpleXSbChain"; +const RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_KEY_LENGTH: usize = 32; +const RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_INIT_OUTPUT_LENGTH: usize = 64; +const RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_STEP_OUTPUT_LENGTH: usize = 88; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpX25519Keypair { @@ -52,6 +58,74 @@ impl RadrootsSimplexSmpX25519Keypair { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpSecretBoxChainKey { + bytes: [u8; RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_KEY_LENGTH], +} + +impl RadrootsSimplexSmpSecretBoxChainKey { + fn from_slice(value: &[u8]) -> Result<Self, RadrootsSimplexSmpCryptoError> { + let bytes: [u8; RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_KEY_LENGTH] = + value.try_into().map_err(|_| { + RadrootsSimplexSmpCryptoError::InvalidSecretBoxChainKeyLength(value.len()) + })?; + Ok(Self { bytes }) + } +} + +pub fn init_secretbox_chain( + session_identifier: &[u8], + shared_secret: &[u8], +) -> Result< + ( + RadrootsSimplexSmpSecretBoxChainKey, + RadrootsSimplexSmpSecretBoxChainKey, + ), + RadrootsSimplexSmpCryptoError, +> { + let output = hkdf_expand( + session_identifier, + shared_secret, + RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_INIT_INFO, + RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_INIT_OUTPUT_LENGTH, + )?; + let (first, second) = output.split_at(RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_KEY_LENGTH); + Ok(( + RadrootsSimplexSmpSecretBoxChainKey::from_slice(first)?, + RadrootsSimplexSmpSecretBoxChainKey::from_slice(second)?, + )) +} + +pub fn advance_secretbox_chain( + chain_key: &RadrootsSimplexSmpSecretBoxChainKey, +) -> Result< + ( + (Vec<u8>, [u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH]), + RadrootsSimplexSmpSecretBoxChainKey, + ), + RadrootsSimplexSmpCryptoError, +> { + let output = hkdf_expand( + b"", + &chain_key.bytes, + RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_INFO, + RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_STEP_OUTPUT_LENGTH, + )?; + let (next_chain_key, remainder) = + output.split_at(RADROOTS_SIMPLEX_SMP_SECRETBOX_CHAIN_KEY_LENGTH); + let (secretbox_key, nonce_bytes) = + remainder.split_at(RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH); + Ok(( + ( + secretbox_key.to_vec(), + nonce_bytes.try_into().map_err(|_| { + RadrootsSimplexSmpCryptoError::InvalidNonceLength(nonce_bytes.len()) + })?, + ), + RadrootsSimplexSmpSecretBoxChainKey::from_slice(next_chain_key)?, + )) +} + pub fn derive_shared_secret( private_key: &[u8], public_key: &[u8], @@ -169,6 +243,19 @@ fn nonce_array( .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidNonceLength(nonce.len())) } +fn hkdf_expand( + salt: &[u8], + ikm: &[u8], + info: &[u8], + output_len: usize, +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let hkdf = Hkdf::<Sha512>::new(Some(salt), ikm); + let mut output = vec![0_u8; output_len]; + hkdf.expand(info, &mut output) + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidKeyDerivationLength(output_len))?; + Ok(output) +} + #[cfg(test)] mod tests { use super::*; @@ -193,4 +280,21 @@ mod tests { let plaintext = decrypt_padded(&bob_secret, &nonce, &ciphertext).unwrap(); assert_eq!(plaintext, b"hello"); } + + #[test] + fn derives_repeatable_secretbox_chain_progression() { + let (rcv_first, snd_first) = init_secretbox_chain(b"session", b"shared-secret").unwrap(); + let (rcv_second, snd_second) = init_secretbox_chain(b"session", b"shared-secret").unwrap(); + assert_eq!(rcv_first, rcv_second); + assert_eq!(snd_first, snd_second); + + let ((send_key, send_nonce), next_send) = advance_secretbox_chain(&snd_first).unwrap(); + let ((recv_key, recv_nonce), next_recv) = advance_secretbox_chain(&rcv_first).unwrap(); + + assert_eq!(send_key.len(), RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH); + assert_eq!(recv_key.len(), RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH); + assert_ne!(send_nonce, recv_nonce); + assert_ne!(next_send, snd_first); + assert_ne!(next_recv, rcv_first); + } } diff --git a/crates/simplex-smp-proto/src/wire.rs b/crates/simplex-smp-proto/src/wire.rs @@ -781,6 +781,7 @@ impl RadrootsSimplexSmpCommandTransmission { transport_version: u16, ) -> Result<Vec<u8>, RadrootsSimplexSmpProtoError> { encode_transmission( + transport_version, &self.authorization, self.correlation_id, &self.entity_id, @@ -796,7 +797,8 @@ impl RadrootsSimplexSmpCommandTransmission { transport_version: u16, bytes: &[u8], ) -> Result<Self, RadrootsSimplexSmpProtoError> { - let (authorization, correlation_id, entity_id, frame) = decode_transmission(bytes)?; + let (authorization, correlation_id, entity_id, frame) = + decode_transmission(transport_version, bytes)?; Ok(Self { authorization, correlation_id, @@ -816,6 +818,7 @@ impl RadrootsSimplexSmpBrokerTransmission { transport_version: u16, ) -> Result<Vec<u8>, RadrootsSimplexSmpProtoError> { encode_transmission( + transport_version, &self.authorization, self.correlation_id, &self.entity_id, @@ -831,7 +834,8 @@ impl RadrootsSimplexSmpBrokerTransmission { transport_version: u16, bytes: &[u8], ) -> Result<Self, RadrootsSimplexSmpProtoError> { - let (authorization, correlation_id, entity_id, frame) = decode_transmission(bytes)?; + let (authorization, correlation_id, entity_id, frame) = + decode_transmission(transport_version, bytes)?; Ok(Self { authorization, correlation_id, @@ -1295,6 +1299,7 @@ fn is_valid_domain_transport_host(host: &str) -> bool { } fn encode_transmission( + transport_version: u16, authorization: &[u8], correlation_id: Option<RadrootsSimplexSmpCorrelationId>, entity_id: &[u8], @@ -1302,6 +1307,11 @@ fn encode_transmission( ) -> Result<Vec<u8>, RadrootsSimplexSmpProtoError> { let mut buffer = Vec::new(); push_short_bytes(&mut buffer, authorization)?; + if transport_version >= RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION + && !authorization.is_empty() + { + push_maybe_short_bytes(&mut buffer, None)?; + } push_short_bytes( &mut buffer, correlation_id @@ -1315,6 +1325,7 @@ fn encode_transmission( } fn decode_transmission( + transport_version: u16, bytes: &[u8], ) -> Result< ( @@ -1327,6 +1338,11 @@ fn decode_transmission( > { let mut cursor = Cursor::new(bytes); let authorization = cursor.read_short_bytes()?; + if transport_version >= RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION + && !authorization.is_empty() + { + let _ = cursor.read_maybe(Cursor::read_short_bytes)?; + } let correlation_id = match cursor.read_short_bytes()?.as_slice() { [] => None, value => Some(RadrootsSimplexSmpCorrelationId::from_slice(value)?), @@ -2187,6 +2203,24 @@ mod tests { } #[test] + fn current_authenticated_transmission_encodes_absent_service_signature_as_maybe_none() { + let transmission = RadrootsSimplexSmpCommandTransmission { + authorization: vec![1, 2, 3], + correlation_id: Some(correlation_id(7)), + entity_id: Vec::new(), + command: RadrootsSimplexSmpCommand::Ping, + }; + + let encoded = transmission.encode().unwrap(); + assert_eq!(encoded[0], 3); + assert_eq!(&encoded[1..4], &[1, 2, 3]); + assert_eq!(encoded[4], b'0'); + + let decoded = RadrootsSimplexSmpCommandTransmission::decode(&encoded).unwrap(); + assert_eq!(decoded, transmission); + } + + #[test] fn round_trips_current_ids_broker_transmission() { let transmission = RadrootsSimplexSmpBrokerTransmission { authorization: Vec::new(), diff --git a/crates/simplex-smp-transport/src/client.rs b/crates/simplex-smp-transport/src/client.rs @@ -8,15 +8,21 @@ use crate::executor::{ use crate::frame::{RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, RadrootsSimplexSmpTransportBlock}; use crate::handshake::{ RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1, RadrootsSimplexSmpClientHello, RadrootsSimplexSmpServerHello, - RadrootsSimplexSmpTlsHandshakeEvidence, RadrootsSimplexSmpTlsPolicy, validate_tls_handshake, + RadrootsSimplexSmpTlsHandshakeEvidence, RadrootsSimplexSmpTlsPolicy, + RadrootsSimplexSmpTransportServerProof, validate_tls_handshake, }; use base64::Engine as _; use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; use radroots_simplex_smp_crypto::prelude::{ RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, + RadrootsSimplexSmpSecretBoxChainKey, RadrootsSimplexSmpX25519Keypair, advance_secretbox_chain, + decode_x25519_public_key_x509, derive_shared_secret, encode_x25519_public_key_x509, + encrypt_padded, init_secretbox_chain, verify_signature, }; use radroots_simplex_smp_proto::prelude::{ - RadrootsSimplexSmpCommandTransmission, RadrootsSimplexSmpServerAddress, + RADROOTS_SIMPLEX_SMP_AUTH_COMMANDS_TRANSPORT_VERSION, + RADROOTS_SIMPLEX_SMP_ENCRYPTED_BLOCK_TRANSPORT_VERSION, RadrootsSimplexSmpCommandTransmission, + RadrootsSimplexSmpServerAddress, }; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; @@ -41,6 +47,9 @@ struct RadrootsSimplexSmpLiveSession { stream: StreamOwned<ClientConnection, TcpStream>, transport_version: u16, session_identifier: Vec<u8>, + send_chain_key: Option<RadrootsSimplexSmpSecretBoxChainKey>, + receive_chain_key: Option<RadrootsSimplexSmpSecretBoxChainKey>, + debug_shared_secret: Option<Vec<u8>>, } impl RadrootsSimplexSmpTlsCommandTransport { @@ -129,7 +138,7 @@ fn execute_live_request( &[transmission], session.transport_version, )?; - let encoded = block.encode()?; + let encoded = encode_live_transport_block(session, &block)?; session .stream .write_all(&encoded) @@ -145,7 +154,7 @@ fn execute_live_request( .read_exact(&mut response_block) .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; let response_hash = Sha256::digest(&response_block).to_vec(); - let decoded = RadrootsSimplexSmpTransportBlock::decode(&response_block)?; + let decoded = decode_live_transport_block(session, &response_block)?; let transmissions = decoded.decode_broker_transmissions(session.transport_version)?; if transmissions.len() != 1 { return Err( @@ -166,6 +175,127 @@ fn execute_live_request( }) } +fn transport_debug_enabled() -> bool { + std::env::var_os("RADROOTS_SIMPLEX_DEBUG_TRANSPORT").is_some() +} + +fn debug_sha256_label(label: &str, value: &[u8]) { + if transport_debug_enabled() { + eprintln!( + "[simplex-smp-transport] {label}: len={} sha256={}", + value.len(), + URL_SAFE_NO_PAD.encode(Sha256::digest(value)), + ); + } +} + +fn encode_live_transport_block( + session: &mut RadrootsSimplexSmpLiveSession, + block: &RadrootsSimplexSmpTransportBlock, +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + if session.transport_version >= RADROOTS_SIMPLEX_SMP_ENCRYPTED_BLOCK_TRANSPORT_VERSION { + if let Some(chain_key) = session.send_chain_key.as_ref() { + let ((secretbox_key, nonce), next_chain_key) = advance_secretbox_chain(chain_key)?; + session.send_chain_key = Some(next_chain_key); + return encrypt_padded( + &secretbox_key, + &nonce, + &block.encode_payload()?, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE - 16, + ) + .map_err(Into::into); + } + } + block.encode() +} + +fn decode_live_transport_block( + session: &mut RadrootsSimplexSmpLiveSession, + bytes: &[u8], +) -> Result<RadrootsSimplexSmpTransportBlock, RadrootsSimplexSmpTransportError> { + if session.transport_version >= RADROOTS_SIMPLEX_SMP_ENCRYPTED_BLOCK_TRANSPORT_VERSION { + if let Some(chain_key) = session.receive_chain_key.as_ref() { + let ((secretbox_key, nonce), next_chain_key) = advance_secretbox_chain(chain_key)?; + match radroots_simplex_smp_crypto::prelude::decrypt_padded( + &secretbox_key, + &nonce, + bytes, + ) { + Ok(payload) => { + session.receive_chain_key = Some(next_chain_key); + debug_sha256_label("live-response-payload", &payload); + return RadrootsSimplexSmpTransportBlock::from_payload(&payload); + } + Err(error) => { + if transport_debug_enabled() { + eprintln!("[simplex-smp-transport] live response decrypt failed: {error}"); + debug_sha256_label("live-response-ciphertext", bytes); + } + if let Some(send_chain_key) = session.send_chain_key.as_ref() { + let ((alt_secretbox_key, alt_nonce), _) = + advance_secretbox_chain(send_chain_key)?; + if radroots_simplex_smp_crypto::prelude::decrypt_padded( + &alt_secretbox_key, + &alt_nonce, + bytes, + ) + .is_ok() + { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "server response decrypted with the outbound chain key; live SMP block direction is assigned incorrectly".into(), + )); + } + } + debug_probe_transport_candidates(session, bytes); + if let Ok(block) = RadrootsSimplexSmpTransportBlock::decode(bytes) { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + format!( + "server returned plaintext SMP block while encrypted transport was expected: {:?}", + block.transmissions.first().map(|t| &t[..t.len().min(8)]) + ), + )); + } + return Err(error.into()); + } + } + } + } + RadrootsSimplexSmpTransportBlock::decode(bytes) +} + +fn debug_probe_transport_candidates(session: &mut RadrootsSimplexSmpLiveSession, bytes: &[u8]) { + if !transport_debug_enabled() { + return; + } + let Some(shared_secret) = session.debug_shared_secret.as_ref() else { + return; + }; + let Ok((first_chain_key, second_chain_key)) = + init_secretbox_chain(&session.session_identifier, shared_secret) + else { + return; + }; + for (label, chain_key) in [ + ("initial-first", first_chain_key), + ("initial-second", second_chain_key), + ] { + let Ok(((secretbox_key, nonce), _)) = advance_secretbox_chain(&chain_key) else { + continue; + }; + let result = + radroots_simplex_smp_crypto::prelude::decrypt_padded(&secretbox_key, &nonce, bytes); + match result { + Ok(payload) => { + eprintln!("[simplex-smp-transport] debug candidate {label} decrypted live block"); + debug_sha256_label("debug-candidate-payload", &payload); + } + Err(error) => { + eprintln!("[simplex-smp-transport] debug candidate {label} failed: {error}"); + } + } + } +} + fn connect_live_session( server: &RadrootsSimplexSmpServerAddress, ) -> Result<RadrootsSimplexSmpLiveSession, RadrootsSimplexSmpTransportError> { @@ -248,7 +378,7 @@ fn connect_live_session_host( let server_hello = read_server_hello(&mut stream)?; let actual_identity = matching_server_identity(&peer_certs, &server.server_identity)?; let expected_identity = canonical_server_identity(&server.server_identity)?; - let mut policy = RadrootsSimplexSmpTlsPolicy::modern(expected_identity); + let mut policy = RadrootsSimplexSmpTlsPolicy::modern(expected_identity.clone()); policy.require_tls_unique_binding = false; let transport_version = validate_tls_handshake( &policy, @@ -264,12 +394,27 @@ fn connect_live_session_host( tls_unique_channel_binding: None, }, )?; + let transport_keypair = + if transport_version >= RADROOTS_SIMPLEX_SMP_AUTH_COMMANDS_TRANSPORT_VERSION { + Some(RadrootsSimplexSmpX25519Keypair::generate()?) + } else { + None + }; let client_hello = RadrootsSimplexSmpClientHello { chosen_version: transport_version, - client_key: None, + server_key_hash: decode_server_identity(&expected_identity)?, + client_key: transport_keypair + .as_ref() + .map(|keypair| encode_x25519_public_key_x509(&keypair.public_key)) + .transpose()?, + proxy_server: false, ignored_part: Vec::new(), }; let encoded_client_hello = client_hello.encode()?; + if transport_debug_enabled() { + debug_sha256_label("client-hello", &encoded_client_hello); + debug_sha256_label("server-session-id", &server_hello.session_identifier); + } stream .write_all(&encoded_client_hello) .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; @@ -277,13 +422,232 @@ fn connect_live_session_host( .flush() .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + let mut debug_shared_secret = None; + let (receive_chain_key, send_chain_key) = + if transport_version >= RADROOTS_SIMPLEX_SMP_ENCRYPTED_BLOCK_TRANSPORT_VERSION { + let server_key = decode_server_transport_public_key( + server_hello + .server_proof + .as_ref() + .ok_or(RadrootsSimplexSmpTransportError::MissingServerProof)?, + )?; + let shared_secret = derive_shared_secret( + &transport_keypair + .as_ref() + .ok_or(RadrootsSimplexSmpTransportError::MissingServerProof)? + .private_key, + &server_key, + )?; + if transport_debug_enabled() { + if let Some(keypair) = transport_keypair.as_ref() { + debug_sha256_label("client-transport-public-key", &keypair.public_key); + } + debug_sha256_label("server-transport-public-key", &server_key); + debug_sha256_label("transport-shared-secret", &shared_secret); + } + debug_shared_secret = transport_debug_enabled().then_some(shared_secret.clone()); + let (receive_chain_key, send_chain_key) = + init_secretbox_chain(&server_hello.session_identifier, &shared_secret)?; + (Some(receive_chain_key), Some(send_chain_key)) + } else { + (None, None) + }; + Ok(RadrootsSimplexSmpLiveSession { stream, transport_version, session_identifier: server_hello.session_identifier, + send_chain_key, + receive_chain_key, + debug_shared_secret, }) } +fn decode_server_transport_public_key( + proof: &RadrootsSimplexSmpTransportServerProof, +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let (signed_object, signature) = decode_signed_server_key_parts(&proof.signed_server_key)?; + if !proof.certificate_payload.is_empty() { + let verify_key = decode_server_certificate_verify_key(&proof.certificate_payload)?; + verify_signature(signed_object, &verify_key, signature).map_err(|error| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "failed to verify SMP server transport key signature: {error}" + )) + })?; + } + + decode_x25519_public_key_x509(signed_object) + .or_else(|_| { + first_der_sequence_element(signed_object) + .and_then(|candidate| decode_x25519_public_key_x509(candidate).map_err(Into::into)) + }) + .map_err(|error: RadrootsSimplexSmpTransportError| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "failed to decode verified SMP server transport key: {error}" + )) + }) +} + +fn first_der_sequence_element(bytes: &[u8]) -> Result<&[u8], RadrootsSimplexSmpTransportError> { + let (sequence_tag, _, sequence_header_end, sequence_content_end) = parse_der_element(bytes, 0)?; + if sequence_tag != 0x30 { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: expected DER sequence".into(), + )); + } + let (_, element_start, _, element_end) = parse_der_element(bytes, sequence_header_end)?; + if element_end > sequence_content_end { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: first element exceeds sequence bounds".into(), + )); + } + Ok(&bytes[element_start..element_end]) +} + +fn decode_signed_server_key_parts( + bytes: &[u8], +) -> Result<(&[u8], &[u8]), RadrootsSimplexSmpTransportError> { + let (sequence_tag, _, sequence_header_end, sequence_content_end) = parse_der_element(bytes, 0)?; + if sequence_tag != 0x30 { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: signed key is not a DER sequence".into(), + )); + } + + let (_, signed_object_start, _, signed_object_end) = + parse_der_element(bytes, sequence_header_end)?; + let (_, _, _, algorithm_end) = parse_der_element(bytes, signed_object_end)?; + if algorithm_end > sequence_content_end { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: signature algorithm exceeds sequence bounds".into(), + )); + } + let (signature_tag, _, signature_value_start, signature_end) = + parse_der_element(bytes, algorithm_end)?; + if signature_tag != 0x03 { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: expected DER bit string signature".into(), + )); + } + if signature_end > sequence_content_end { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: signature exceeds sequence bounds".into(), + )); + } + let signature_value = bytes + .get(signature_value_start..signature_end) + .ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: truncated signature".into(), + ) + })?; + let (unused_bits, signature) = signature_value.split_first().ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: missing signature payload".into(), + ) + })?; + if *unused_bits != 0 { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: unsupported signature bit padding".into(), + )); + } + Ok((&bytes[signed_object_start..signed_object_end], signature)) +} + +fn decode_server_certificate_verify_key( + certificate_payload: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let Some(&cert_count) = certificate_payload.first() else { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: missing certificate chain".into(), + )); + }; + if cert_count == 0 { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: empty certificate chain".into(), + )); + } + let (certificate_der, _) = read_large_handshake_field(certificate_payload, 1)?; + let (_, certificate) = x509_parser::certificate::X509Certificate::from_der(&certificate_der) + .map_err(|error| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "failed to parse SMP proof certificate: {error}" + )) + })?; + Ok(certificate + .tbs_certificate + .subject_pki + .subject_public_key + .data + .to_vec()) +} + +fn read_large_handshake_field( + bytes: &[u8], + offset: usize, +) -> Result<(Vec<u8>, usize), RadrootsSimplexSmpTransportError> { + let Some(length_bytes) = bytes.get(offset..offset + 2) else { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: truncated certificate length".into(), + )); + }; + let length = u16::from_be_bytes([length_bytes[0], length_bytes[1]]) as usize; + let start = offset + 2; + let end = start + length; + let value = bytes.get(start..end).ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: certificate exceeds payload".into(), + ) + })?; + Ok((value.to_vec(), end)) +} + +fn parse_der_element( + bytes: &[u8], + offset: usize, +) -> Result<(u8, usize, usize, usize), RadrootsSimplexSmpTransportError> { + let tag = *bytes.get(offset).ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: truncated DER element".into(), + ) + })?; + let length_offset = offset + 1; + let length_tag = *bytes.get(length_offset).ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: missing DER length".into(), + ) + })?; + let (value_len, header_len) = if length_tag & 0x80 == 0 { + (length_tag as usize, 2) + } else { + let length_bytes = (length_tag & 0x7f) as usize; + if length_bytes == 0 || length_bytes > 4 { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: unsupported DER length encoding".into(), + )); + } + let length_start = length_offset + 1; + let length_end = length_start + length_bytes; + let encoded_length = bytes.get(length_start..length_end).ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: truncated DER length".into(), + ) + })?; + let value_len = encoded_length + .iter() + .fold(0_usize, |acc, byte| (acc << 8) | (*byte as usize)); + (value_len, 2 + length_bytes) + }; + let value_start = offset + header_len; + let value_end = value_start + value_len; + if value_end > bytes.len() { + return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( + "invalid SMP server proof: DER element exceeds input".into(), + )); + } + Ok((tag, offset, value_start, value_end)) +} + fn read_server_hello( stream: &mut StreamOwned<ClientConnection, TcpStream>, ) -> Result<RadrootsSimplexSmpServerHello, RadrootsSimplexSmpTransportError> { @@ -318,13 +682,12 @@ fn matching_server_identity( fn server_identity_from_certificate( der: &[u8], ) -> Result<String, RadrootsSimplexSmpTransportError> { - let (_, certificate) = - x509_parser::certificate::X509Certificate::from_der(der).map_err(|error| { - RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( - "failed to parse SMP certificate: {error}" - )) - })?; - let digest = Sha256::digest(certificate.tbs_certificate.subject_pki.raw); + x509_parser::certificate::X509Certificate::from_der(der).map_err(|error| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "failed to parse SMP certificate: {error}" + )) + })?; + let digest = Sha256::digest(der); Ok(URL_SAFE_NO_PAD.encode(digest)) } @@ -340,6 +703,17 @@ fn canonical_server_identity(value: &str) -> Result<String, RadrootsSimplexSmpTr }) } +fn decode_server_identity(value: &str) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + URL_SAFE_NO_PAD + .decode(value) + .or_else(|_| URL_SAFE.decode(value)) + .map_err(|_| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "invalid base64url server identity `{value}`" + )) + }) +} + #[derive(Debug)] struct PermissiveSimplexServerVerifier; @@ -388,11 +762,70 @@ impl ServerCertVerifier for PermissiveSimplexServerVerifier { #[cfg(test)] mod tests { - use super::canonical_server_identity; + use super::{canonical_server_identity, decode_server_transport_public_key}; + use crate::handshake::RadrootsSimplexSmpTransportServerProof; + use radroots_simplex_smp_crypto::prelude::{ + RadrootsSimplexSmpX25519Keypair, encode_x25519_public_key_x509, + }; #[test] fn canonicalizes_padded_and_unpadded_server_identity() { assert_eq!(canonical_server_identity("YWJjZA").unwrap(), "YWJjZA"); assert_eq!(canonical_server_identity("YWJjZA==").unwrap(), "YWJjZA"); } + + #[test] + fn extracts_spki_from_signed_server_key_sequence() { + let keypair = RadrootsSimplexSmpX25519Keypair::from_seed(b"transport-proof"); + let spki = encode_x25519_public_key_x509(&keypair.public_key).unwrap(); + let empty_sequence = der_sequence(core::iter::once(&[][..])); + let signature = [0x03, 0x01, 0x00]; + let signed_object = der_sequence([ + spki.as_slice(), + empty_sequence.as_slice(), + signature.as_slice(), + ]); + let proof = RadrootsSimplexSmpTransportServerProof { + certificate_payload: Vec::new(), + signed_server_key: signed_object, + }; + assert_eq!( + decode_server_transport_public_key(&proof).unwrap(), + keypair.public_key + ); + } + + fn der_sequence<'a, I>(elements: I) -> Vec<u8> + where + I: IntoIterator<Item = &'a [u8]>, + { + let mut body = Vec::new(); + for element in elements { + if element.is_empty() { + body.extend_from_slice(&[0x30, 0x00]); + } else { + body.extend_from_slice(element); + } + } + let mut sequence = vec![0x30]; + push_der_length(&mut sequence, body.len()); + sequence.extend_from_slice(&body); + sequence + } + + fn push_der_length(buffer: &mut Vec<u8>, len: usize) { + if len < 0x80 { + buffer.push(len as u8); + return; + } + let mut bytes = Vec::new(); + let mut remaining = len; + while remaining > 0 { + bytes.push((remaining & 0xff) as u8); + remaining >>= 8; + } + bytes.reverse(); + buffer.push(0x80 | (bytes.len() as u8)); + buffer.extend_from_slice(&bytes); + } } diff --git a/crates/simplex-smp-transport/src/frame.rs b/crates/simplex-smp-transport/src/frame.rs @@ -48,7 +48,7 @@ impl RadrootsSimplexSmpTransportBlock { } pub fn encode(&self) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { - let payload = encode_transport_payload(&self.transmissions)?; + let payload = self.encode_payload()?; encode_padded_bytes( &payload, RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, @@ -62,7 +62,15 @@ impl RadrootsSimplexSmpTransportBlock { RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE, RADROOTS_SIMPLEX_SMP_TRANSPORT_PAD_BYTE, )?; - let transmissions = decode_transport_payload(&payload)?; + Self::from_payload(&payload) + } + + pub fn encode_payload(&self) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + encode_transport_payload(&self.transmissions) + } + + pub fn from_payload(payload: &[u8]) -> Result<Self, RadrootsSimplexSmpTransportError> { + let transmissions = decode_transport_payload(payload)?; Self::new(transmissions) } @@ -313,4 +321,13 @@ mod tests { RadrootsSimplexSmpTransportError::InvalidPadding { .. } )); } + + #[test] + fn roundtrips_transport_payload_without_padding() { + let block = + RadrootsSimplexSmpTransportBlock::new(vec![b"one".to_vec(), b"two".to_vec()]).unwrap(); + let payload = block.encode_payload().unwrap(); + let decoded = RadrootsSimplexSmpTransportBlock::from_payload(&payload).unwrap(); + assert_eq!(decoded, block); + } } diff --git a/crates/simplex-smp-transport/src/handshake.rs b/crates/simplex-smp-transport/src/handshake.rs @@ -7,7 +7,8 @@ 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, + RADROOTS_SIMPLEX_SMP_PROXY_SERVER_HANDSHAKE_TRANSPORT_VERSION, + RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION, RadrootsSimplexSmpVersionRange, }; pub const RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1: &str = "smp/1"; @@ -32,7 +33,9 @@ pub struct RadrootsSimplexSmpServerHello { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpClientHello { pub chosen_version: u16, + pub server_key_hash: Vec<u8>, pub client_key: Option<Vec<u8>>, + pub proxy_server: bool, pub ignored_part: Vec<u8>, } @@ -79,10 +82,8 @@ impl RadrootsSimplexSmpServerHello { 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); + push_large_bytes(&mut payload, &proof.signed_server_key)?; } payload.extend_from_slice(&self.ignored_part); encode_padded_bytes( @@ -130,9 +131,16 @@ 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()); + push_short_bytes(&mut payload, &self.server_key_hash)?; if let Some(client_key) = &self.client_key { push_short_bytes(&mut payload, client_key)?; } + if self.chosen_version >= RADROOTS_SIMPLEX_SMP_PROXY_SERVER_HANDSHAKE_TRANSPORT_VERSION { + payload.push(if self.proxy_server { b'T' } else { b'F' }); + } + if self.chosen_version >= RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION { + payload.push(b'0'); + } payload.extend_from_slice(&self.ignored_part); encode_padded_bytes( &payload, @@ -153,11 +161,57 @@ impl RadrootsSimplexSmpClientHello { )); }; let chosen_version = u16::from_be_bytes([version_bytes[0], version_bytes[1]]); - let (client_key, ignored_part) = parse_optional_client_key(&payload[2..]); + let (server_key_hash, mut cursor) = read_short_bytes(&payload, 2)?; + let client_key = if chosen_version + >= RADROOTS_SIMPLEX_SMP_PROXY_SERVER_HANDSHAKE_TRANSPORT_VERSION + && matches!(payload.get(cursor), Some(b'T' | b'F')) + { + None + } else { + let (client_key, consumed) = parse_optional_client_key(&payload[cursor..]); + cursor += consumed; + client_key + }; + let proxy_server = + if chosen_version >= RADROOTS_SIMPLEX_SMP_PROXY_SERVER_HANDSHAKE_TRANSPORT_VERSION { + let Some(value) = payload.get(cursor) else { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "proxy_server", + )); + }; + cursor += 1; + match *value { + b'T' => true, + b'F' => false, + _ => { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "proxy_server", + )); + } + } + } else { + false + }; + if chosen_version >= RADROOTS_SIMPLEX_SMP_SERVICE_CERTS_TRANSPORT_VERSION { + let Some(tag) = payload.get(cursor) else { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "client_service", + )); + }; + cursor += 1; + if *tag != b'0' { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "client_service", + )); + } + } + let ignored_part = payload[cursor..].to_vec(); Ok(Self { chosen_version, + server_key_hash, client_key, + proxy_server, ignored_part, }) } @@ -254,6 +308,18 @@ fn push_short_bytes( Ok(()) } +fn push_large_bytes( + buffer: &mut Vec<u8>, + bytes: &[u8], +) -> Result<(), RadrootsSimplexSmpTransportError> { + let len = u16::try_from(bytes.len()).map_err(|_| { + RadrootsSimplexSmpTransportError::InvalidSessionIdentifierLength(bytes.len()) + })?; + buffer.extend_from_slice(&len.to_be_bytes()); + buffer.extend_from_slice(bytes); + Ok(()) +} + fn read_short_bytes( payload: &[u8], offset: usize, @@ -273,41 +339,63 @@ fn read_short_bytes( Ok((value.to_vec(), end)) } +fn read_large_bytes( + payload: &[u8], + offset: usize, +) -> Result<(Vec<u8>, usize), RadrootsSimplexSmpTransportError> { + let Some(length_bytes) = payload.get(offset..offset + 2) else { + return Err(RadrootsSimplexSmpTransportError::MissingHandshakeField( + "large_field", + )); + }; + let length = u16::from_be_bytes([length_bytes[0], length_bytes[1]]) as usize; + let start = offset + 2; + let end = start + length; + 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 { + let Some(&cert_count) = remainder.first() else { 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() { + }; + if cert_count == 0 { 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()); + let mut cursor = 1; + for _ in 0..cert_count { + let Ok((_, next_cursor)) = read_large_bytes(remainder, cursor) else { + return (None, remainder.to_vec()); + }; + cursor = next_cursor; } + let Ok((signed_server_key, cursor)) = read_large_bytes(remainder, cursor) else { + 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(), + certificate_payload: remainder[..cursor - signed_server_key.len() - 2].to_vec(), + signed_server_key, }), - remainder[key_end..].to_vec(), + remainder[cursor..].to_vec(), ) } -fn parse_optional_client_key(remainder: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) { +fn parse_optional_client_key(remainder: &[u8]) -> (Option<Vec<u8>>, usize) { let Some(&length) = remainder.first() else { - return (None, Vec::new()); + return (None, 0); }; let end = 1 + length as usize; if length == 0 || end > remainder.len() { - return (None, remainder.to_vec()); + return (None, remainder.len()); } - (Some(remainder[1..end].to_vec()), remainder[end..].to_vec()) + (Some(remainder[1..end].to_vec()), end) } #[cfg(test)] @@ -320,7 +408,7 @@ mod tests { 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(), + certificate_payload: encode_certificate_chain_payload([b"cert-chain".as_slice()]), signed_server_key: b"signed-key".to_vec(), }), ignored_part: b"ignored".to_vec(), @@ -390,4 +478,17 @@ mod tests { RadrootsSimplexSmpTransportError::ServerIdentityMismatch { .. } )); } + + fn encode_certificate_chain_payload<'a, I>(certificates: I) -> Vec<u8> + where + I: IntoIterator<Item = &'a [u8]>, + { + let certificates: Vec<&[u8]> = certificates.into_iter().collect(); + let mut payload = vec![certificates.len() as u8]; + for certificate in certificates { + payload.extend_from_slice(&(certificate.len() as u16).to_be_bytes()); + payload.extend_from_slice(certificate); + } + payload + } }