fix: accept RDS certificates without EKU using verified OpenSSL TLS
This commit is contained in:
Generated
+25
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<TlsConnector> {
|
||||
let mut tls = TlsConnector::builder();
|
||||
if let Some(path) = root_cert {
|
||||
fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result<SslConnector> {
|
||||
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)
|
||||
let pem = std::fs::read(root_cert)
|
||||
.map_err(|error| format!("Cannot read CA bundle: {error}"))?;
|
||||
let certificates = Certificate::stack_from_pem(&pem)
|
||||
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());
|
||||
}
|
||||
// An explicit bundle replaces system roots; certificate and hostname checks stay enabled.
|
||||
tls.disable_built_in_roots(true);
|
||||
let mut roots = X509StoreBuilder::new()?;
|
||||
for certificate in certificates {
|
||||
tls.add_root_certificate(certificate);
|
||||
roots.add_cert(certificate)?;
|
||||
}
|
||||
}
|
||||
Ok(tls.build()?)
|
||||
// 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<i64> {
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user