From 2aac7361d6a254957c4614e0619db461f5c34955 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 20 Oct 2026 13:40:04 +0200 Subject: [PATCH] fix: accept RDS certificates without EKU using verified OpenSSL TLS --- Cargo.lock | 25 +++++++++ Cargo.toml | 2 + src/db.rs | 147 +++++++++++++++++++++++++++++++++++++++++++--------- src/main.rs | 3 ++ 4 files changed, 152 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11e4c78..ccb3ab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,8 +271,10 @@ version = "0.1.0" dependencies = [ "crossterm", "native-tls", + "openssl", "postgres", "postgres-native-tls", + "postgres-openssl", "ratatui", "rusqlite", "shellexpand", @@ -941,6 +943,18 @@ dependencies = [ "tokio-postgres", ] +[[package]] +name = "postgres-openssl" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06743eefaa1a5c0ef2ccb6d9abf6528790a229eabd62ddcabf9b2a3aeff09fa4" +dependencies = [ + "openssl", + "tokio", + "tokio-openssl", + "tokio-postgres", +] + [[package]] name = "postgres-protocol" version = "0.6.12" @@ -1475,6 +1489,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-openssl" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd" +dependencies = [ + "openssl", + "openssl-sys", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.18" diff --git a/Cargo.toml b/Cargo.toml index 53d13d2..54556f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ crossterm = "0.29" postgres = "0.19" postgres-native-tls = "0.5" native-tls = "0.2" +postgres-openssl = "0.5" +openssl = "0.10" rusqlite = { version = "0.40", default-features = false, features = ["bundled"] } shellexpand = { version = "3.1", features = ["path"] } sqlparser = { version = "0.63", default-features = false, features = ["std"] } diff --git a/src/db.rs b/src/db.rs index ae8e060..8d20ebf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,10 @@ use std::{path::Path, time::Duration}; -use native_tls::{Certificate, TlsConnector}; +use native_tls::TlsConnector; +use openssl::{ + ssl::{SslConnector, SslMethod, SslVerifyMode, SslVersion}, + x509::{X509, store::X509StoreBuilder}, +}; use postgres::{Client, Config, config::SslMode}; use postgres_native_tls::MakeTlsConnector; @@ -61,8 +65,12 @@ impl Database { if config.get_connect_timeout().is_none() { config.connect_timeout(Duration::from_secs(5)); } - let tls = MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), root_cert)?); - let mut client = config.connect(tls)?; + let mut client = if let Some(path) = root_cert { + let tls = postgres_openssl::MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), path)?); + config.connect(tls)? + } else { + config.connect(MakeTlsConnector::new(TlsConnector::new()?))? + }; // Views can invoke functions: enforce read-only on the server, not just in the UI. client.batch_execute( "SET default_transaction_read_only = on; SET statement_timeout = '5s';", @@ -276,26 +284,28 @@ impl Database { } } -fn postgres_tls(mode: SslMode, root_cert: Option<&Path>) -> Result { - let mut tls = TlsConnector::builder(); - if let Some(path) = root_cert { - if mode != SslMode::Require { - return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into()); - } - let pem = std::fs::read(path) - .map_err(|error| format!("Cannot read CA bundle: {error}"))?; - let certificates = Certificate::stack_from_pem(&pem) - .map_err(|error| format!("Invalid PEM CA bundle: {error}"))?; - if certificates.is_empty() { - return Err("CA bundle must contain at least one PEM CA certificate.".into()); - } - // An explicit bundle replaces system roots; certificate and hostname checks stay enabled. - tls.disable_built_in_roots(true); - for certificate in certificates { - tls.add_root_certificate(certificate); - } +fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result { + if mode != SslMode::Require { + return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into()); } - Ok(tls.build()?) + let pem = std::fs::read(root_cert) + .map_err(|error| format!("Cannot read CA bundle: {error}"))?; + let certificates = X509::stack_from_pem(&pem) + .map_err(|error| format!("Invalid PEM CA bundle: {error}"))?; + if certificates.is_empty() { + return Err("CA bundle must contain at least one PEM CA certificate.".into()); + } + let mut roots = X509StoreBuilder::new()?; + for certificate in certificates { + roots.add_cert(certificate)?; + } + // RDS may omit EKU. OpenSSL accepts absent EKU, but rejects incompatible usage. + // Replace default roots; normal chain, validity, purpose, and hostname checks stay enabled. + let mut tls = SslConnector::builder(SslMethod::tls_client())?; + tls.set_cert_store(roots.build()); + tls.set_min_proto_version(Some(SslVersion::TLS1_2))?; + tls.set_verify(SslVerifyMode::PEER); + Ok(tls.build()) } fn page_offset(page: usize) -> Result { @@ -325,18 +335,105 @@ pub fn display_text(value: &str) -> String { mod tests { use super::*; + #[test] + fn private_ca_tls_accepts_absent_eku_but_rejects_wrong_usage_host_and_expiry() -> Result<()> { + use openssl::{ + asn1::Asn1Time, + bn::BigNum, + hash::MessageDigest, + pkey::PKey, + rsa::Rsa, + ssl::SslAcceptor, + x509::{X509NameBuilder, extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}}, + }; + use std::net::{TcpListener, TcpStream}; + + const NOW: i64 = 1_800_000_000; + let before = Asn1Time::from_unix(NOW - 86_400)?; + let after = Asn1Time::from_unix(NOW + 86_400)?; + let ca_key = PKey::from_rsa(Rsa::generate(2048)?)?; + let server_key = PKey::from_rsa(Rsa::generate(2048)?)?; + let mut name = X509NameBuilder::new()?; + name.append_entry_by_text("CN", "dbdash test CA")?; + let name = name.build(); + let mut ca = X509::builder()?; + ca.set_version(2)?; + let serial = BigNum::from_u32(1)?.to_asn1_integer()?; + ca.set_serial_number(&serial)?; + ca.set_subject_name(&name)?; + ca.set_issuer_name(&name)?; + ca.set_pubkey(&ca_key)?; + ca.set_not_before(&before)?; + ca.set_not_after(&after)?; + ca.append_extension(BasicConstraints::new().critical().ca().build()?)?; + ca.sign(&ca_key, MessageDigest::sha256())?; + let ca = ca.build(); + let bundle = tempfile::NamedTempFile::new()?; + std::fs::write(bundle.path(), ca.to_pem()?)?; + let mut name = X509NameBuilder::new()?; + name.append_entry_by_text("CN", "localhost")?; + let name = name.build(); + + for (client_only, hostname, clock, accepted) in [ + (false, "localhost", NOW, true), + (true, "localhost", NOW, false), + (false, "wrong.example", NOW, false), + (false, "localhost", NOW + 172_800, false), + ] { + let mut leaf = X509::builder()?; + leaf.set_version(2)?; + let serial = BigNum::from_u32(2)?.to_asn1_integer()?; + leaf.set_serial_number(&serial)?; + leaf.set_subject_name(&name)?; + leaf.set_issuer_name(ca.subject_name())?; + leaf.set_pubkey(&server_key)?; + leaf.set_not_before(&before)?; + leaf.set_not_after(&after)?; + leaf.append_extension(BasicConstraints::new().critical().build()?)?; + let san = SubjectAlternativeName::new().dns("localhost") + .build(&leaf.x509v3_context(Some(&ca), None))?; + leaf.append_extension(san)?; + if client_only { + leaf.append_extension(ExtendedKeyUsage::new().client_auth().build()?)?; + } + leaf.sign(&ca_key, MessageDigest::sha256())?; + let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls_server())?; + acceptor.set_private_key(&server_key)?; + acceptor.set_certificate(&leaf.build())?; + let acceptor = acceptor.build(); + let listener = TcpListener::bind("127.0.0.1:0")?; + let socket = TcpStream::connect(listener.local_addr()?)?; + socket.set_read_timeout(Some(Duration::from_secs(3)))?; + socket.set_write_timeout(Some(Duration::from_secs(3)))?; + let server = std::thread::spawn(move || { + let (socket, _) = listener.accept().unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap(); + let _ = acceptor.accept(socket); + }); + let connector = postgres_tls(SslMode::Require, bundle.path())?; + let mut configuration = connector.configure()?; + configuration.param_mut().set_time(clock); + let result = configuration.connect(hostname, socket); + server.join().unwrap(); + assert_eq!(result.is_ok(), accepted, + "client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}"); + } + Ok(()) + } + #[test] fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> { let bundle = tempfile::NamedTempFile::new()?; for mode in [SslMode::Prefer, SslMode::Disable] { - assert!(postgres_tls(mode, Some(bundle.path())).is_err()); + assert!(postgres_tls(mode, bundle.path()).is_err()); } for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] { std::fs::write(bundle.path(), contents)?; - assert!(postgres_tls(SslMode::Require, Some(bundle.path())).is_err()); + assert!(postgres_tls(SslMode::Require, bundle.path()).is_err()); } let missing = bundle.path().with_extension("missing"); - assert!(postgres_tls(SslMode::Require, Some(&missing)).is_err()); + assert!(postgres_tls(SslMode::Require, &missing).is_err()); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 2d811af..ad52f95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,9 @@ For CLI PostgreSQL connections, use --ca-bundle PATH before the connection strin and specify sslmode=require. Unset PGSSLROOTCERT; legacy launchers are rejected instead of silently losing their custom CA verification. Only the selected bundle is trusted for that connection; system trust is unchanged. +Explicit CA bundles use OpenSSL verification for compatibility with RDS certificates +that omit Extended Key Usage; incompatible EKU, invalid chains, dates, and hostnames +are still rejected. System trust uses the native platform TLS verifier. Certificates and hostnames are verified before authentication; no plaintext fallback with a CA bundle. Missing, empty, or invalid selected files block connection. Use a bundle from the trusted issuer, not an unverified server's certificate.