lib

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

commit bd60788825c51d8bbe35b58235d3cd49383231c9
parent 92be05355a40040cbd0e52682b024ee59b775fa8
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 03:18:23 +0000

simplex_smp_transport: match upstream encrypted interop

- replace generic XSalsa AEAD with upstream-compatible secretbox stream and padding
- prove encrypted request and response chain direction with transport tests
- add required-mode upstream env handling and live SMP ping plus queue flow tests
- validate live create, subscribe, send, receive, ack, and resubscribe against local upstream

Diffstat:
MCargo.lock | 17+++--------------
MCargo.toml | 4+++-
Mcrates/simplex_interop_tests/src/lib.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/simplex_interop_tests/src/policy.rs | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/simplex_smp_crypto/Cargo.toml | 5+++--
Mcrates/simplex_smp_crypto/src/message.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/simplex_smp_transport/src/client.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
7 files changed, 447 insertions(+), 89 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4641,12 +4641,14 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.17", "hkdf", + "poly1305", "radroots_simplex_smp_proto", + "salsa20", "sha2", "sntrup761", + "subtle", "x25519-dalek", "x448", - "xsalsa20poly1305", ] [[package]] @@ -8468,19 +8470,6 @@ dependencies = [ ] [[package]] -name = "xsalsa20poly1305" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a6dad357567f81cd78ee75f7c61f1b30bb2fe4390be8fb7c69e2ac8dffb6c7" -dependencies = [ - "aead", - "poly1305", - "salsa20", - "subtle", - "zeroize", -] - -[[package]] name = "xtask" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -131,7 +131,9 @@ nostr = { version = "0.44.2" } nostr-relay-pool = { version = "0.44.0" } nostr-sdk = { version = "0.44.1" } num_cpus = { version = "1.17.0" } +poly1305 = { version = "0.8", default-features = false } secrecy = { version = "0.10.3" } +salsa20 = { version = "0.10.2", default-features = false } serde = { version = "1", default-features = false, features = [ "derive", "alloc", @@ -143,6 +145,7 @@ sntrup761 = { version = "0.4.0", default-features = false, features = [ "ecap", "kgen", ] } +subtle = { version = "2.6", default-features = false } sqlx = { version = "0.8.6", default-features = false } sp1-build = { version = "6.2.3" } sp1-sdk = { version = "6.2.3", default-features = false } @@ -154,7 +157,6 @@ rustls = { version = "0.23", default-features = false, features = [ ] } rust_decimal = { version = "1", default-features = false } rust_decimal_macros = { version = "1" } -xsalsa20poly1305 = { version = "0.9", default-features = false } x25519-dalek = { version = "2", default-features = false, features = [ "static_secrets", ] } diff --git a/crates/simplex_interop_tests/src/lib.rs b/crates/simplex_interop_tests/src/lib.rs @@ -98,6 +98,11 @@ mod tests { } } + #[cfg(feature = "std")] + fn local_upstream_target() -> Option<RadrootsSimplexInteropLocalUpstream> { + RadrootsSimplexInteropLocalUpstream::required_from_env().unwrap() + } + #[derive(Default)] struct ScriptedTransport { responses: VecDeque<RadrootsSimplexSmpBrokerMessage>, @@ -339,18 +344,55 @@ mod tests { #[cfg(feature = "std")] #[test] fn local_upstream_contract_is_opt_in() { - let Some(target) = RadrootsSimplexInteropLocalUpstream::from_env() else { + let Some(target) = local_upstream_target() else { + return; + }; + target.assert_reachable().unwrap(); + } + + #[cfg(feature = "std")] + #[test] + fn required_local_upstream_contract_is_enforced() { + let Some(target) = local_upstream_target() else { return; }; target.assert_reachable().unwrap(); + assert!(target.server_address().is_some()); } #[cfg(feature = "std")] #[test] - fn local_upstream_subscribe_receives_sent_message_when_identity_is_configured() { - let Some(target) = RadrootsSimplexInteropLocalUpstream::from_env() else { + fn local_upstream_ping_round_trips_when_configured() { + let Some(target) = local_upstream_target() else { return; }; + target.assert_reachable().unwrap(); + let Some(server) = target.server_address() else { + return; + }; + + let response = RadrootsSimplexSmpTlsCommandTransport::new() + .execute(live_transport_request( + server, + correlation_id(1), + Vec::new(), + RadrootsSimplexSmpCommand::Ping, + RadrootsSimplexSmpCommandAuthorization::None, + )) + .unwrap(); + assert!(matches!( + response.transmission.message, + RadrootsSimplexSmpBrokerMessage::Pong + )); + } + + #[cfg(feature = "std")] + #[test] + fn local_upstream_create_subscribe_send_receive_ack_and_resubscribe_when_configured() { + let Some(target) = local_upstream_target() else { + return; + }; + target.assert_reachable().unwrap(); let Some(server) = target.server_address() else { return; }; @@ -390,20 +432,22 @@ mod tests { correlation_id(2), ids.recipient_id.clone(), RadrootsSimplexSmpCommand::Sub, - RadrootsSimplexSmpCommandAuthorization::Ed25519(recipient_auth), + RadrootsSimplexSmpCommandAuthorization::Ed25519(recipient_auth.clone()), )) .unwrap(); - assert!(matches!( - subscribe_response.transmission.message, - RadrootsSimplexSmpBrokerMessage::Ok | RadrootsSimplexSmpBrokerMessage::Msg(_) - )); + match subscribe_response.transmission.message { + RadrootsSimplexSmpBrokerMessage::Ok + | RadrootsSimplexSmpBrokerMessage::Sok(_) + | RadrootsSimplexSmpBrokerMessage::Msg(_) => {} + other => panic!("expected live SMP subscription readiness response, got {other:?}"), + } let mut sender_transport = RadrootsSimplexSmpTlsCommandTransport::new(); let send_response = sender_transport .execute(live_transport_request( server.clone(), correlation_id(3), - ids.sender_id, + ids.sender_id.clone(), RadrootsSimplexSmpCommand::Send(RadrootsSimplexSmpSendCommand { flags: RadrootsSimplexSmpMessageFlags::notifications_enabled(), message_body: b"rr-synth-live-subscribe-message".to_vec(), @@ -417,7 +461,9 @@ mod tests { )); let subscription_response = recipient_transport - .receive_subscription(RadrootsSimplexSmpSubscriptionReceiveRequest { server }) + .receive_subscription(RadrootsSimplexSmpSubscriptionReceiveRequest { + server: server.clone(), + }) .unwrap() .expect("expected live SMP subscription message"); let RadrootsSimplexSmpBrokerMessage::Msg(message) = @@ -427,5 +473,36 @@ mod tests { }; assert!(!message.message_id.is_empty()); assert!(!message.encrypted_body.is_empty()); + + let ack_response = recipient_transport + .execute(live_transport_request( + server.clone(), + correlation_id(4), + ids.recipient_id.clone(), + RadrootsSimplexSmpCommand::Ack(message.message_id), + RadrootsSimplexSmpCommandAuthorization::Ed25519(recipient_auth.clone()), + )) + .unwrap(); + match ack_response.transmission.message { + RadrootsSimplexSmpBrokerMessage::Ok => {} + other => panic!("expected live SMP ACK response, got {other:?}"), + } + + let mut reconnect_transport = RadrootsSimplexSmpTlsCommandTransport::new(); + let resubscribe_response = reconnect_transport + .execute(live_transport_request( + server, + correlation_id(5), + ids.recipient_id, + RadrootsSimplexSmpCommand::Sub, + RadrootsSimplexSmpCommandAuthorization::Ed25519(recipient_auth), + )) + .unwrap(); + match resubscribe_response.transmission.message { + RadrootsSimplexSmpBrokerMessage::Ok + | RadrootsSimplexSmpBrokerMessage::Sok(_) + | RadrootsSimplexSmpBrokerMessage::Msg(_) => {} + other => panic!("expected live SMP resubscription readiness response, got {other:?}"), + } } } diff --git a/crates/simplex_interop_tests/src/policy.rs b/crates/simplex_interop_tests/src/policy.rs @@ -4,6 +4,16 @@ use alloc::vec; use core::fmt; use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueUri; +#[cfg(feature = "std")] +pub const RADROOTS_SIMPLEX_INTEROP_REQUIRE_UPSTREAM_ENV: &str = + "RADROOTS_SIMPLEX_INTEROP_REQUIRE_UPSTREAM"; +#[cfg(feature = "std")] +pub const RADROOTS_SIMPLEX_INTEROP_SMP_HOST_ENV: &str = "RADROOTS_SIMPLEX_INTEROP_SMP_HOST"; +#[cfg(feature = "std")] +pub const RADROOTS_SIMPLEX_INTEROP_SMP_PORT_ENV: &str = "RADROOTS_SIMPLEX_INTEROP_SMP_PORT"; +#[cfg(feature = "std")] +pub const RADROOTS_SIMPLEX_INTEROP_SMP_IDENTITY_ENV: &str = "RADROOTS_SIMPLEX_INTEROP_SMP_IDENTITY"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexInteropFixturePolicy { pub namespace_prefix: &'static str, @@ -54,17 +64,51 @@ pub struct RadrootsSimplexInteropLocalUpstream { #[cfg(feature = "std")] impl RadrootsSimplexInteropLocalUpstream { pub fn from_env() -> Option<Self> { - let host = std::env::var("RADROOTS_SIMPLEX_INTEROP_SMP_HOST").ok()?; - let port = std::env::var("RADROOTS_SIMPLEX_INTEROP_SMP_PORT") - .ok()? - .parse::<u16>() - .ok()?; - let server_identity = std::env::var("RADROOTS_SIMPLEX_INTEROP_SMP_IDENTITY").ok(); - Some(Self { + Self::from_env_values(false).ok().flatten() + } + + pub fn required_from_env() -> Result<Option<Self>, RadrootsSimplexInteropPolicyError> { + Self::from_env_values(required_upstream_enabled()) + } + + fn from_env_values(required: bool) -> Result<Option<Self>, RadrootsSimplexInteropPolicyError> { + let host = optional_env_value(RADROOTS_SIMPLEX_INTEROP_SMP_HOST_ENV); + let port = optional_env_value(RADROOTS_SIMPLEX_INTEROP_SMP_PORT_ENV); + let server_identity = optional_env_value(RADROOTS_SIMPLEX_INTEROP_SMP_IDENTITY_ENV); + Self::from_values(host, port, server_identity, required) + } + + pub fn from_values( + host: Option<String>, + port: Option<String>, + server_identity: Option<String>, + required: bool, + ) -> Result<Option<Self>, RadrootsSimplexInteropPolicyError> { + let Some(host) = + required_or_optional(host, required, RADROOTS_SIMPLEX_INTEROP_SMP_HOST_ENV)? + else { + return Ok(None); + }; + let Some(port) = + required_or_optional(port, required, RADROOTS_SIMPLEX_INTEROP_SMP_PORT_ENV)? + else { + return Ok(None); + }; + let server_identity = match required_or_optional( + server_identity, + required, + RADROOTS_SIMPLEX_INTEROP_SMP_IDENTITY_ENV, + )? { + Some(value) => Some(value), + None => None, + }; + Ok(Some(Self { host, - port, + port: port.parse::<u16>().map_err(|_| { + RadrootsSimplexInteropPolicyError::InvalidLocalUpstreamPort(port.clone()) + })?, server_identity, - }) + })) } pub fn server_address( @@ -104,6 +148,8 @@ impl RadrootsSimplexInteropLocalUpstream { pub enum RadrootsSimplexInteropPolicyError { InvalidFixtureId(String), InvalidFixtureHost(String), + MissingLocalUpstreamEnv(&'static str), + InvalidLocalUpstreamPort(String), LocalUpstreamIo(String), } @@ -122,6 +168,15 @@ impl fmt::Display for RadrootsSimplexInteropPolicyError { "interop fixture host `{host}` is not in a synthetic domain" ) } + Self::MissingLocalUpstreamEnv(name) => { + write!( + f, + "required SimpleX upstream environment `{name}` is not set" + ) + } + Self::InvalidLocalUpstreamPort(port) => { + write!(f, "invalid SimpleX upstream port `{port}`") + } Self::LocalUpstreamIo(message) => write!(f, "{message}"), } } @@ -129,3 +184,95 @@ impl fmt::Display for RadrootsSimplexInteropPolicyError { #[cfg(feature = "std")] impl std::error::Error for RadrootsSimplexInteropPolicyError {} + +#[cfg(feature = "std")] +fn optional_env_value(name: &str) -> Option<String> { + std::env::var(name) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +#[cfg(feature = "std")] +fn required_or_optional( + value: Option<String>, + required: bool, + name: &'static str, +) -> Result<Option<String>, RadrootsSimplexInteropPolicyError> { + match value { + Some(value) => Ok(Some(value)), + None if required => Err(RadrootsSimplexInteropPolicyError::MissingLocalUpstreamEnv( + name, + )), + None => Ok(None), + } +} + +#[cfg(feature = "std")] +fn required_upstream_enabled() -> bool { + optional_env_value(RADROOTS_SIMPLEX_INTEROP_REQUIRE_UPSTREAM_ENV) + .map(|value| { + matches!( + value.as_str(), + "1" | "true" | "TRUE" | "required" | "REQUIRED" + ) + }) + .unwrap_or(false) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn optional_upstream_config_returns_none_when_unset() { + assert_eq!( + RadrootsSimplexInteropLocalUpstream::from_values(None, None, None, false).unwrap(), + None + ); + } + + #[test] + fn required_upstream_config_reports_first_missing_value() { + let error = + RadrootsSimplexInteropLocalUpstream::from_values(None, None, None, true).unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexInteropPolicyError::MissingLocalUpstreamEnv( + RADROOTS_SIMPLEX_INTEROP_SMP_HOST_ENV + ) + )); + } + + #[test] + fn required_upstream_config_requires_identity() { + let error = RadrootsSimplexInteropLocalUpstream::from_values( + Some("127.0.0.1".to_owned()), + Some("5223".to_owned()), + None, + true, + ) + .unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexInteropPolicyError::MissingLocalUpstreamEnv( + RADROOTS_SIMPLEX_INTEROP_SMP_IDENTITY_ENV + ) + )); + } + + #[test] + fn required_upstream_config_rejects_invalid_port() { + let error = RadrootsSimplexInteropLocalUpstream::from_values( + Some("127.0.0.1".to_owned()), + Some("not-a-port".to_owned()), + Some("server-identity".to_owned()), + true, + ) + .unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexInteropPolicyError::InvalidLocalUpstreamPort(_) + )); + } +} diff --git a/crates/simplex_smp_crypto/Cargo.toml b/crates/simplex_smp_crypto/Cargo.toml @@ -20,7 +20,6 @@ std = [ "getrandom/std", "radroots_simplex_smp_proto/std", "sha2/std", - "xsalsa20poly1305/std", ] [dependencies] @@ -34,14 +33,16 @@ ed25519-dalek = { workspace = true, default-features = false, features = [ ] } getrandom = { workspace = true, default-features = false } hkdf = { workspace = true, default-features = false } +poly1305 = { workspace = true, default-features = false } radroots_simplex_smp_proto = { workspace = true, default-features = false } +salsa20 = { workspace = true, default-features = false } sha2 = { workspace = true, default-features = false } +subtle = { workspace = true, default-features = false } sntrup761 = { workspace = true, default-features = false, features = [ "dcap", "ecap", "kgen", ] } -xsalsa20poly1305 = { workspace = true, default-features = false } x25519-dalek = { workspace = true, default-features = false, features = [ "static_secrets", ] } diff --git a/crates/simplex_smp_crypto/src/message.rs b/crates/simplex_smp_crypto/src/message.rs @@ -3,10 +3,14 @@ use alloc::vec; use alloc::vec::Vec; use getrandom::getrandom; use hkdf::Hkdf; +use poly1305::Poly1305; +use poly1305::universal_hash::KeyInit as Poly1305KeyInit; +use salsa20::cipher::consts::U10; +use salsa20::cipher::{KeyIvInit, StreamCipher}; +use salsa20::{Salsa20, hsalsa}; use sha2::{Digest, Sha256, Sha512}; +use subtle::ConstantTimeEq; use x25519_dalek::{PublicKey, StaticSecret}; -use xsalsa20poly1305::aead::{AeadInPlace, KeyInit}; -use xsalsa20poly1305::{Tag, XSalsa20Poly1305}; pub const RADROOTS_SIMPLEX_SMP_NONCE_LENGTH: usize = 24; pub const RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH: usize = 32; @@ -163,7 +167,7 @@ pub fn encrypt_padded( let mut padded = Vec::with_capacity(padded_len); padded.extend_from_slice(&(plaintext.len() as u16).to_be_bytes()); padded.extend_from_slice(plaintext); - padded.resize(padded_len, 0); + padded.resize(padded_len, b'#'); encrypt_no_pad(shared_secret, nonce, &padded) } @@ -192,14 +196,14 @@ pub fn encrypt_no_pad( nonce: &[u8], plaintext: &[u8], ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { - let cipher = cipher(shared_secret)?; - let mut buffer = plaintext.to_vec(); - let tag = cipher - .encrypt_in_place_detached(&nonce_array(nonce)?.into(), b"", &mut buffer) - .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(plaintext.len()))?; - let mut encrypted = Vec::with_capacity(RADROOTS_SIMPLEX_SMP_AUTH_TAG_LENGTH + buffer.len()); + let stream = simplex_secretbox_stream(shared_secret, nonce, plaintext.len())?; + let (mac_key, mask) = stream.split_at(RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH); + let mut encrypted = Vec::with_capacity(RADROOTS_SIMPLEX_SMP_AUTH_TAG_LENGTH + plaintext.len()); + let mut ciphertext = plaintext.to_vec(); + xor_in_place(&mut ciphertext, mask); + let tag = Poly1305::new(mac_key.into()).compute_unpadded(&ciphertext); encrypted.extend_from_slice(&tag); - encrypted.extend_from_slice(&buffer); + encrypted.extend_from_slice(&ciphertext); Ok(encrypted) } @@ -213,32 +217,45 @@ pub fn decrypt_no_pad( ciphertext.len(), )); } - let cipher = cipher(shared_secret)?; let (tag_bytes, encrypted) = ciphertext.split_at(RADROOTS_SIMPLEX_SMP_AUTH_TAG_LENGTH); - let tag = Tag::from_slice(tag_bytes); + let stream = simplex_secretbox_stream(shared_secret, nonce, encrypted.len())?; + let (mac_key, mask) = stream.split_at(RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH); + let tag = Poly1305::new(mac_key.into()).compute_unpadded(encrypted); + if tag.as_slice().ct_eq(tag_bytes).unwrap_u8() != 1 { + return Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( + ciphertext.len(), + )); + } let mut buffer = encrypted.to_vec(); - cipher - .decrypt_in_place_detached(&nonce_array(nonce)?.into(), b"", &mut buffer, tag) - .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(ciphertext.len()))?; + xor_in_place(&mut buffer, mask); Ok(buffer) } -fn cipher(shared_secret: &[u8]) -> Result<XSalsa20Poly1305, RadrootsSimplexSmpCryptoError> { +fn simplex_secretbox_stream( + shared_secret: &[u8], + nonce: &[u8], + plaintext_len: usize, +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { if shared_secret.len() != RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH { return Err(RadrootsSimplexSmpCryptoError::InvalidSharedSecretLength( shared_secret.len(), )); } - XSalsa20Poly1305::new_from_slice(shared_secret) - .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidSharedSecretLength(shared_secret.len())) + let nonce: [u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH] = nonce + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidNonceLength(nonce.len()))?; + let first_key = hsalsa::<U10>(shared_secret.into(), (&[0_u8; 16]).into()); + let second_key = hsalsa::<U10>(&first_key, (&nonce[..16]).into()); + let mut cipher = Salsa20::new(&second_key, (&nonce[16..]).into()); + let mut stream = vec![0_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH + plaintext_len]; + cipher.apply_keystream(&mut stream); + Ok(stream) } -fn nonce_array( - nonce: &[u8], -) -> Result<[u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH], RadrootsSimplexSmpCryptoError> { - nonce - .try_into() - .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidNonceLength(nonce.len())) +fn xor_in_place(value: &mut [u8], mask: &[u8]) { + for (byte, mask) in value.iter_mut().zip(mask.iter()) { + *byte ^= mask; + } } fn hkdf_expand( @@ -280,6 +297,17 @@ mod tests { } #[test] + fn encrypt_padded_uses_simplex_transport_padding() { + let key = [7_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let nonce = [11_u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH]; + let ciphertext = encrypt_padded(&key, &nonce, b"hello", 12).unwrap(); + let padded = decrypt_no_pad(&key, &nonce, &ciphertext).unwrap(); + + assert_eq!(&padded[..7], &[0, 5, b'h', b'e', b'l', b'l', b'o']); + assert!(padded[7..].iter().all(|byte| *byte == b'#')); + } + + #[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(); diff --git a/crates/simplex_smp_transport/src/client.rs b/crates/simplex_smp_transport/src/client.rs @@ -147,7 +147,8 @@ fn session_kind_for_command( radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::Sub | radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::Subs | radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::NSub - | radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::NSubs => "subscription", + | radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::NSubs + | radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::Ack(_) => "subscription", radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::Get | radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommand::LGet => "poll", _ => "command", @@ -257,34 +258,40 @@ fn encode_live_transport_block( block: &RadrootsSimplexSmpTransportBlock, ) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { if session.transport_version >= RADROOTS_SIMPLEX_SMP_ENCRYPTED_BLOCK_TRANSPORT_VERSION - && let Some(chain_key) = session.send_chain_key.as_ref() + && let Some(chain_key) = session.send_chain_key.as_mut() { - 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); + return encode_encrypted_transport_payload(chain_key, &block.encode_payload()?); } block.encode() } +fn encode_encrypted_transport_payload( + chain_key: &mut RadrootsSimplexSmpSecretBoxChainKey, + payload: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpTransportError> { + let ((secretbox_key, nonce), next_chain_key) = advance_secretbox_chain(chain_key)?; + *chain_key = next_chain_key; + encrypt_padded( + &secretbox_key, + &nonce, + payload, + RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE - 16, + ) + .map_err(Into::into) +} + fn decode_live_transport_block( session: &mut RadrootsSimplexSmpLiveSession, bytes: &[u8], ) -> Result<RadrootsSimplexSmpTransportBlock, RadrootsSimplexSmpTransportError> { if session.transport_version >= RADROOTS_SIMPLEX_SMP_ENCRYPTED_BLOCK_TRANSPORT_VERSION - && let Some(chain_key) = session.receive_chain_key.as_ref() + && let Some(chain_key) = session.receive_chain_key.as_mut() { - 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); + match decode_encrypted_transport_block(chain_key, bytes) { + Ok(block) => { + let payload = block.encode_payload()?; debug_sha256_label("live-response-payload", &payload); - return RadrootsSimplexSmpTransportBlock::from_payload(&payload); + return Ok(block); } Err(error) => { if transport_debug_enabled() { @@ -292,15 +299,8 @@ fn decode_live_transport_block( 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() - { + let mut alternate_chain_key = send_chain_key.clone(); + if decode_encrypted_transport_block(&mut alternate_chain_key, bytes).is_ok() { return Err(RadrootsSimplexSmpTransportError::InvalidServerAddress( "server response decrypted with the outbound chain key; live SMP block direction is assigned incorrectly".into(), )); @@ -322,6 +322,18 @@ fn decode_live_transport_block( RadrootsSimplexSmpTransportBlock::decode(bytes) } +fn decode_encrypted_transport_block( + chain_key: &mut RadrootsSimplexSmpSecretBoxChainKey, + bytes: &[u8], +) -> Result<RadrootsSimplexSmpTransportBlock, RadrootsSimplexSmpTransportError> { + let ((secretbox_key, nonce), next_chain_key) = advance_secretbox_chain(chain_key)?; + let payload = + radroots_simplex_smp_crypto::prelude::decrypt_padded(&secretbox_key, &nonce, bytes)?; + let block = RadrootsSimplexSmpTransportBlock::from_payload(&payload)?; + *chain_key = next_chain_key; + Ok(block) +} + fn debug_probe_transport_candidates(session: &mut RadrootsSimplexSmpLiveSession, bytes: &[u8]) { if !transport_debug_enabled() { return; @@ -502,7 +514,6 @@ fn connect_live_session_host( 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) = @@ -526,6 +537,14 @@ 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 transport_debug_enabled() { + eprintln!( + "[simplex-smp-transport] signed-server-key: proof_len={} signed_object_len={} signature_len={}", + proof.signed_server_key.len(), + signed_object.len(), + signature.len() + ); + } 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| { @@ -821,10 +840,19 @@ impl ServerCertVerifier for PermissiveSimplexServerVerifier { #[cfg(test)] mod tests { - use super::{canonical_server_identity, decode_server_transport_public_key}; + use super::{ + canonical_server_identity, decode_encrypted_transport_block, + decode_server_transport_public_key, encode_encrypted_transport_payload, + }; use crate::handshake::RadrootsSimplexSmpTransportServerProof; + use crate::prelude::RadrootsSimplexSmpTransportBlock; use radroots_simplex_smp_crypto::prelude::{ - RadrootsSimplexSmpX25519Keypair, encode_x25519_public_key_x509, + RadrootsSimplexSmpX25519Keypair, encode_x25519_public_key_x509, init_secretbox_chain, + }; + use radroots_simplex_smp_proto::prelude::{ + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RadrootsSimplexSmpBrokerMessage, + RadrootsSimplexSmpBrokerTransmission, RadrootsSimplexSmpCommand, + RadrootsSimplexSmpCommandTransmission, RadrootsSimplexSmpCorrelationId, }; #[test] @@ -854,6 +882,92 @@ mod tests { ); } + #[test] + fn encrypted_transport_blocks_use_upstream_client_chain_direction() { + let session_identifier = b"rr-synth-session-id"; + let shared_secret = b"rr-synth-shared-secret"; + let (mut server_send_chain, mut server_receive_chain) = + init_secretbox_chain(session_identifier, shared_secret).unwrap(); + let (client_receive_chain, client_send_chain) = + init_secretbox_chain(session_identifier, shared_secret).unwrap(); + let mut client_receive_chain_for_response = client_receive_chain.clone(); + let mut client_send_chain_for_request = client_send_chain.clone(); + + let command_transmission = RadrootsSimplexSmpCommandTransmission { + authorization: Vec::new(), + correlation_id: Some(RadrootsSimplexSmpCorrelationId::new([3_u8; 24])), + entity_id: b"rr-synth-queue".to_vec(), + command: RadrootsSimplexSmpCommand::Ping, + }; + let command_block = RadrootsSimplexSmpTransportBlock::from_command_transmissions( + &[command_transmission.clone()], + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + ) + .unwrap(); + let encrypted_command = encode_encrypted_transport_payload( + &mut client_send_chain_for_request, + &command_block.encode_payload().unwrap(), + ) + .unwrap(); + assert_eq!( + decode_encrypted_transport_block(&mut server_receive_chain, &encrypted_command) + .unwrap() + .decode_command_transmissions(RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION) + .unwrap(), + vec![command_transmission] + ); + + let broker_transmission = RadrootsSimplexSmpBrokerTransmission { + authorization: Vec::new(), + correlation_id: Some(RadrootsSimplexSmpCorrelationId::new([3_u8; 24])), + entity_id: b"rr-synth-queue".to_vec(), + message: RadrootsSimplexSmpBrokerMessage::Ok, + }; + let broker_block = RadrootsSimplexSmpTransportBlock::from_broker_transmissions( + &[broker_transmission.clone()], + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + ) + .unwrap(); + let encrypted_broker = encode_encrypted_transport_payload( + &mut server_send_chain, + &broker_block.encode_payload().unwrap(), + ) + .unwrap(); + assert_eq!( + decode_encrypted_transport_block( + &mut client_receive_chain_for_response, + &encrypted_broker, + ) + .unwrap() + .decode_broker_transmissions(RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION) + .unwrap(), + vec![broker_transmission] + ); + + let mut wrong_response_chain = client_send_chain; + let wrong_direction_broker = encode_encrypted_transport_payload( + &mut wrong_response_chain, + &broker_block.encode_payload().unwrap(), + ) + .unwrap(); + let mut fresh_client_receive_chain = client_receive_chain; + assert!( + decode_encrypted_transport_block( + &mut fresh_client_receive_chain, + &wrong_direction_broker + ) + .is_err() + ); + } + + #[test] + fn ack_uses_subscription_session_state() { + assert_eq!( + super::session_kind_for_command(&RadrootsSimplexSmpCommand::Ack(b"message".to_vec())), + "subscription" + ); + } + fn der_sequence<'a, I>(elements: I) -> Vec<u8> where I: IntoIterator<Item = &'a [u8]>,