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 = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
|
"openssl",
|
||||||
"postgres",
|
"postgres",
|
||||||
"postgres-native-tls",
|
"postgres-native-tls",
|
||||||
|
"postgres-openssl",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
@@ -941,6 +943,18 @@ dependencies = [
|
|||||||
"tokio-postgres",
|
"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]]
|
[[package]]
|
||||||
name = "postgres-protocol"
|
name = "postgres-protocol"
|
||||||
version = "0.6.12"
|
version = "0.6.12"
|
||||||
@@ -1475,6 +1489,17 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-postgres"
|
name = "tokio-postgres"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ crossterm = "0.29"
|
|||||||
postgres = "0.19"
|
postgres = "0.19"
|
||||||
postgres-native-tls = "0.5"
|
postgres-native-tls = "0.5"
|
||||||
native-tls = "0.2"
|
native-tls = "0.2"
|
||||||
|
postgres-openssl = "0.5"
|
||||||
|
openssl = "0.10"
|
||||||
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
|
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
|
||||||
shellexpand = { version = "3.1", features = ["path"] }
|
shellexpand = { version = "3.1", features = ["path"] }
|
||||||
sqlparser = { version = "0.63", default-features = false, features = ["std"] }
|
sqlparser = { version = "0.63", default-features = false, features = ["std"] }
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use std::{path::Path, time::Duration};
|
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::{Client, Config, config::SslMode};
|
||||||
use postgres_native_tls::MakeTlsConnector;
|
use postgres_native_tls::MakeTlsConnector;
|
||||||
|
|
||||||
@@ -61,8 +65,12 @@ impl Database {
|
|||||||
if config.get_connect_timeout().is_none() {
|
if config.get_connect_timeout().is_none() {
|
||||||
config.connect_timeout(Duration::from_secs(5));
|
config.connect_timeout(Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
let tls = MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), root_cert)?);
|
let mut client = if let Some(path) = root_cert {
|
||||||
let mut client = config.connect(tls)?;
|
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.
|
// Views can invoke functions: enforce read-only on the server, not just in the UI.
|
||||||
client.batch_execute(
|
client.batch_execute(
|
||||||
"SET default_transaction_read_only = on; SET statement_timeout = '5s';",
|
"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> {
|
fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result<SslConnector> {
|
||||||
let mut tls = TlsConnector::builder();
|
if mode != SslMode::Require {
|
||||||
if let Some(path) = root_cert {
|
return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into());
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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<i64> {
|
fn page_offset(page: usize) -> Result<i64> {
|
||||||
@@ -325,18 +335,105 @@ pub fn display_text(value: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> {
|
fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> {
|
||||||
let bundle = tempfile::NamedTempFile::new()?;
|
let bundle = tempfile::NamedTempFile::new()?;
|
||||||
for mode in [SslMode::Prefer, SslMode::Disable] {
|
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-----"] {
|
for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] {
|
||||||
std::fs::write(bundle.path(), contents)?;
|
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");
|
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(())
|
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
|
and specify sslmode=require. Unset PGSSLROOTCERT; legacy launchers are rejected
|
||||||
instead of silently losing their custom CA verification.
|
instead of silently losing their custom CA verification.
|
||||||
Only the selected bundle is trusted for that connection; system trust is unchanged.
|
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
|
Certificates and hostnames are verified before authentication; no plaintext fallback
|
||||||
with a CA bundle. Missing, empty, or invalid selected files block connection.
|
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.
|
Use a bundle from the trusted issuer, not an unverified server's certificate.
|
||||||
|
|||||||
Reference in New Issue
Block a user