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