diff --git a/crates/modbussim-app/src/commands.rs b/crates/modbussim-app/src/commands.rs index 348c4da..2b616cc 100644 --- a/crates/modbussim-app/src/commands.rs +++ b/crates/modbussim-app/src/commands.rs @@ -289,10 +289,10 @@ pub async fn start_slave_connection( ); })); - conn.connection - .start() - .await - .map_err(|e| format!("failed to start: {}", e))?; + conn.connection.start().await.map_err(|e| { + log::error!("Failed to start slave connection {id}: {e}"); + format!("failed to start: {e}") + })?; state_str = format!("{:?}", conn.connection.state()); } diff --git a/crates/modbussim-app/src/lib.rs b/crates/modbussim-app/src/lib.rs index b114a39..c199449 100644 --- a/crates/modbussim-app/src/lib.rs +++ b/crates/modbussim-app/src/lib.rs @@ -92,13 +92,12 @@ pub fn run() { ]) .plugin(tauri_plugin_dialog::init()) .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } + // Keep transport startup diagnostics available in installed builds. + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; analytics::track_started(app.handle()); let update_app = app.handle().clone(); diff --git a/crates/modbussim-core/Cargo.toml b/crates/modbussim-core/Cargo.toml index 648157b..0fc1799 100644 --- a/crates/modbussim-core/Cargo.toml +++ b/crates/modbussim-core/Cargo.toml @@ -17,10 +17,11 @@ serialport = "4" tokio-serial = "5.4" native-tls = "0.2" tokio-native-tls = "0.3" +# Parse slave identities and perform TLS in memory, without macOS Keychain. +# Bundle OpenSSL so users do not need a system installation (including Windows). +openssl = { version = "0.10", features = ["vendored"] } [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } tempfile = "3" rcgen = "0.13" -# vendored: 编译 OpenSSL 源码,避免 Windows MSVC CI 上找不到系统 OpenSSL -openssl = { version = "0.10", features = ["vendored"] } diff --git a/crates/modbussim-core/src/slave.rs b/crates/modbussim-core/src/slave.rs index 5b59013..d81e78d 100644 --- a/crates/modbussim-core/src/slave.rs +++ b/crates/modbussim-core/src/slave.rs @@ -281,7 +281,7 @@ impl SlaveConnection { } /// Start the server. Idempotent: returns Ok if already running. Errors - /// only on bind/transport failure. + /// on bind/transport failure or invalid TLS certificates. pub async fn start(&mut self) -> Result<(), SlaveError> { if self.state == ConnectionState::Running { return Ok(()); @@ -397,10 +397,21 @@ impl SlaveConnection { *port, ); let tls_config = self.tls_config.clone(); + // Validate certificates before reporting Running, and keep the + // bound socket so startup failures reach the caller immediately. + let acceptor = tokio::task::spawn_blocking(move || { + crate::tls_slave::build_tls_acceptor(&tls_config) + }) + .await + .map_err(|e| SlaveError::TlsError(format!("TLS initialization failed: {e}")))? + .map_err(SlaveError::CertError)?; + let listener = TcpListener::bind(addr) + .await + .map_err(|e| SlaveError::BindError(format!("Failed to bind {addr}: {e}")))?; tokio::spawn(async move { if let Err(e) = crate::tls_slave::run_tls_slave( - addr, - tls_config, + listener, + acceptor, devices, log_collector, change_callback, diff --git a/crates/modbussim-core/src/tls_slave.rs b/crates/modbussim-core/src/tls_slave.rs index 1b3b48b..2ef39a4 100644 --- a/crates/modbussim-core/src/tls_slave.rs +++ b/crates/modbussim-core/src/tls_slave.rs @@ -1,7 +1,7 @@ //! TLS-enabled Modbus TCP slave server. //! //! Binds a TCP listener (tokio), accepts connections, performs a TLS handshake -//! via `native_tls`, and then handles MBAP-framed Modbus requests on each +//! via OpenSSL (in-memory keys, no OS keychain), then handles MBAP requests on each //! connection in a blocking thread. use crate::clients::ConnectedClients; @@ -13,7 +13,12 @@ use crate::rtu_slave::{ }; use crate::slave::{SharedChangeCallback, SharedDevices, SharedLogCollector}; use crate::transport::SlaveTlsConfig; -use native_tls::{Identity, Protocol, TlsAcceptor}; +use openssl::pkcs12::Pkcs12; +use openssl::pkey::PKey; +use openssl::provider::Provider; +use openssl::ssl::{SslAcceptor, SslMethod, SslStream, SslVerifyMode, SslVersion}; +use openssl::stack::Stack; +use openssl::x509::X509; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -25,60 +30,130 @@ use tokio::sync::oneshot; // TLS acceptor construction // --------------------------------------------------------------------------- -/// Build a `native_tls::TlsAcceptor` from a `SlaveTlsConfig`. +/// Build an in-memory TLS acceptor without importing private keys into Keychain. /// /// Loads identity from PKCS#12 (priority) or PEM cert+key, sets min TLS -/// version to 1.2, and returns the acceptor. -pub fn build_tls_acceptor(config: &SlaveTlsConfig) -> Result { - let identity = if !config.pkcs12_file.is_empty() { +/// version to 1.2, and enforces client authentication against the configured CA. +pub fn build_tls_acceptor(config: &SlaveTlsConfig) -> Result { + let (cert, key, chain) = if !config.pkcs12_file.is_empty() { // Load PKCS#12 identity. let pkcs12_bytes = std::fs::read(&config.pkcs12_file) .map_err(|e| format!("Failed to read PKCS#12 file '{}': {e}", config.pkcs12_file))?; - Identity::from_pkcs12(&pkcs12_bytes, &config.pkcs12_password) - .map_err(|e| format!("Failed to parse PKCS#12: {e}"))? + let p12 = + Pkcs12::from_der(&pkcs12_bytes).map_err(|e| format!("Failed to parse PKCS#12: {e}"))?; + let identity = p12 + .parse2(&config.pkcs12_password) + .or_else(|original| { + // Older P12 exports use RC2/3DES. Enable their decoder only + // while importing, retaining OpenSSL's default algorithms. + // The TLS cipher allowlist below stays unchanged. + let _legacy = Provider::try_load(None, "legacy", true).map_err(|_| original)?; + p12.parse2(&config.pkcs12_password) + }) + .map_err(|e| format!("Failed to parse PKCS#12: {e}"))?; + ( + identity.cert.ok_or("PKCS#12 contains no certificate")?, + identity.pkey.ok_or("PKCS#12 contains no private key")?, + identity + .ca + .map(|chain| chain.into_iter().collect()) + .unwrap_or_default(), + ) } else if !config.cert_file.is_empty() && !config.key_file.is_empty() { // Load PEM cert + key. let cert_bytes = std::fs::read(&config.cert_file) .map_err(|e| format!("Failed to read cert file '{}': {e}", config.cert_file))?; let key_bytes = std::fs::read(&config.key_file) .map_err(|e| format!("Failed to read key file '{}': {e}", config.key_file))?; - Identity::from_pkcs8(&cert_bytes, &key_bytes) - .map_err(|e| format!("Failed to create identity from PEM: {e}"))? + let mut certs = X509::stack_from_pem(&cert_bytes) + .map_err(|e| format!("Failed to parse PEM certificates: {e}"))? + .into_iter(); + let cert = certs.next().ok_or("PEM file contains no certificate")?; + // Supply an explicit passphrase to prevent OpenSSL's default interactive + // password prompt. The existing PEM configuration has no password field. + let key = PKey::private_key_from_pem_passphrase(&key_bytes, b"") + .map_err(|e| format!("Failed to parse PEM private key (use an unencrypted key or password-protected PKCS#12): {e}"))?; + (cert, key, certs.collect::>()) } else { return Err("No certificate configured: set pkcs12_file or cert_file+key_file".into()); }; - let acceptor = TlsAcceptor::builder(identity) - .min_protocol_version(Some(Protocol::Tlsv12)) - .build() + let mut acceptor = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls_server()) .map_err(|e| format!("Failed to build TLS acceptor: {e}"))?; + acceptor + .set_min_proto_version(Some(SslVersion::TLS1_2)) + .map_err(|e| format!("Failed to set TLS minimum version: {e}"))?; + acceptor + .set_certificate(&cert) + .map_err(|e| format!("Failed to set server certificate: {e}"))?; + acceptor + .set_private_key(&key) + .map_err(|e| format!("Failed to set server private key: {e}"))?; + acceptor + .check_private_key() + .map_err(|e| format!("Server certificate and private key do not match: {e}"))?; + for cert in chain { + acceptor + .add_extra_chain_cert(cert) + .map_err(|e| format!("Failed to set server certificate chain: {e}"))?; + } + + if config.require_client_cert { + if config.ca_file.is_empty() { + return Err("Client certificate authentication requires a CA file".into()); + } + let ca_bytes = std::fs::read(&config.ca_file) + .map_err(|e| format!("Failed to read CA file '{}': {e}", config.ca_file))?; + let ca_certs = X509::stack_from_pem(&ca_bytes) + .map_err(|e| format!("Failed to parse CA certificates: {e}"))?; + if ca_certs.is_empty() { + return Err("CA file contains no certificates".into()); + } + let mut ca_names = + Stack::new().map_err(|e| format!("Failed to create client CA list: {e}"))?; + for ca in ca_certs { + let name = ca + .subject_name() + .to_owned() + .map_err(|e| format!("Failed to read CA subject: {e}"))?; + ca_names + .push(name) + .map_err(|e| format!("Failed to add client CA name: {e}"))?; + acceptor + .cert_store_mut() + .add_cert(ca) + .map_err(|e| format!("Failed to add trusted client CA: {e}"))?; + } + acceptor.set_client_ca_list(ca_names); + acceptor.set_verify(SslVerifyMode::PEER | SslVerifyMode::FAIL_IF_NO_PEER_CERT); + } else { + acceptor.set_verify(SslVerifyMode::NONE); + } - Ok(acceptor) + Ok(acceptor.build()) } // --------------------------------------------------------------------------- // Server entry point // --------------------------------------------------------------------------- -/// Run a TLS Modbus TCP slave on `addr`. +/// Run a TLS Modbus TCP slave with an already bound listener and valid identity. /// /// Accepts TCP connections, performs TLS handshake via `spawn_blocking`, then /// hands off to `handle_client` which runs in a blocking thread. pub async fn run_tls_slave( - addr: SocketAddr, - tls_config: SlaveTlsConfig, + listener: TcpListener, + acceptor: SslAcceptor, devices: SharedDevices, log_collector: SharedLogCollector, change_callback: SharedChangeCallback, clients: ConnectedClients, shutdown_rx: oneshot::Receiver<()>, ) -> Result<(), String> { - let acceptor = build_tls_acceptor(&tls_config)?; let acceptor = Arc::new(acceptor); - - let listener = TcpListener::bind(addr) - .await - .map_err(|e| format!("Failed to bind {addr}: {e}"))?; + let addr = listener + .local_addr() + .map_err(|e| format!("Failed to get TLS address: {e}"))?; log::info!("TLS Modbus slave listening on {addr}"); // Shared shutdown flag that blocking client threads can check. @@ -155,7 +230,7 @@ pub async fn run_tls_slave( /// Sets a 200ms read timeout so the thread can periodically check the shutdown /// flag. Reads MBAP frames, processes requests, and writes responses. fn handle_client( - mut tls_stream: native_tls::TlsStream, + mut tls_stream: SslStream, peer_addr: SocketAddr, devices: SharedDevices, log_collector: SharedLogCollector, diff --git a/crates/modbussim-core/tests/tls_e2e.rs b/crates/modbussim-core/tests/tls_e2e.rs index de6a828..a32b15c 100644 --- a/crates/modbussim-core/tests/tls_e2e.rs +++ b/crates/modbussim-core/tests/tls_e2e.rs @@ -180,6 +180,70 @@ fn write_temp_file(data: &[u8]) -> NamedTempFile { f } +#[test] +fn test_slave_pkcs1_pem_identity_is_supported() { + let (_, ca, ca_key) = gen_ca(); + let (cert, key) = gen_leaf_cert(&["localhost"], &ca, &ca_key); + let cert_file = write_temp_file(&cert.to_pem().unwrap()); + let key_file = write_temp_file(&key.rsa().unwrap().private_key_to_pem().unwrap()); + let config = SlaveTlsConfig { + cert_file: cert_file.path().to_string_lossy().into_owned(), + key_file: key_file.path().to_string_lossy().into_owned(), + ..Default::default() + }; + assert!( + modbussim_core::tls_slave::build_tls_acceptor(&config).is_ok(), + "A PKCS#1 RSA server key must load without format conversion or Keychain" + ); +} + +#[tokio::test] +async fn test_slave_missing_certificate_never_reports_running() { + let mut slave = SlaveConnection::new(Transport::TcpTls { + host: "127.0.0.1".into(), + port: 0, + }); + let result = slave.start().await; + let state = slave.state(); + slave.stop().await.unwrap(); + assert!(result.is_err(), "TLS setup failure must reach the caller"); + assert_eq!(state, modbussim_core::slave::ConnectionState::Stopped); +} + +#[tokio::test] +async fn test_slave_tls_bind_failure_can_be_retried() { + use modbussim_core::slave::{ConnectionState, SlaveError}; + let (_, server_p12) = generate_test_certs(); + let p12_file = write_temp_file(&server_p12); + let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = occupied.local_addr().unwrap(); + let mut slave = SlaveConnection::new(Transport::TcpTls { + host: address.ip().to_string(), + port: address.port(), + }); + slave.tls_config = SlaveTlsConfig { + pkcs12_file: p12_file.path().to_string_lossy().into_owned(), + pkcs12_password: "password".into(), + ..Default::default() + }; + assert!(matches!(slave.start().await, Err(SlaveError::BindError(_)))); + assert_eq!(slave.state(), ConnectionState::Stopped); + drop(occupied); + + slave.start().await.unwrap(); + assert_eq!(slave.state(), ConnectionState::Running); + assert!( + std::net::TcpListener::bind(address).is_err(), + "Running must mean the socket is already bound" + ); + slave.stop().await.unwrap(); + assert_eq!(slave.state(), ConnectionState::Stopped); + assert!( + std::net::TcpListener::bind(address).is_ok(), + "Stop must release the listening socket" + ); +} + // --------------------------------------------------------------------------- // Test 1: TLS read/write holding registers with full cert validation // --------------------------------------------------------------------------- @@ -536,12 +600,13 @@ async fn test_legacy_certificate_requires_explicit_compatibility() { cert.sign(&ca_key, MessageDigest::sha256()).unwrap(); let cert = cert.build(); assert!(cert.subject_alt_names().is_none()); - let identity = - native_tls::Identity::from_pkcs12(&make_pkcs12(&cert, &key, &ca_cert), "password").unwrap(); - let acceptor = native_tls::TlsAcceptor::builder(identity) - .min_protocol_version(Some(native_tls::Protocol::Tlsv12)) - .build() - .unwrap(); + let identity_file = write_temp_file(&make_pkcs12(&cert, &key, &ca_cert)); + let acceptor = modbussim_core::tls_slave::build_tls_acceptor(&SlaveTlsConfig { + pkcs12_file: identity_file.path().to_string_lossy().into_owned(), + pkcs12_password: "password".into(), + ..Default::default() + }) + .unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let server = tokio::spawn(async move { @@ -628,3 +693,266 @@ async fn test_legacy_certificate_requires_explicit_compatibility() { compatible.disconnect().await.unwrap(); assert_eq!(server.await.unwrap(), 1); } + +// Exercise the production slave handshake and Modbus handler with an in-memory +// client too: these tests must not import any private key into macOS Keychain. +async fn exchange_with_tls_slave( + config: &SlaveTlsConfig, + client: openssl::ssl::SslConnector, +) -> Result<(), String> { + use modbussim_core::{clients::ConnectedClients, mbap, tls_slave}; + use std::{sync::Arc, time::Duration}; + + let acceptor = tls_slave::build_tls_acceptor(config)?; + // Retain the socket throughout setup; there is no free-port/rebind race. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let devices = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::from([ + (1, SlaveDevice::with_default_registers(1, "mTLS", 10)), + ]))); + let clients = ConnectedClients::default(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(tls_slave::run_tls_slave( + listener, + acceptor, + devices, + None, + None, + clients.clone(), + shutdown_rx, + )); + let result = tokio::task::spawn_blocking(move || -> Result<(), String> { + let socket = std::net::TcpStream::connect_timeout(&addr, Duration::from_secs(3)) + .map_err(|e| e.to_string())?; + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut stream = client + .connect("localhost", socket) + .map_err(|e| e.to_string())?; + // TLS 1.3 can report certificate rejection on the first read, after the + // client connect() returned. Require a real response to count as success. + mbap::write_frame(&mut stream, 1, 1, &[6, 0, 0, 0, 42]).map_err(|e| e.to_string())?; + let (header, response) = mbap::read_frame(&mut stream).map_err(|e| e.to_string())?; + assert_eq!(header.transaction_id, 1); + assert_eq!(response, [6, 0, 0, 0, 42]); + mbap::write_frame(&mut stream, 2, 1, &[3, 0, 0, 0, 1]).map_err(|e| e.to_string())?; + let (_, response) = mbap::read_frame(&mut stream).map_err(|e| e.to_string())?; + assert_eq!(response, [3, 2, 0, 42]); + let _ = stream.shutdown(); + Ok(()) + }) + .await + .unwrap(); + clients.close_all(); + shutdown_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(3), server) + .await + .unwrap() + .unwrap() + .unwrap(); + result +} + +fn in_memory_client( + ca: &openssl::x509::X509, + identity: Option<( + &openssl::x509::X509, + &openssl::pkey::PKey, + )>, + version: openssl::ssl::SslVersion, +) -> openssl::ssl::SslConnector { + use openssl::ssl::{SslConnector, SslMethod}; + let mut client = SslConnector::builder(SslMethod::tls_client()).unwrap(); + client.cert_store_mut().add_cert(ca.to_owned()).unwrap(); + client.set_min_proto_version(Some(version)).unwrap(); + client.set_max_proto_version(Some(version)).unwrap(); + if let Some((cert, key)) = identity { + client.set_certificate(cert).unwrap(); + client.set_private_key(key).unwrap(); + } + client.build() +} + +#[tokio::test] +async fn test_slave_pem_and_pkcs12_restart_without_keychain() { + use openssl::ssl::SslVersion; + let (ca_pem, ca_cert, ca_key) = gen_ca(); + let (cert, key) = gen_leaf_cert(&["localhost"], &ca_cert, &ca_key); + let mut chain = cert.to_pem().unwrap(); + chain.extend_from_slice(&ca_pem); + let cert_file = write_temp_file(&chain); + let key_file = write_temp_file(&key.private_key_to_pem_pkcs8().unwrap()); + let p12_file = write_temp_file(&make_pkcs12(&cert, &key, &ca_cert)); + let pkcs1_file = write_temp_file(&key.rsa().unwrap().private_key_to_pem().unwrap()); + for config in [ + SlaveTlsConfig { + cert_file: cert_file.path().to_string_lossy().into_owned(), + key_file: key_file.path().to_string_lossy().into_owned(), + ..Default::default() + }, + SlaveTlsConfig { + cert_file: cert_file.path().to_string_lossy().into_owned(), + key_file: pkcs1_file.path().to_string_lossy().into_owned(), + ..Default::default() + }, + SlaveTlsConfig { + pkcs12_file: p12_file.path().to_string_lossy().into_owned(), + pkcs12_password: "password".into(), + ..Default::default() + }, + ] { + for _ in 0..3 { + exchange_with_tls_slave( + &config, + in_memory_client(&ca_cert, None, SslVersion::TLS1_2), + ) + .await + .expect("Repeated slave starts must work without Keychain"); + } + } +} + +#[tokio::test] +async fn test_slave_legacy_pkcs12_without_keychain() { + use openssl::{nid::Nid, pkcs12::Pkcs12, provider::Provider, ssl::SslVersion}; + let (_, ca, ca_key) = gen_ca(); + let (cert, key) = gen_leaf_cert(&["localhost"], &ca, &ca_key); + let p12 = { + let _legacy = Provider::try_load(None, "legacy", true).unwrap(); + Pkcs12::builder() + .cert(&cert) + .pkey(&key) + .key_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) + .cert_algorithm(Nid::PBE_WITHSHA1AND40BITRC2_CBC) + .build2("password") + .unwrap() + .to_der() + .unwrap() + }; + let p12_file = write_temp_file(&p12); + let config = SlaveTlsConfig { + pkcs12_file: p12_file.path().to_string_lossy().into_owned(), + pkcs12_password: "password".into(), + ..Default::default() + }; + exchange_with_tls_slave(&config, in_memory_client(&ca, None, SslVersion::TLS1_2)) + .await + .expect("Legacy P12 exports must still work without Keychain"); +} + +#[tokio::test] +async fn test_slave_mutual_tls_requires_trusted_client_certificate() { + use openssl::ssl::SslVersion; + let (ca_pem, ca_cert, ca_key) = gen_ca(); + let (server_cert, server_key) = gen_leaf_cert(&["localhost"], &ca_cert, &ca_key); + let (client_cert, client_key) = gen_leaf_cert(&["client"], &ca_cert, &ca_key); + let (_, rogue_ca, rogue_ca_key) = gen_ca(); + let (rogue_cert, rogue_key) = gen_leaf_cert(&["rogue"], &rogue_ca, &rogue_ca_key); + let cert_file = write_temp_file(&server_cert.to_pem().unwrap()); + let key_file = write_temp_file(&server_key.private_key_to_pem_pkcs8().unwrap()); + let ca_file = write_temp_file(&ca_pem); + let config = SlaveTlsConfig { + cert_file: cert_file.path().to_string_lossy().into_owned(), + key_file: key_file.path().to_string_lossy().into_owned(), + ca_file: ca_file.path().to_string_lossy().into_owned(), + require_client_cert: true, + ..Default::default() + }; + for version in [SslVersion::TLS1_2, SslVersion::TLS1_3] { + exchange_with_tls_slave( + &config, + in_memory_client(&ca_cert, Some((&client_cert, &client_key)), version), + ) + .await + .expect("A trusted client must read and write registers"); + assert!( + exchange_with_tls_slave(&config, in_memory_client(&ca_cert, None, version)) + .await + .is_err(), + "mTLS must reject clients without certificates" + ); + assert!( + exchange_with_tls_slave( + &config, + in_memory_client(&ca_cert, Some((&rogue_cert, &rogue_key)), version) + ) + .await + .is_err(), + "mTLS must reject certificates signed by a different CA" + ); + } +} + +#[tokio::test] +async fn test_slave_bad_tls_configuration_fails_before_running() { + use modbussim_core::{slave::SlaveError, tls_slave::build_tls_acceptor}; + let (_, ca, ca_key) = gen_ca(); + let (cert, key) = gen_leaf_cert(&["localhost"], &ca, &ca_key); + let cert_file = write_temp_file(&cert.to_pem().unwrap()); + let key_file = write_temp_file(&key.private_key_to_pem_pkcs8().unwrap()); + let p12_file = write_temp_file(&make_pkcs12(&cert, &key, &ca)); + let config = SlaveTlsConfig { + cert_file: cert_file.path().to_string_lossy().into_owned(), + key_file: key_file.path().to_string_lossy().into_owned(), + require_client_cert: true, + ..Default::default() + }; + let mut slave = SlaveConnection::new(Transport::TcpTls { + host: "127.0.0.1".into(), + port: 0, + }); + slave.tls_config = config.clone(); + assert!( + matches!(slave.start().await, Err(SlaveError::CertError(e)) if e.contains("requires a CA file")) + ); + assert_eq!( + slave.state(), + modbussim_core::slave::ConnectionState::Stopped + ); + let empty_ca = write_temp_file(b""); + slave.tls_config.ca_file = empty_ca.path().to_string_lossy().into_owned(); + assert!( + matches!(slave.start().await, Err(SlaveError::CertError(e)) if e.contains("no certificates")) + ); + assert_eq!( + slave.state(), + modbussim_core::slave::ConnectionState::Stopped + ); + + let wrong_key_file = write_temp_file(&ca_key.private_key_to_pem_pkcs8().unwrap()); + assert!( + build_tls_acceptor(&SlaveTlsConfig { + key_file: wrong_key_file.path().to_string_lossy().into_owned(), + require_client_cert: false, + ..config.clone() + }) + .is_err(), + "Mismatched certificate/key must fail during setup" + ); + assert!( + build_tls_acceptor(&SlaveTlsConfig { + pkcs12_file: p12_file.path().to_string_lossy().into_owned(), + pkcs12_password: "wrong-password".into(), + ..Default::default() + }) + .is_err(), + "Wrong P12 password must fail instead of prompting" + ); + let encrypted_key = write_temp_file( + &key.private_key_to_pem_pkcs8_passphrase(openssl::symm::Cipher::aes_256_cbc(), b"secret") + .unwrap(), + ); + assert!( + build_tls_acceptor(&SlaveTlsConfig { + key_file: encrypted_key.path().to_string_lossy().into_owned(), + require_client_cert: false, + ..config + }) + .is_err(), + "Encrypted PEM must fail without an interactive password callback" + ); +}