From 6bb4e9b0b08c3e7c6525fad8bc20d47a4f5a52dd Mon Sep 17 00:00:00 2001 From: adisusilayasa <3523adisusilayasa@gmail.com> Date: Wed, 19 Aug 2026 11:29:03 +0800 Subject: [PATCH 1/2] fix(postgres): add mTLS client certificate authentication support --- src-tauri/Cargo.lock | 2 +- src-tauri/src/pool_manager.rs | 75 ++++++++++++++-- src-tauri/src/pool_manager_tests.rs | 129 ++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb7602fb..134802b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6459,7 +6459,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.19.0" +version = "0.20.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 8fa1f7ee..91f2472d 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -7,7 +7,7 @@ use rustls::client::{verify_server_cert_signed_by_trust_anchor, WebPkiServerVeri use rustls::crypto::verify_tls12_signature; use rustls::crypto::verify_tls13_signature; use rustls::crypto::CryptoProvider; -use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; use rustls::server::ParsedCertificate; use rustls::DigitallySignedStruct; use rustls::{ClientConfig, Error as TlsError, RootCertStore}; @@ -120,7 +120,9 @@ pub(crate) fn build_connection_key( "verify-ca" | "verify-full" => params.ssl_ca.as_deref().unwrap_or(""), _ => "", }; - Some(format!("ssl:{ssl_mode}:{ssl_ca}")) + let ssl_cert = params.ssl_cert.as_deref().unwrap_or(""); + let ssl_key = params.ssl_key.as_deref().unwrap_or(""); + Some(format!("ssl:{ssl_mode}:{ssl_ca}:{ssl_cert}:{ssl_key}")) } _ => None, }; @@ -437,15 +439,33 @@ pub(crate) fn build_postgres_tls_connector( ensure_rustls_crypto_provider(); let ssl_mode = params.ssl_mode.as_deref().unwrap_or("prefer"); let user_ca = params.ssl_ca.as_deref().filter(|s| !s.trim().is_empty()); + let user_cert = params.ssl_cert.as_deref().filter(|s| !s.trim().is_empty()); + let user_key = params.ssl_key.as_deref().filter(|s| !s.trim().is_empty()); - let config = match ssl_mode { + let client_auth = match (user_cert, user_key) { + (Some(cert), Some(key)) => Some(load_client_auth_from_pem(cert, key)?), + (Some(_), None) => { + return Err( + "Client certificate provided (ssl_cert) without a client private key (ssl_key)" + .to_string(), + ); + } + (None, Some(_)) => { + return Err( + "Client private key provided (ssl_key) without a client certificate (ssl_cert)" + .to_string(), + ); + } + (None, None) => None, + }; + + let builder = match ssl_mode { "disable" | "allow" | "prefer" => { // No cert verification; PgSslMode below controls whether TLS is attempted. let verifier = Arc::new(NoCertVerifier::new()); ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(verifier) - .with_no_client_auth() } "require" => { // Force TLS, skip cert validation. @@ -453,7 +473,6 @@ pub(crate) fn build_postgres_tls_connector( ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(verifier) - .with_no_client_auth() } "verify-ca" => { // Validate chain, skip hostname. Requires an explicit CA file — @@ -470,7 +489,6 @@ pub(crate) fn build_postgres_tls_connector( ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(verifier) - .with_no_client_auth() } "verify-full" => { // Validate certificate chain AND hostname. @@ -479,7 +497,6 @@ pub(crate) fn build_postgres_tls_connector( ClientConfig::builder() .with_platform_verifier() .map_err(|e| format!("Failed to build platform TLS verifier: {}", e))? - .with_no_client_auth() } else { // Use custom CA with full hostname verification. let roots = load_roots_from_pem(user_ca.unwrap())?; @@ -489,7 +506,6 @@ pub(crate) fn build_postgres_tls_connector( ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(verifier) - .with_no_client_auth() } } _ => { @@ -498,12 +514,53 @@ pub(crate) fn build_postgres_tls_connector( ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(verifier) - .with_no_client_auth() } }; + + let config = match client_auth { + Some((certs, key)) => builder + .with_client_auth_cert(certs, key) + .map_err(|e| format!("Failed to configure client certificate: {e}"))?, + None => builder.with_no_client_auth(), + }; + Ok(MakeRustlsConnect::new(config)) } +/// Load client certificate chain and private key from PEM files for mTLS. +pub(crate) fn load_client_auth_from_pem( + cert_path: &str, + key_path: &str, +) -> Result<(Vec>, PrivateKeyDer<'static>), String> { + let cert_bytes = std::fs::read(cert_path) + .map_err(|e| format!("Failed to read ssl_cert file '{}': {}", cert_path, e))?; + let mut cert_cursor = std::io::Cursor::new(&cert_bytes[..]); + let certs: Vec> = rustls_pemfile::certs(&mut cert_cursor) + .collect::, _>>() + .map_err(|e| format!("Failed to parse ssl_cert '{}': {}", cert_path, e))?; + + if certs.is_empty() { + return Err(format!( + "ssl_cert '{}' contained no PEM CERTIFICATE blocks", + cert_path + )); + } + + let key_bytes = std::fs::read(key_path) + .map_err(|e| format!("Failed to read ssl_key file '{}': {}", key_path, e))?; + let mut key_cursor = std::io::Cursor::new(&key_bytes[..]); + let key = rustls_pemfile::private_key(&mut key_cursor) + .map_err(|e| format!("Failed to parse ssl_key '{}': {}", key_path, e))? + .ok_or_else(|| { + format!( + "ssl_key '{}' contained no private key in PEM format", + key_path + ) + })?; + + Ok((certs, key)) +} + /// Load root certificates from a PEM file. pub(crate) fn load_roots_from_pem(path: &str) -> Result { let pem = diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 4e0d561a..1378a3d2 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -108,6 +108,28 @@ mod tests { ); } + #[test] + fn postgres_pool_key_changes_when_ssl_cert_or_key_changes() { + let base = connection_params("postgres", Some("require")); + let mut with_cert = connection_params("postgres", Some("require")); + with_cert.ssl_cert = Some("/tmp/client-cert.pem".to_string()); + let mut with_key = connection_params("postgres", Some("require")); + with_key.ssl_key = Some("/tmp/client-key.pem".to_string()); + + assert_ne!( + build_connection_key(&base, Some("conn-1")), + build_connection_key(&with_cert, Some("conn-1")) + ); + assert_ne!( + build_connection_key(&base, Some("conn-1")), + build_connection_key(&with_key, Some("conn-1")) + ); + assert_ne!( + build_connection_key(&with_cert, Some("conn-1")), + build_connection_key(&with_key, Some("conn-1")) + ); + } + #[test] fn sqlite_pool_key_ignores_tls_key_fields() { let required = connection_params("sqlite", Some("required")); @@ -974,6 +996,113 @@ mod postgres_tls_connector_tests { // Cleanup let _ = std::fs::remove_file(&file_path); } + + #[test] + fn test_load_client_auth_from_pem_valid() { + use crate::pool_manager::load_client_auth_from_pem; + use std::io::Write; + + let temp_dir = std::env::temp_dir(); + let cert_path = temp_dir.join("test_client_cert.pem"); + let key_path = temp_dir.join("test_client_key.pem"); + + let cert_pem = b"-----BEGIN CERTIFICATE-----\n\ +MIIDDTCCAfWgAwIBAgIUJ8HHuLoSwWh1pDLxr7Tq1SLa59AwDQYJKoZIhvcNAQEL\n\ +BQAwFjEUMBIGA1UEAwwLdGVzdC1jbGllbnQwHhcNMjYwODE5MDMyNzEyWhcNMjcw\n\ +ODE5MDMyNzEyWjAWMRQwEgYDVQQDDAt0ZXN0LWNsaWVudDCCASIwDQYJKoZIhvcN\n\ +AQEBBQADggEPADCCAQoCggEBALg2eEdxdFRd6XsQkXuFWcvtZ4JXb5npWFnodU0J\n\ +9SQcMWonQaKnIkCktCyH/BhEU7GG61TwyF6WFXMAl8facpN7Y7vGxPfj8M6xmq0U\n\ +7iACDSHEH+jQ7yqqR/LzF/VFP/3+l8MiO5f/p39Dwl7yg+dwQKp2D7rN6wtHjTIj\n\ +t5M1295G3VvvfvrL7DtlrxUuFhKi00RfdfmmobGOPnB4Ta4pyb+Wxs2Pgiftvv5A\n\ +abcGtnmkB1j8ymAGlmCslfYtWWkShZhDdvA/jT2Ufsn98vq3vzkDSpADKVWGZZPH\n\ +IC0QM26fJTjzabkRbhdv/s7gSZJaQk5khPyS0pxuHI3NSiUCAwEAAaNTMFEwHQYD\n\ +VR0OBBYEFKG5VWX/3V8ELmZOuq8Xo0yltyKRMB8GA1UdIwQYMBaAFKG5VWX/3V8E\n\ +LmZOuq8Xo0yltyKRMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB\n\ +ADKTW2L0fOtcCIBn1BjQYBpcFm9onk68OmCkcEjxPMVI82ywsusNknZV3aInrQOr\n\ +R8eHSnbLcWddBgT87I1uQNljoPxKgl6BHzmVE5i3sp2mV3+x9BYzCXfscvNlitUz\n\ +1o8CAYwv36ZCSdJzK5M73n/W4sv9QIlwqQ9uRtpOtyWNTpBsy2D0EQXvKmiOoLQm\n\ +9jzBlzmbYH+PfXE7dtWpnN4DuDf+LMKWvzB2VP0zQq5r7sCgG3b6uH2hMUbfDk80\n\ +lqTyecmlouEhCXwDn9565FSBOg/a1iNkUXo87rwY3GE5T40UKafllLhy9DDpJoOV\n\ +/XzcU5fETyl+X8bwhNHXnbk=\n\ +-----END CERTIFICATE-----\n"; + + let key_pem = b"-----BEGIN PRIVATE KEY-----\n\ +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4NnhHcXRUXel7\n\ +EJF7hVnL7WeCV2+Z6VhZ6HVNCfUkHDFqJ0GipyJApLQsh/wYRFOxhutU8MhelhVz\n\ +AJfH2nKTe2O7xsT34/DOsZqtFO4gAg0hxB/o0O8qqkfy8xf1RT/9/pfDIjuX/6d/\n\ +Q8Je8oPncECqdg+6zesLR40yI7eTNdveRt1b7376y+w7Za8VLhYSotNEX3X5pqGx\n\ +jj5weE2uKcm/lsbNj4In7b7+QGm3BrZ5pAdY/MpgBpZgrJX2LVlpEoWYQ3bwP409\n\ +lH7J/fL6t785A0qQAylVhmWTxyAtEDNunyU482m5EW4Xb/7O4EmSWkJOZIT8ktKc\n\ +bhyNzUolAgMBAAECggEADkhgOF8vlIBY41XRhzDmWXgQF9G3sXNKNQ5fqe16Yvux\n\ +880UuwvCYsi3v4wmRlYQjH8tgpR4tKdmWlhSwbLKQhfFuoAa3YLVikZy+7XXu1uY\n\ +BmTdJIFZSdw8tTWWnJ8V6xebpLbkGqY+fRDqjSS4iBqdU5omdhFpMU1cQTr2YiTK\n\ +gamazEZYwxk+uQOTikNJgS7y8Br6dPhRKGkmS7x4jfPFwO8mRIW9ZwvgkAXybs6p\n\ +vpJR5hT262mndLVU+3+UjEOpevrLZArrad0FNfRS5YBvgg66dirbem4rq4P+e9aq\n\ +5mElyAx0A5RQpFMqbF7uQLkVAIFnzQbrr128NmO4MQKBgQDlXuKfhU+epxE8zeai\n\ +5zhsW5wfSuMuyAN01vf0vFKDCDpxOYU2WXFYC0PQHog/12UWwtzL9c3l2LWaKf5v\n\ +ASdIuQ8C+ZGL9JSo3DRpdB2SjDEgr2vpfmAoK45dadV0AJoDjFGHs/W7vKVaq+Ze\n\ +Hrd9hW8YRVwcvIHeuvlaSs9xywKBgQDNmXMQVYaRDl8rc496L1y4M2jWjLORShVU\n\ +t/GCFdBDtTvwXlLDY1Lu9ZI9iiXNVkXE7u604oj9XStbjH0qEo1It+3+bDvGGJH/\n\ +eq2U4iAmKsftvovMkbz8MgOME2P4FrTbU45c57D2u9n2GZhVrZsP6eMmmLskSDEG\n\ +pDBln1v1zwKBgQCY20oV2wa7iUUQi3tHVuYgOFDr/cE23O6Iv/YQsCwgzKv95sJi\n\ +/OpvLVqs6JwOR6JDr+rrNc1YfrpPmerI2TDv4vwhFGatqXokqlN3b32Bu1HGIYG9\n\ +4o18V8KReEVbAEejU7DFyeVajpZ3vZVRZhEMYo8t0pNXRz0ZTOt+A9sJTwKBgCrv\n\ +Xp4MnjtwmuNCELZdaal14vDbFSzEIcw9VYvq7kEVedzqdbIj7c/FLLL5RIeq+orz\n\ +spnHrP/sEv/dSM4eba6/6k11YM4vl12YyuMKjdgqmvHFFwCzdpnb/+2ipv/KDh63\n\ +RkWUhNohxJSmJ6/Mv1MFbtBCmOIsyUAvzYOLUfL1AoGAAjwuwq/vQKeebkYd91T4\n\ +ygRQGhZXL1juHpZ/KPCDSb41dI7GmABtLfEG2SzzIl3nnuyq1VMzyyPiSbTggozo\n\ +MzZebKJ4qdpxkxKv9KQP63JVuE2MBQcFT0qhUSdpas2ENMaF7lXWABNCahBEL6jD\n\ +dBNMbRP5kADLpoevZgLZECs=\n\ +-----END PRIVATE KEY-----\n"; + + std::fs::File::create(&cert_path).unwrap().write_all(cert_pem).unwrap(); + std::fs::File::create(&key_path).unwrap().write_all(key_pem).unwrap(); + + let result = load_client_auth_from_pem( + cert_path.to_str().unwrap(), + key_path.to_str().unwrap(), + ); + assert!(result.is_ok()); + let (certs, _key) = result.unwrap(); + assert_eq!(certs.len(), 1); + + // Test connector builder with client auth + let mut params = params_with_ssl("require"); + params.ssl_cert = Some(cert_path.to_str().unwrap().to_string()); + params.ssl_key = Some(key_path.to_str().unwrap().to_string()); + let conn_res = build_postgres_tls_connector(¶ms); + assert!(conn_res.is_ok()); + + // Cleanup + let _ = std::fs::remove_file(&cert_path); + let _ = std::fs::remove_file(&key_path); + } + + #[test] + fn test_tls_connector_client_cert_without_key_fails() { + let mut params = params_with_ssl("require"); + params.ssl_cert = Some("/path/to/cert.pem".to_string()); + params.ssl_key = None; + + let result = build_postgres_tls_connector(¶ms); + match result { + Err(e) => assert!(e.contains("ssl_cert")), + Ok(_) => panic!("Expected error when ssl_cert is provided without ssl_key"), + } + } + + #[test] + fn test_tls_connector_client_key_without_cert_fails() { + let mut params = params_with_ssl("require"); + params.ssl_cert = None; + params.ssl_key = Some("/path/to/key.pem".to_string()); + + let result = build_postgres_tls_connector(¶ms); + match result { + Err(e) => assert!(e.contains("ssl_key")), + Ok(_) => panic!("Expected error when ssl_key is provided without ssl_cert"), + } + } } #[cfg(test)] From 07793f9bbfe57f53b1215fc128f712edb3a1f949 Mon Sep 17 00:00:00 2001 From: adisusilayasa <3523adisusilayasa@gmail.com> Date: Thu, 20 Aug 2026 01:11:24 +0800 Subject: [PATCH 2/2] fix: skip postgres client auth when ssl_mode is disabled and use tempfile in tests --- src-tauri/src/pool_manager.rs | 42 ++++++++++++++++++----------- src-tauri/src/pool_manager_tests.rs | 41 +++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 91f2472d..68e12b5c 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -120,8 +120,14 @@ pub(crate) fn build_connection_key( "verify-ca" | "verify-full" => params.ssl_ca.as_deref().unwrap_or(""), _ => "", }; - let ssl_cert = params.ssl_cert.as_deref().unwrap_or(""); - let ssl_key = params.ssl_key.as_deref().unwrap_or(""); + let (ssl_cert, ssl_key) = if ssl_mode == "disable" { + ("", "") + } else { + ( + params.ssl_cert.as_deref().unwrap_or(""), + params.ssl_key.as_deref().unwrap_or(""), + ) + }; Some(format!("ssl:{ssl_mode}:{ssl_ca}:{ssl_cert}:{ssl_key}")) } _ => None, @@ -442,21 +448,25 @@ pub(crate) fn build_postgres_tls_connector( let user_cert = params.ssl_cert.as_deref().filter(|s| !s.trim().is_empty()); let user_key = params.ssl_key.as_deref().filter(|s| !s.trim().is_empty()); - let client_auth = match (user_cert, user_key) { - (Some(cert), Some(key)) => Some(load_client_auth_from_pem(cert, key)?), - (Some(_), None) => { - return Err( - "Client certificate provided (ssl_cert) without a client private key (ssl_key)" - .to_string(), - ); - } - (None, Some(_)) => { - return Err( - "Client private key provided (ssl_key) without a client certificate (ssl_cert)" - .to_string(), - ); + let client_auth = if ssl_mode == "disable" { + None + } else { + match (user_cert, user_key) { + (Some(cert), Some(key)) => Some(load_client_auth_from_pem(cert, key)?), + (Some(_), None) => { + return Err( + "Client certificate provided (ssl_cert) without a client private key (ssl_key)" + .to_string(), + ); + } + (None, Some(_)) => { + return Err( + "Client private key provided (ssl_key) without a client certificate (ssl_cert)" + .to_string(), + ); + } + (None, None) => None, } - (None, None) => None, }; let builder = match ssl_mode { diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 1378a3d2..b0eccebb 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -130,6 +130,24 @@ mod tests { ); } + #[test] + fn postgres_pool_key_ignores_ssl_cert_and_key_when_ssl_disabled() { + let base = connection_params("postgres", Some("disable")); + let mut with_cert = connection_params("postgres", Some("disable")); + with_cert.ssl_cert = Some("/tmp/client-cert.pem".to_string()); + let mut with_key = connection_params("postgres", Some("disable")); + with_key.ssl_key = Some("/tmp/client-key.pem".to_string()); + + assert_eq!( + build_connection_key(&base, Some("conn-1")), + build_connection_key(&with_cert, Some("conn-1")) + ); + assert_eq!( + build_connection_key(&base, Some("conn-1")), + build_connection_key(&with_key, Some("conn-1")) + ); + } + #[test] fn sqlite_pool_key_ignores_tls_key_fields() { let required = connection_params("sqlite", Some("required")); @@ -1002,9 +1020,9 @@ mod postgres_tls_connector_tests { use crate::pool_manager::load_client_auth_from_pem; use std::io::Write; - let temp_dir = std::env::temp_dir(); - let cert_path = temp_dir.join("test_client_cert.pem"); - let key_path = temp_dir.join("test_client_key.pem"); + let temp_dir = tempfile::tempdir().unwrap(); + let cert_path = temp_dir.path().join("test_client_cert.pem"); + let key_path = temp_dir.path().join("test_client_key.pem"); let cert_pem = b"-----BEGIN CERTIFICATE-----\n\ MIIDDTCCAfWgAwIBAgIUJ8HHuLoSwWh1pDLxr7Tq1SLa59AwDQYJKoZIhvcNAQEL\n\ @@ -1072,10 +1090,21 @@ dBNMbRP5kADLpoevZgLZECs=\n\ params.ssl_key = Some(key_path.to_str().unwrap().to_string()); let conn_res = build_postgres_tls_connector(¶ms); assert!(conn_res.is_ok()); + } - // Cleanup - let _ = std::fs::remove_file(&cert_path); - let _ = std::fs::remove_file(&key_path); + #[test] + fn test_tls_connector_disabled_ssl_ignores_client_cert_and_key() { + let mut params = params_with_ssl("disable"); + // Mismatched or non-existent client cert/key paths should be ignored when SSL is disabled. + params.ssl_cert = Some("/path/to/nonexistent_cert.pem".to_string()); + params.ssl_key = None; + + let result = build_postgres_tls_connector(¶ms); + assert!(result.is_ok()); + + params.ssl_key = Some("/path/to/nonexistent_key.pem".to_string()); + let result = build_postgres_tls_connector(¶ms); + assert!(result.is_ok()); } #[test]