lib

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

commit 9fdd88480b6aa683ddf9ec4213861c8c72ca176e
parent 41188033d11137b831ea25c5ddb7e1ffb2dfefd2
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 14:49:39 +0000

simplex: add live smp auth and tls transport client

Diffstat:
MCargo.lock | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 4++++
Mcrates/simplex-agent-runtime/src/runtime.rs | 148++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/simplex-agent-store/Cargo.toml | 2++
Mcrates/simplex-agent-store/src/store.rs | 101+++++++++++++++++++++++--------------------------------------------------------
Mcrates/simplex-smp-crypto/Cargo.toml | 9++++++++-
Mcrates/simplex-smp-crypto/src/auth.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/simplex-smp-crypto/src/error.rs | 16++++++++++++++++
Mcrates/simplex-smp-crypto/src/lib.rs | 2++
Mcrates/simplex-smp-transport/Cargo.toml | 17++++++++++++++++-
Acrates/simplex-smp-transport/src/client.rs | 373+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/simplex-smp-transport/src/error.rs | 35+++++++++++++++++++++++++++++++++++
Mcrates/simplex-smp-transport/src/executor.rs | 10+++++++---
Mcrates/simplex-smp-transport/src/lib.rs | 4++++
14 files changed, 969 insertions(+), 181 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -140,6 +140,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "async-trait" version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -510,6 +549,12 @@ dependencies = [ ] [[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] name = "const-random" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -633,6 +678,33 @@ dependencies = [ ] [[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "dary_heap" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -667,6 +739,30 @@ dependencies = [ ] [[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -728,6 +824,30 @@ dependencies = [ ] [[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -777,6 +897,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] name = "filetime" version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1774,12 +1900,31 @@ dependencies = [ ] [[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1790,6 +1935,15 @@ dependencies = [ ] [[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1905,6 +2059,16 @@ dependencies = [ ] [[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1966,6 +2130,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2441,6 +2615,7 @@ name = "radroots-simplex-agent-store" version = "0.1.0-alpha.1" dependencies = [ "radroots-simplex-agent-proto", + "radroots-simplex-smp-crypto", "radroots-simplex-smp-proto", "serde", "serde_json", @@ -2475,6 +2650,8 @@ dependencies = [ name = "radroots-simplex-smp-crypto" version = "0.1.0-alpha.1" dependencies = [ + "ed25519-dalek", + "getrandom 0.2.17", "radroots-simplex-smp-proto", "sha2", ] @@ -2490,7 +2667,13 @@ dependencies = [ name = "radroots-simplex-smp-transport" version = "0.1.0-alpha.1" dependencies = [ + "base64 0.22.1", + "radroots-simplex-smp-crypto", "radroots-simplex-smp-proto", + "rcgen", + "rustls", + "sha2", + "x509-parser 0.17.0", ] [[package]] @@ -2636,6 +2819,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + +[[package]] name = "redox_syscall" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2831,6 +3028,15 @@ dependencies = [ ] [[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3127,6 +3333,15 @@ dependencies = [ ] [[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3155,6 +3370,16 @@ dependencies = [ ] [[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] name = "sqlite-wasm-rs" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4364,6 +4589,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] name = "xattr" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4396,6 +4657,15 @@ dependencies = [ ] [[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -93,7 +93,9 @@ chrono = { version = "0.4" } clap = { version = "4" } config = { version = "0.14" } directories = { version = "6" } +ed25519-dalek = { version = "2.1.1", default-features = false } futures = { version = "0.3" } +getrandom = { version = "0.2", default-features = false } hex = { version = "0.4" } js-sys = { version = "0.3" } keyring = { version = "3.6.3", default-features = false, features = [ @@ -115,6 +117,7 @@ serde_json = { version = "1", default-features = false, features = ["alloc"] } serde-wasm-bindgen = { version = "0.6" } sha2 = { version = "0.10", default-features = false } reqwest = { version = "0.12", default-features = false } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } rust_decimal = { version = "1", default-features = false } rust_decimal_macros = { version = "1" } sled = { version = "0.34" } @@ -130,6 +133,7 @@ ts-rs = { version = "11.1" } typeshare = { version = "1" } url = { version = "2" } uuid = { version = "1.22.0", features = ["v4", "v7"] } +x509-parser = { version = "0.17", default-features = false } zstd = { version = "0.13", default-features = false } zeroize = { version = "1" } uniffi = { version = "=0.29.4" } diff --git a/crates/simplex-agent-runtime/src/runtime.rs b/crates/simplex-agent-runtime/src/runtime.rs @@ -18,8 +18,7 @@ use radroots_simplex_agent_store::prelude::{ RadrootsSimplexAgentStore, }; use radroots_simplex_smp_crypto::prelude::{ - RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, - RadrootsSimplexSmpRatchetState, + RadrootsSimplexSmpCommandAuthorization, RadrootsSimplexSmpRatchetState, }; use radroots_simplex_smp_proto::prelude::{ RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, RadrootsSimplexSmpBrokerMessage, @@ -157,6 +156,7 @@ impl RadrootsSimplexAgentRuntime { contact_address, }; self.store.connection_mut(&connection.id)?.invitation = Some(invitation.clone()); + let receive_auth_state = self.store.generate_queue_auth_state()?; let descriptor = RadrootsSimplexAgentQueueDescriptor { queue_uri: invitation_queue, replaced_queue: None, @@ -168,6 +168,7 @@ impl RadrootsSimplexAgentRuntime { descriptor.clone(), RadrootsSimplexAgentQueueRole::Receive, true, + receive_auth_state, )?; self.store.enqueue_command( &connection.id, @@ -222,15 +223,14 @@ impl RadrootsSimplexAgentRuntime { Some(invitation.clone()), ratchet_state, ); + let send_auth_state = self.store.generate_queue_auth_state()?; let send_descriptor = RadrootsSimplexAgentQueueDescriptor { queue_uri: invitation.invitation_queue.clone(), replaced_queue: None, primary: true, - sender_key: Some(derive_material( - b"join-sender-auth", - &[invitation.connection_id.as_slice(), &now.to_be_bytes()], - )), + sender_key: Some(send_auth_state.public_key.clone()), }; + let receive_auth_state = self.store.generate_queue_auth_state()?; let receive_descriptor = RadrootsSimplexAgentQueueDescriptor { queue_uri: reply_queue, replaced_queue: None, @@ -242,12 +242,14 @@ impl RadrootsSimplexAgentRuntime { send_descriptor.clone(), RadrootsSimplexAgentQueueRole::Send, true, + send_auth_state, )?; self.store.add_queue( &connection.id, receive_descriptor.clone(), RadrootsSimplexAgentQueueRole::Receive, true, + receive_auth_state, )?; self.store.enqueue_command( &connection.id, @@ -486,11 +488,15 @@ impl RadrootsSimplexAgentRuntime { } RadrootsSimplexAgentDecryptedMessage::ConnectionInfoReply { reply_queues, info } => { for descriptor in reply_queues { + let auth_state = self.store.generate_queue_auth_state()?; + let mut descriptor = descriptor; + descriptor.sender_key = Some(auth_state.public_key.clone()); self.store.add_queue( connection_id, descriptor, RadrootsSimplexAgentQueueRole::Send, true, + auth_state, )?; } self.store.set_status( @@ -634,11 +640,13 @@ impl RadrootsSimplexAgentRuntime { match &command.kind { RadrootsSimplexAgentPendingCommandKind::RotateQueues { descriptors } => { for descriptor in descriptors.clone() { + let auth_state = self.store.generate_queue_auth_state()?; self.store.add_queue( &command.connection_id, descriptor, RadrootsSimplexAgentQueueRole::Receive, true, + auth_state, )?; } self.record_command_outcome( @@ -697,30 +705,18 @@ impl RadrootsSimplexAgentRuntime { ) })?; let correlation_id = correlation_id_for_command(command.id); - let scope = RadrootsSimplexSmpQueueAuthorizationScope::new( - auth.session_identifier.clone(), - correlation_id, - queue_address.sender_id.clone(), - ) - .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; - let material = RadrootsSimplexSmpQueueAuthorizationMaterial::for_command( - &scope, - &smp_command, - RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, - auth.queue_key_material.clone(), - auth.server_session_key.clone(), - ) - .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; Ok(RadrootsSimplexSmpTransportRequest { server: queue.descriptor.queue_uri.server.clone(), transport_version: RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, - transmission: - radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommandTransmission { - authorization: material.authorized_digest.to_vec(), - correlation_id: Some(correlation_id), - entity_id: queue_address.sender_id, - command: smp_command, + correlation_id: Some(correlation_id), + entity_id: queue_address.sender_id, + command: smp_command, + authorization: RadrootsSimplexSmpCommandAuthorization::Ed25519( + radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpEd25519Keypair { + public_key: auth.public_key, + private_key: auth.private_key, }, + ), }) } @@ -735,34 +731,39 @@ impl RadrootsSimplexAgentRuntime { RadrootsSimplexAgentRuntimeError, > { match &command.kind { - RadrootsSimplexAgentPendingCommandKind::CreateQueue { descriptor } => Ok(( - descriptor.queue_address(), - RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest { - recipient_auth_public_key: descriptor.queue_uri.sender_id.as_bytes().to_vec(), - recipient_dh_public_key: descriptor - .queue_uri - .recipient_dh_public_key - .as_bytes() - .to_vec(), - basic_auth: None, - subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe, - queue_request_data: Some( - match descriptor + RadrootsSimplexAgentPendingCommandKind::CreateQueue { descriptor } => { + let auth_state = self + .store + .queue_auth_state(&command.connection_id, &descriptor.queue_address())?; + Ok(( + descriptor.queue_address(), + RadrootsSimplexSmpCommand::New(RadrootsSimplexSmpNewQueueRequest { + recipient_auth_public_key: auth_state.public_key, + recipient_dh_public_key: descriptor .queue_uri - .queue_mode - .unwrap_or(RadrootsSimplexSmpQueueMode::Messaging) - { - RadrootsSimplexSmpQueueMode::Messaging => { - RadrootsSimplexSmpQueueRequestData::Messaging(None) - } - RadrootsSimplexSmpQueueMode::Contact => { - RadrootsSimplexSmpQueueRequestData::Contact(None) - } - }, - ), - notifier_credentials: None, - }), - )), + .recipient_dh_public_key + .as_bytes() + .to_vec(), + basic_auth: None, + subscription_mode: RadrootsSimplexSmpSubscriptionMode::Subscribe, + queue_request_data: Some( + match descriptor + .queue_uri + .queue_mode + .unwrap_or(RadrootsSimplexSmpQueueMode::Messaging) + { + RadrootsSimplexSmpQueueMode::Messaging => { + RadrootsSimplexSmpQueueRequestData::Messaging(None) + } + RadrootsSimplexSmpQueueMode::Contact => { + RadrootsSimplexSmpQueueRequestData::Contact(None) + } + }, + ), + notifier_credentials: None, + }), + )) + } RadrootsSimplexAgentPendingCommandKind::SecureQueue { queue, sender_key } => Ok(( queue.clone(), RadrootsSimplexSmpCommand::SKey(sender_key.clone().unwrap_or_default()), @@ -923,6 +924,9 @@ fn correlation_id_for_command(command_id: u64) -> RadrootsSimplexSmpCorrelationI mod tests { use super::*; use alloc::collections::VecDeque; + use radroots_simplex_smp_crypto::prelude::{ + RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, + }; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpBrokerTransmission, RadrootsSimplexSmpQueueIdsResponse, }; @@ -964,11 +968,33 @@ mod tests { &mut self, request: RadrootsSimplexSmpTransportRequest, ) -> Result<RadrootsSimplexSmpTransportResponse, Self::Error> { - let block = - RadrootsSimplexSmpTransportBlock::from_current_command_transmissions(&[request - .transmission - .clone()]) - .map_err(|error| error.to_string())?; + let correlation_id = request + .correlation_id + .ok_or_else(|| "missing scripted transport correlation id".to_owned())?; + let scope = RadrootsSimplexSmpQueueAuthorizationScope::new( + b"scripted-session".to_vec(), + correlation_id, + request.entity_id.clone(), + ) + .map_err(|error| error.to_string())?; + let material = RadrootsSimplexSmpQueueAuthorizationMaterial::for_command( + &scope, + &request.command, + request.transport_version, + &request.authorization, + ) + .map_err(|error| error.to_string())?; + let transmission = + radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpCommandTransmission { + authorization: material.authorization, + correlation_id: Some(correlation_id), + entity_id: request.entity_id.clone(), + command: request.command.clone(), + }; + let block = RadrootsSimplexSmpTransportBlock::from_current_command_transmissions(&[ + transmission.clone(), + ]) + .map_err(|error| error.to_string())?; let encoded = block.encode().map_err(|error| error.to_string())?; let decoded = RadrootsSimplexSmpTransportBlock::decode(&encoded) .map_err(|error| error.to_string())?; @@ -976,7 +1002,7 @@ mod tests { .decode_command_transmissions(request.transport_version) .map_err(|error| error.to_string())?; assert_eq!(decoded_transmissions.len(), 1); - assert_eq!(decoded_transmissions[0], request.transmission); + assert_eq!(decoded_transmissions[0], transmission); let response_message = self .responses @@ -984,8 +1010,8 @@ mod tests { .ok_or_else(|| "missing scripted transport response".to_owned())?; let response_transmission = RadrootsSimplexSmpBrokerTransmission { authorization: Vec::new(), - correlation_id: request.transmission.correlation_id, - entity_id: request.transmission.entity_id.clone(), + correlation_id: Some(correlation_id), + entity_id: request.entity_id.clone(), message: response_message, }; let response_block = RadrootsSimplexSmpTransportBlock::from_broker_transmissions( diff --git a/crates/simplex-agent-store/Cargo.toml b/crates/simplex-agent-store/Cargo.toml @@ -17,6 +17,7 @@ readme.workspace = true default = ["std"] std = [ "radroots-simplex-agent-proto/std", + "radroots-simplex-smp-crypto/std", "radroots-simplex-smp-proto/std", "serde/std", "serde_json/std", @@ -25,6 +26,7 @@ std = [ [dependencies] radroots-simplex-agent-proto = { workspace = true, default-features = false } +radroots-simplex-smp-crypto = { workspace = true, default-features = false } radroots-simplex-smp-proto = { workspace = true, default-features = false } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/simplex-agent-store/src/store.rs b/crates/simplex-agent-store/src/store.rs @@ -10,12 +10,12 @@ use radroots_simplex_agent_proto::prelude::{ RadrootsSimplexSmpRatchetState, decode_connection_link, decode_envelope, encode_connection_link, encode_envelope, }; +use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpEd25519Keypair; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpServerAddress, }; #[cfg(feature = "std")] use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; #[cfg(feature = "std")] use std::fs; #[cfg(feature = "std")] @@ -31,9 +31,8 @@ pub enum RadrootsSimplexAgentQueueRole { #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "std", derive(Serialize, Deserialize))] pub struct RadrootsSimplexAgentQueueAuthState { - pub session_identifier: Vec<u8>, - pub queue_key_material: Vec<u8>, - pub server_session_key: Vec<u8>, + pub public_key: Vec<u8>, + pub private_key: Vec<u8>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -400,8 +399,8 @@ impl RadrootsSimplexAgentStore { descriptor: RadrootsSimplexAgentQueueDescriptor, role: RadrootsSimplexAgentQueueRole, primary: bool, + auth_state: RadrootsSimplexAgentQueueAuthState, ) -> Result<(), RadrootsSimplexAgentStoreError> { - let derived_auth = derive_queue_auth_state(connection_id, &descriptor, role); let connection = self.connection_mut(connection_id)?; let address = descriptor.queue_address(); if let Some(queue) = connection @@ -412,9 +411,7 @@ impl RadrootsSimplexAgentStore { queue.descriptor = descriptor; queue.role = role; queue.primary = primary; - if queue.auth_state.is_none() { - queue.auth_state = Some(derived_auth); - } + queue.auth_state = Some(auth_state); return Ok(()); } connection.queues.push(RadrootsSimplexAgentQueueRecord { @@ -423,11 +420,25 @@ impl RadrootsSimplexAgentStore { subscribed: false, primary, tested: false, - auth_state: Some(derived_auth), + auth_state: Some(auth_state), }); Ok(()) } + pub fn generate_queue_auth_state( + &self, + ) -> Result<RadrootsSimplexAgentQueueAuthState, RadrootsSimplexAgentStoreError> { + let keypair = RadrootsSimplexSmpEd25519Keypair::generate().map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to generate SimpleX queue auth keypair: {error}" + )) + })?; + Ok(RadrootsSimplexAgentQueueAuthState { + public_key: keypair.public_key, + private_key: keypair.private_key, + }) + } + pub fn queue_record( &self, connection_id: &str, @@ -745,69 +756,6 @@ impl RadrootsSimplexAgentStore { } } -fn derive_queue_auth_state( - connection_id: &str, - descriptor: &RadrootsSimplexAgentQueueDescriptor, - role: RadrootsSimplexAgentQueueRole, -) -> RadrootsSimplexAgentQueueAuthState { - let address = descriptor.queue_address(); - let role_label = match role { - RadrootsSimplexAgentQueueRole::Receive => b"receive".as_slice(), - RadrootsSimplexAgentQueueRole::Send => b"send".as_slice(), - }; - let session_identifier = hash_material( - b"session", - connection_id, - &address, - role_label, - descriptor.sender_key.as_deref(), - )[..24] - .to_vec(); - let queue_key_material = hash_material( - b"queue-key", - connection_id, - &address, - role_label, - descriptor.sender_key.as_deref(), - ); - let server_session_key = hash_material( - b"server-session", - connection_id, - &address, - role_label, - descriptor.sender_key.as_deref(), - ); - RadrootsSimplexAgentQueueAuthState { - session_identifier, - queue_key_material, - server_session_key, - } -} - -fn hash_material( - label: &[u8], - connection_id: &str, - address: &RadrootsSimplexAgentQueueAddress, - role_label: &[u8], - sender_key: Option<&[u8]>, -) -> Vec<u8> { - let mut hasher = Sha256::new(); - hasher.update(label); - hasher.update(connection_id.as_bytes()); - hasher.update(address.server.server_identity.as_bytes()); - for host in &address.server.hosts { - hasher.update(host.as_bytes()); - hasher.update([0_u8]); - } - hasher.update(address.server.port.unwrap_or_default().to_be_bytes()); - hasher.update(&address.sender_id); - hasher.update(role_label); - if let Some(sender_key) = sender_key { - hasher.update(sender_key); - } - hasher.finalize().to_vec() -} - #[cfg(feature = "std")] fn connection_to_snapshot( record: RadrootsSimplexAgentConnectionRecord, @@ -1272,6 +1220,13 @@ mod tests { } } + fn sample_auth_state() -> RadrootsSimplexAgentQueueAuthState { + RadrootsSimplexAgentQueueAuthState { + public_key: vec![7_u8; 32], + private_key: vec![9_u8; 32], + } + } + #[test] fn stores_connections_queues_and_retryable_commands() { let mut store = RadrootsSimplexAgentStore::new(); @@ -1287,6 +1242,7 @@ mod tests { sample_descriptor(true), RadrootsSimplexAgentQueueRole::Send, true, + sample_auth_state(), ) .unwrap(); let command = store @@ -1367,6 +1323,7 @@ mod tests { sample_descriptor(true), RadrootsSimplexAgentQueueRole::Send, true, + sample_auth_state(), ) .unwrap(); let prepared = store diff --git a/crates/simplex-smp-crypto/Cargo.toml b/crates/simplex-smp-crypto/Cargo.toml @@ -15,8 +15,15 @@ readme.workspace = true [features] default = ["std"] -std = ["radroots-simplex-smp-proto/std", "sha2/std"] +std = [ + "ed25519-dalek/std", + "getrandom/std", + "radroots-simplex-smp-proto/std", + "sha2/std", +] [dependencies] +ed25519-dalek = { workspace = true, default-features = false, features = ["alloc"] } +getrandom = { workspace = true, default-features = false } radroots-simplex-smp-proto = { workspace = true, default-features = false } sha2 = { workspace = true, default-features = false } diff --git a/crates/simplex-smp-crypto/src/auth.rs b/crates/simplex-smp-crypto/src/auth.rs @@ -1,9 +1,53 @@ use crate::error::RadrootsSimplexSmpCryptoError; use alloc::vec::Vec; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpBrokerMessage, RadrootsSimplexSmpCommand, RadrootsSimplexSmpCorrelationId, }; -use sha2::{Digest, Sha512}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpEd25519Keypair { + pub public_key: Vec<u8>, + pub private_key: Vec<u8>, +} + +impl RadrootsSimplexSmpEd25519Keypair { + pub fn generate() -> Result<Self, RadrootsSimplexSmpCryptoError> { + let mut secret = [0_u8; 32]; + getrandom::getrandom(&mut secret) + .map_err(|_| RadrootsSimplexSmpCryptoError::EntropyUnavailable)?; + let signing_key = SigningKey::from_bytes(&secret); + Ok(Self { + public_key: signing_key.verifying_key().to_bytes().to_vec(), + private_key: secret.to_vec(), + }) + } + + pub fn signing_key(&self) -> Result<SigningKey, RadrootsSimplexSmpCryptoError> { + let bytes: [u8; 32] = self.private_key.as_slice().try_into().map_err(|_| { + RadrootsSimplexSmpCryptoError::InvalidPrivateKeyLength(self.private_key.len()) + })?; + Ok(SigningKey::from_bytes(&bytes)) + } + + pub fn verifying_key(&self) -> Result<VerifyingKey, RadrootsSimplexSmpCryptoError> { + verifying_key_from_bytes(&self.public_key) + } + + pub fn verify( + &self, + payload: &[u8], + signature: &[u8], + ) -> Result<(), RadrootsSimplexSmpCryptoError> { + verify_signature(payload, &self.public_key, signature) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexSmpCommandAuthorization { + None, + Ed25519(RadrootsSimplexSmpEd25519Keypair), +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpQueueAuthorizationScope { @@ -61,10 +105,7 @@ impl RadrootsSimplexSmpQueueAuthorizationScope { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpQueueAuthorizationMaterial { pub authorized_body: Vec<u8>, - pub authorized_digest: [u8; 64], - pub nonce: [u8; 24], - pub queue_key_material: Vec<u8>, - pub server_session_key: Vec<u8>, + pub authorization: Vec<u8>, } impl RadrootsSimplexSmpQueueAuthorizationMaterial { @@ -72,54 +113,65 @@ impl RadrootsSimplexSmpQueueAuthorizationMaterial { scope: &RadrootsSimplexSmpQueueAuthorizationScope, command: &RadrootsSimplexSmpCommand, transport_version: u16, - queue_key_material: Vec<u8>, - server_session_key: Vec<u8>, + authorization: &RadrootsSimplexSmpCommandAuthorization, ) -> Result<Self, RadrootsSimplexSmpCryptoError> { let authorized_body = scope.authorized_command_body(command, transport_version)?; - Ok(Self::new( - authorized_body, - scope.correlation_id, - queue_key_material, - server_session_key, - )) + Self::new(authorized_body, authorization) } pub fn for_broker_message( scope: &RadrootsSimplexSmpQueueAuthorizationScope, message: &RadrootsSimplexSmpBrokerMessage, transport_version: u16, - queue_key_material: Vec<u8>, - server_session_key: Vec<u8>, + authorization: &RadrootsSimplexSmpCommandAuthorization, ) -> Result<Self, RadrootsSimplexSmpCryptoError> { let authorized_body = scope.authorized_broker_body(message, transport_version)?; - Ok(Self::new( - authorized_body, - scope.correlation_id, - queue_key_material, - server_session_key, - )) + Self::new(authorized_body, authorization) } fn new( authorized_body: Vec<u8>, - correlation_id: RadrootsSimplexSmpCorrelationId, - queue_key_material: Vec<u8>, - server_session_key: Vec<u8>, - ) -> Self { - let digest = Sha512::digest(&authorized_body); - let mut authorized_digest = [0_u8; 64]; - authorized_digest.copy_from_slice(&digest); - - Self { + authorization: &RadrootsSimplexSmpCommandAuthorization, + ) -> Result<Self, RadrootsSimplexSmpCryptoError> { + let authorization = match authorization { + RadrootsSimplexSmpCommandAuthorization::None => Vec::new(), + RadrootsSimplexSmpCommandAuthorization::Ed25519(keypair) => { + let signing_key = keypair.signing_key()?; + let signature = signing_key.sign(&authorized_body); + signature.to_bytes().to_vec() + } + }; + Ok(Self { authorized_body, - authorized_digest, - nonce: *correlation_id.as_bytes(), - queue_key_material, - server_session_key, - } + authorization, + }) } } +pub fn verify_signature( + payload: &[u8], + public_key: &[u8], + signature: &[u8], +) -> Result<(), RadrootsSimplexSmpCryptoError> { + let verifying_key = verifying_key_from_bytes(public_key)?; + let signature: [u8; 64] = signature + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidSignatureLength(signature.len()))?; + verifying_key + .verify(payload, &Signature::from_bytes(&signature)) + .map_err(|_| RadrootsSimplexSmpCryptoError::SignatureVerificationFailed) +} + +fn verifying_key_from_bytes( + public_key: &[u8], +) -> Result<VerifyingKey, RadrootsSimplexSmpCryptoError> { + let bytes: [u8; 32] = public_key + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(public_key.len()))?; + VerifyingKey::from_bytes(&bytes) + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(public_key.len())) +} + fn validate_short_field(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { if value.len() > u8::MAX as usize { return Err(RadrootsSimplexSmpCryptoError::InvalidShortFieldLength( @@ -147,26 +199,47 @@ mod tests { }; #[test] - fn builds_authorization_material_for_command_scope() { + fn builds_ed25519_authorization_for_command_scope() { let scope = RadrootsSimplexSmpQueueAuthorizationScope::new( - b"tls-unique".to_vec(), + b"tls-session".to_vec(), RadrootsSimplexSmpCorrelationId::new([5_u8; 24]), b"queue-id".to_vec(), ) .unwrap(); + let keypair = RadrootsSimplexSmpEd25519Keypair::generate().unwrap(); + + let material = RadrootsSimplexSmpQueueAuthorizationMaterial::for_command( + &scope, + &RadrootsSimplexSmpCommand::Ping, + RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, + &RadrootsSimplexSmpCommandAuthorization::Ed25519(keypair.clone()), + ) + .unwrap(); + + assert_eq!(material.authorized_body[0], b"tls-session".len() as u8); + assert_eq!(material.authorization.len(), 64); + keypair + .verify(&material.authorized_body, &material.authorization) + .unwrap(); + } + + #[test] + fn leaves_unsigned_authorization_empty() { + let scope = RadrootsSimplexSmpQueueAuthorizationScope::new( + b"tls-session".to_vec(), + RadrootsSimplexSmpCorrelationId::new([3_u8; 24]), + Vec::new(), + ) + .unwrap(); let material = RadrootsSimplexSmpQueueAuthorizationMaterial::for_command( &scope, &RadrootsSimplexSmpCommand::Ping, RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, - b"queue-private".to_vec(), - b"server-session".to_vec(), + &RadrootsSimplexSmpCommandAuthorization::None, ) .unwrap(); - assert_eq!(material.nonce, [5_u8; 24]); - assert_eq!(material.authorized_body[0], b"tls-unique".len() as u8); - assert_eq!(material.authorized_body[11], 24); - assert_eq!(material.authorized_digest.len(), 64); + assert!(material.authorization.is_empty()); } } diff --git a/crates/simplex-smp-crypto/src/error.rs b/crates/simplex-smp-crypto/src/error.rs @@ -6,12 +6,16 @@ use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError; pub enum RadrootsSimplexSmpCryptoError { Proto(RadrootsSimplexSmpProtoError), InvalidShortFieldLength(usize), + EntropyUnavailable, MissingRatchetKey(&'static str), IncompletePqHeader, RatchetMessageRegression { received: u32, current: u32 }, InvalidSharedSecretLength(usize), InvalidCiphertextLength(usize), InvalidPublicKeyLength(usize), + InvalidPrivateKeyLength(usize), + InvalidSignatureLength(usize), + SignatureVerificationFailed, InvalidSessionIdentifier(String), } @@ -28,6 +32,9 @@ impl fmt::Display for RadrootsSimplexSmpCryptoError { Self::InvalidShortFieldLength(length) => { write!(f, "invalid SMP short field length {length}") } + Self::EntropyUnavailable => { + write!(f, "unable to obtain entropy for SimpleX SMP key generation") + } Self::MissingRatchetKey(field) => write!(f, "missing SMP ratchet key `{field}`"), Self::IncompletePqHeader => { write!( @@ -50,6 +57,15 @@ impl fmt::Display for RadrootsSimplexSmpCryptoError { Self::InvalidPublicKeyLength(length) => { write!(f, "invalid SMP public key length {length}") } + Self::InvalidPrivateKeyLength(length) => { + write!(f, "invalid SMP private key length {length}") + } + Self::InvalidSignatureLength(length) => { + write!(f, "invalid SMP signature length {length}") + } + Self::SignatureVerificationFailed => { + write!(f, "failed to verify SMP signature") + } Self::InvalidSessionIdentifier(value) => { write!(f, "invalid SMP session identifier `{value}`") } diff --git a/crates/simplex-smp-crypto/src/lib.rs b/crates/simplex-smp-crypto/src/lib.rs @@ -9,7 +9,9 @@ pub mod ratchet; pub mod prelude { pub use crate::auth::{ + RadrootsSimplexSmpCommandAuthorization, RadrootsSimplexSmpEd25519Keypair, RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, + verify_signature, }; pub use crate::error::RadrootsSimplexSmpCryptoError; pub use crate::ratchet::{ diff --git a/crates/simplex-smp-transport/Cargo.toml b/crates/simplex-smp-transport/Cargo.toml @@ -15,7 +15,22 @@ readme.workspace = true [features] default = ["std"] -std = ["radroots-simplex-smp-proto/std"] +std = [ + "base64/std", + "radroots-simplex-smp-crypto/std", + "radroots-simplex-smp-proto/std", + "rustls/std", + "sha2/std", + "x509-parser/verify", +] [dependencies] +base64 = { workspace = true } +radroots-simplex-smp-crypto = { workspace = true, default-features = false } radroots-simplex-smp-proto = { workspace = true, default-features = false } +rustls = { workspace = true, default-features = false } +sha2 = { workspace = true, default-features = false } +x509-parser = { workspace = true, default-features = false } + +[dev-dependencies] +rcgen = { version = "0.14" } diff --git a/crates/simplex-smp-transport/src/client.rs b/crates/simplex-smp-transport/src/client.rs @@ -0,0 +1,373 @@ +#![cfg(feature = "std")] + +use crate::error::RadrootsSimplexSmpTransportError; +use crate::executor::{ + RadrootsSimplexSmpCommandTransport, RadrootsSimplexSmpTransportRequest, + RadrootsSimplexSmpTransportResponse, +}; +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, +}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use radroots_simplex_smp_crypto::prelude::{ + RadrootsSimplexSmpQueueAuthorizationMaterial, RadrootsSimplexSmpQueueAuthorizationScope, +}; +use radroots_simplex_smp_proto::prelude::{ + RadrootsSimplexSmpCommandTransmission, RadrootsSimplexSmpServerAddress, +}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ + ClientConfig, ClientConnection, DigitallySignedStruct, Error as RustlsError, SignatureScheme, + StreamOwned, +}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::net::{IpAddr, TcpStream, ToSocketAddrs}; +use std::sync::Arc; +use std::time::Duration; +use x509_parser::prelude::FromDer; + +#[derive(Default)] +pub struct RadrootsSimplexSmpTlsCommandTransport { + sessions: BTreeMap<String, RadrootsSimplexSmpLiveSession>, +} + +struct RadrootsSimplexSmpLiveSession { + stream: StreamOwned<ClientConnection, TcpStream>, + transport_version: u16, + session_identifier: Vec<u8>, +} + +impl RadrootsSimplexSmpTlsCommandTransport { + pub fn new() -> Self { + Self::default() + } + + fn session_key(server: &RadrootsSimplexSmpServerAddress) -> String { + let mut key = server.server_identity.clone(); + key.push('@'); + key.push_str(&server.hosts.join(",")); + key.push(':'); + key.push_str(&server.port.unwrap_or(5223).to_string()); + key + } + + fn session_for( + &mut self, + server: &RadrootsSimplexSmpServerAddress, + ) -> Result<&mut RadrootsSimplexSmpLiveSession, RadrootsSimplexSmpTransportError> { + let key = Self::session_key(server); + if !self.sessions.contains_key(&key) { + let session = connect_live_session(server)?; + self.sessions.insert(key.clone(), session); + } + self.sessions.get_mut(&key).ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "missing live SMP session for `{}`", + server.server_identity + )) + }) + } +} + +impl RadrootsSimplexSmpCommandTransport for RadrootsSimplexSmpTlsCommandTransport { + type Error = RadrootsSimplexSmpTransportError; + + fn execute( + &mut self, + request: RadrootsSimplexSmpTransportRequest, + ) -> Result<RadrootsSimplexSmpTransportResponse, Self::Error> { + let key = Self::session_key(&request.server); + match execute_live_request(self.session_for(&request.server)?, &request) { + Ok(response) => Ok(response), + Err(RadrootsSimplexSmpTransportError::LiveTransportIo(error)) => { + self.sessions.remove(&key); + let response = execute_live_request(self.session_for(&request.server)?, &request); + match response { + Ok(response) => Ok(response), + Err(RadrootsSimplexSmpTransportError::LiveTransportIo(_)) => { + Err(RadrootsSimplexSmpTransportError::LiveTransportIo(error)) + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + } + } +} + +fn execute_live_request( + session: &mut RadrootsSimplexSmpLiveSession, + request: &RadrootsSimplexSmpTransportRequest, +) -> Result<RadrootsSimplexSmpTransportResponse, RadrootsSimplexSmpTransportError> { + let correlation_id = request + .correlation_id + .ok_or(RadrootsSimplexSmpTransportError::MissingCorrelationId)?; + let scope = RadrootsSimplexSmpQueueAuthorizationScope::new( + session.session_identifier.clone(), + correlation_id, + request.entity_id.clone(), + )?; + let material = RadrootsSimplexSmpQueueAuthorizationMaterial::for_command( + &scope, + &request.command, + session.transport_version, + &request.authorization, + )?; + let transmission = RadrootsSimplexSmpCommandTransmission { + authorization: material.authorization, + correlation_id: Some(correlation_id), + entity_id: request.entity_id.clone(), + command: request.command.clone(), + }; + let block = RadrootsSimplexSmpTransportBlock::from_command_transmissions( + &[transmission], + session.transport_version, + )?; + let encoded = block.encode()?; + session + .stream + .write_all(&encoded) + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + session + .stream + .flush() + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + + let mut response_block = vec![0_u8; RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE]; + session + .stream + .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 transmissions = decoded.decode_broker_transmissions(session.transport_version)?; + if transmissions.len() != 1 { + return Err( + RadrootsSimplexSmpTransportError::UnexpectedBrokerTransmissionCount( + transmissions.len(), + ), + ); + } + let transmission = transmissions.into_iter().next().expect("checked len"); + if transmission.correlation_id != Some(correlation_id) { + return Err(RadrootsSimplexSmpTransportError::CorrelationIdMismatch); + } + Ok(RadrootsSimplexSmpTransportResponse { + server: request.server.clone(), + transport_version: session.transport_version, + transmission, + transport_hash: response_hash, + }) +} + +fn connect_live_session( + server: &RadrootsSimplexSmpServerAddress, +) -> Result<RadrootsSimplexSmpLiveSession, RadrootsSimplexSmpTransportError> { + let mut last_error = None; + for host in &server.hosts { + match connect_live_session_host(server, host) { + Ok(session) => return Ok(session), + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "SMP server `{}` has no usable hosts", + server.server_identity + )) + })) +} + +fn connect_live_session_host( + server: &RadrootsSimplexSmpServerAddress, + host: &str, +) -> Result<RadrootsSimplexSmpLiveSession, RadrootsSimplexSmpTransportError> { + let port = server.port.unwrap_or(5223); + let mut addresses = (host, port).to_socket_addrs().map_err(|error| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "failed to resolve SMP server host `{host}:{port}`: {error}" + )) + })?; + let socket_addr = addresses.next().ok_or_else(|| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "failed to resolve SMP server host `{host}:{port}`" + )) + })?; + let tcp = + TcpStream::connect_timeout(&socket_addr, Duration::from_secs(5)).map_err(|error| { + RadrootsSimplexSmpTransportError::LiveTransportIo(format!( + "failed to connect to SMP server `{host}:{port}`: {error}" + )) + })?; + tcp.set_nodelay(true) + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + tcp.set_read_timeout(Some(Duration::from_secs(5))) + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + tcp.set_write_timeout(Some(Duration::from_secs(5))) + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + + let server_name = match host.parse::<IpAddr>() { + Ok(address) => ServerName::IpAddress(address.into()), + Err(_) => ServerName::try_from(host.to_owned()).map_err(|_| { + RadrootsSimplexSmpTransportError::InvalidServerAddress(format!( + "invalid SMP server name `{host}`" + )) + })?, + }; + let verifier = Arc::new(PermissiveSimplexServerVerifier); + let mut config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + config.alpn_protocols = vec![RADROOTS_SIMPLEX_SMP_TLS_ALPN_V1.as_bytes().to_vec()]; + + let mut stream = StreamOwned::new( + ClientConnection::new(Arc::new(config), server_name).map_err(|error| { + RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()) + })?, + tcp, + ); + while stream.conn.is_handshaking() { + stream.conn.complete_io(&mut stream.sock).map_err(|error| { + RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()) + })?; + } + + let peer_certs = stream + .conn + .peer_certificates() + .ok_or(RadrootsSimplexSmpTransportError::MissingPeerCertificates)? + .to_vec(); + let server_hello = read_server_hello(&mut stream)?; + let actual_identity = matching_server_identity(&peer_certs, &server.server_identity)?; + let mut policy = RadrootsSimplexSmpTlsPolicy::modern(server.server_identity.clone()); + policy.require_tls_unique_binding = false; + let transport_version = validate_tls_handshake( + &policy, + &server_hello, + &RadrootsSimplexSmpTlsHandshakeEvidence { + confirmed_alpn: stream + .conn + .alpn_protocol() + .map(|value| String::from_utf8_lossy(value).into_owned()), + session_resumed: false, + certificate_chain_length: peer_certs.len(), + online_certificate_fingerprint: actual_identity, + tls_unique_channel_binding: None, + }, + )?; + let client_hello = RadrootsSimplexSmpClientHello { + chosen_version: transport_version, + client_key: None, + ignored_part: Vec::new(), + }; + let encoded_client_hello = client_hello.encode()?; + stream + .write_all(&encoded_client_hello) + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + stream + .flush() + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + + Ok(RadrootsSimplexSmpLiveSession { + stream, + transport_version, + session_identifier: server_hello.session_identifier, + }) +} + +fn read_server_hello( + stream: &mut StreamOwned<ClientConnection, TcpStream>, +) -> Result<RadrootsSimplexSmpServerHello, RadrootsSimplexSmpTransportError> { + let mut block = vec![0_u8; RADROOTS_SIMPLEX_SMP_TRANSPORT_BLOCK_SIZE]; + stream + .read_exact(&mut block) + .map_err(|error| RadrootsSimplexSmpTransportError::LiveTransportIo(error.to_string()))?; + RadrootsSimplexSmpServerHello::decode(&block) +} + +fn matching_server_identity( + chain: &[CertificateDer<'static>], + expected_identity: &str, +) -> Result<String, RadrootsSimplexSmpTransportError> { + for certificate in chain { + let identity = server_identity_from_certificate(certificate.as_ref())?; + if identity == expected_identity { + return Ok(identity); + } + } + Err(RadrootsSimplexSmpTransportError::ServerIdentityMismatch { + expected: expected_identity.into(), + actual: chain + .first() + .map(|certificate| server_identity_from_certificate(certificate.as_ref())) + .transpose()? + .unwrap_or_default(), + }) +} + +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); + Ok(URL_SAFE_NO_PAD.encode(digest)) +} + +#[derive(Debug)] +struct PermissiveSimplexServerVerifier; + +impl ServerCertVerifier for PermissiveSimplexServerVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result<ServerCertVerified, RustlsError> { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result<HandshakeSignatureValid, RustlsError> { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result<HandshakeSignatureValid, RustlsError> { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec<SignatureScheme> { + vec![ + SignatureScheme::ED25519, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + ] + } +} diff --git a/crates/simplex-smp-transport/src/error.rs b/crates/simplex-smp-transport/src/error.rs @@ -1,10 +1,12 @@ use alloc::string::String; use core::fmt; +use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpCryptoError; use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpProtoError; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RadrootsSimplexSmpTransportError { Proto(RadrootsSimplexSmpProtoError), + Crypto(RadrootsSimplexSmpCryptoError), InvalidPaddedBlockLength { expected: usize, actual: usize }, TransportPayloadTooLarge(usize), EmptyTransportBlock, @@ -23,6 +25,12 @@ pub enum RadrootsSimplexSmpTransportError { MissingChannelBinding, SessionBindingMismatch, NoMutualTransportVersion { offered: String, supported: String }, + MissingCorrelationId, + InvalidServerAddress(String), + LiveTransportIo(String), + MissingPeerCertificates, + UnexpectedBrokerTransmissionCount(usize), + CorrelationIdMismatch, } impl From<RadrootsSimplexSmpProtoError> for RadrootsSimplexSmpTransportError { @@ -31,10 +39,17 @@ impl From<RadrootsSimplexSmpProtoError> for RadrootsSimplexSmpTransportError { } } +impl From<RadrootsSimplexSmpCryptoError> for RadrootsSimplexSmpTransportError { + fn from(value: RadrootsSimplexSmpCryptoError) -> Self { + Self::Crypto(value) + } +} + impl fmt::Display for RadrootsSimplexSmpTransportError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Proto(error) => write!(f, "{error}"), + Self::Crypto(error) => write!(f, "{error}"), Self::InvalidPaddedBlockLength { expected, actual } => { write!( f, @@ -99,6 +114,26 @@ impl fmt::Display for RadrootsSimplexSmpTransportError { "no mutual SMP transport version between `{offered}` and `{supported}`" ) } + Self::MissingCorrelationId => { + write!(f, "SMP transport request is missing a correlation id") + } + Self::InvalidServerAddress(message) => write!(f, "{message}"), + Self::LiveTransportIo(message) => write!(f, "{message}"), + Self::MissingPeerCertificates => { + write!(f, "SMP TLS peer certificate chain is missing") + } + Self::UnexpectedBrokerTransmissionCount(count) => { + write!( + f, + "expected exactly one SMP broker transmission, got {count}" + ) + } + Self::CorrelationIdMismatch => { + write!( + f, + "SMP broker response correlation id did not match the request" + ) + } } } } diff --git a/crates/simplex-smp-transport/src/executor.rs b/crates/simplex-smp-transport/src/executor.rs @@ -1,14 +1,18 @@ use alloc::vec::Vec; +use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpCommandAuthorization; use radroots_simplex_smp_proto::prelude::{ - RadrootsSimplexSmpBrokerTransmission, RadrootsSimplexSmpCommandTransmission, - RadrootsSimplexSmpServerAddress, + RadrootsSimplexSmpBrokerTransmission, RadrootsSimplexSmpCommand, + RadrootsSimplexSmpCorrelationId, RadrootsSimplexSmpServerAddress, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpTransportRequest { pub server: RadrootsSimplexSmpServerAddress, pub transport_version: u16, - pub transmission: RadrootsSimplexSmpCommandTransmission, + pub correlation_id: Option<RadrootsSimplexSmpCorrelationId>, + pub entity_id: Vec<u8>, + pub command: RadrootsSimplexSmpCommand, + pub authorization: RadrootsSimplexSmpCommandAuthorization, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/simplex-smp-transport/src/lib.rs b/crates/simplex-smp-transport/src/lib.rs @@ -3,12 +3,16 @@ extern crate alloc; +#[cfg(feature = "std")] +pub mod client; pub mod error; pub mod executor; pub mod frame; pub mod handshake; pub mod prelude { + #[cfg(feature = "std")] + pub use crate::client::RadrootsSimplexSmpTlsCommandTransport; pub use crate::error::RadrootsSimplexSmpTransportError; pub use crate::executor::{ RadrootsSimplexSmpCommandTransport, RadrootsSimplexSmpTransportRequest,