Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions integration/load_balancer/pgx/reload_auto_role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ func TestReloadWithAutoRole(t *testing.T) {
t.Logf("reloads: %d, write errors: %d, read errors: %d",
reloads.Load(), writeErrors.Load(), readErrors.Load())

assert.LessOrEqual(t, writeErrors.Load(), 5, "expected no write errors from reload with auto role detection")
assert.LessOrEqual(t, readErrors.Load(), 5, "expected no read errors from reload with auto role detection")
assert.LessOrEqual(t, writeErrors.Load(), int64(5), "expected no write errors from reload with auto role detection")
assert.LessOrEqual(t, readErrors.Load(), int64(5), "expected no read errors from reload with auto role detection")
}

// TestReconnectWithAutoRole validates that RECONNECT doesn't break read/write
Expand Down
9 changes: 9 additions & 0 deletions integration/tls/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ set -e
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source ${SCRIPT_DIR}/../common.sh

# Force backend Postgres to require TLS in GitHub CI so we exercise the
# client<->PgDog<->Postgres path end to end. Skipped locally because dev
# clusters aren't guaranteed to have server certs configured.
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
psql -c "ALTER SYSTEM SET ssl TO on"
PSQL_VERSION=$(psql -tAc "SELECT current_setting('server_version_num')::int / 10000")
sudo pg_ctlcluster "${PSQL_VERSION}" main restart
fi

run_pgdog integration/tls

# psql requires private keys to be 0600 (git doesn't preserve this).
Expand Down
191 changes: 130 additions & 61 deletions pgdog/src/net/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,93 +179,84 @@ fn build_acceptor(cert: &Path, key: &Path, client_ca: Option<&Path>) -> Result<T
}

fn build_client_cert_verifier(ca_path: &Path) -> Result<Arc<dyn ClientCertVerifier>, Error> {
debug!("loading client CA certificate from: {}", ca_path.display());
let roots = load_ca_bundle(ca_path, "client CA")?;

let certs = CertificateDer::pem_file_iter(ca_path)
WebPkiClientVerifier::builder(Arc::new(roots))
.build()
.map_err(|e| invalid_data(format!("failed to build client certificate verifier: {e}")))
}

/// Load a PEM bundle from `path` and turn it into a `RootCertStore`. Every PEM block
/// in the file is added as a trust anchor, so a single file can carry a root CA
/// together with one or more intermediate CAs that signed leaf client certificates.
fn load_ca_bundle(path: &Path, label: &str) -> Result<rustls::RootCertStore, Error> {
debug!("loading {label} bundle from {}", path.display());

let certs = CertificateDer::pem_file_iter(path)
.map_err(|e| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to read client CA certificate file: {}", e),
invalid_data(format!(
"failed to read {label} file {}: {e}",
path.display()
))
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse client CA certificates: {}", e),
invalid_data(format!(
"failed to parse {label} from {}: {e}",
path.display()
))
})?;

if certs.is_empty() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"No valid certificates found in client CA file",
return Err(invalid_data(format!(
"no PEM certificates found in {label} file {}",
path.display()
)));
}

let total = certs.len();
let mut roots = rustls::RootCertStore::empty();
let (added, _ignored) = roots.add_parsable_certificates(certs);
debug!("added {} client CA certificates from file", added);
let (added, ignored) = roots.add_parsable_certificates(certs);

if added == 0 {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"No valid certificates could be added from client CA file",
if ignored > 0 {
return Err(invalid_data(format!(
"{ignored} of {total} certificates in {label} bundle {} could not be loaded as trust anchors",
path.display()
)));
}

WebPkiClientVerifier::builder(Arc::new(roots))
.build()
.map_err(|e| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to build client certificate verifier: {}", e),
))
})
}
if added == 0 {
return Err(invalid_data(format!(
"no valid trust anchors in {label} bundle {}",
path.display()
)));
}

fn build_connector(config_key: &ConnectorConfigKey) -> Result<Arc<ClientConfig>, Error> {
let mut roots = rustls::RootCertStore::empty();
info!(
path = %path.display(),
certs = added,
"🔐 loaded {label} bundle"
);

if let Some(ca_path) = config_key.ca_path.as_ref() {
debug!("loading CA certificate from: {}", ca_path.display());

let certs = CertificateDer::pem_file_iter(ca_path)
.map_err(|e| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to read CA certificate file: {}", e),
))
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse CA certificates: {}", e),
))
})?;

if certs.is_empty() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"No valid certificates found in CA file",
)));
}
Ok(roots)
}

let (added, _ignored) = roots.add_parsable_certificates(certs);
debug!("added {} CA certificates from file", added);
fn invalid_data(msg: impl Into<String>) -> Error {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
msg.into(),
))
}

if added == 0 {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"No valid certificates could be added from CA file",
)));
}
fn build_connector(config_key: &ConnectorConfigKey) -> Result<Arc<ClientConfig>, Error> {
let roots = if let Some(ca_path) = config_key.ca_path.as_ref() {
load_ca_bundle(ca_path, "server CA")?
} else if matches!(
config_key.mode,
TlsVerifyMode::VerifyCa | TlsVerifyMode::VerifyFull
) {
debug!("no custom CA certificate provided, loading system certificates");
let mut roots = rustls::RootCertStore::empty();
let result = rustls_native_certs::load_native_certs();
for cert in result.certs {
roots.add(cert)?;
Expand All @@ -277,7 +268,10 @@ fn build_connector(config_key: &ConnectorConfigKey) -> Result<Arc<ClientConfig>,
);
}
debug!("loaded {} system CA certificates", roots.len());
}
roots
} else {
rustls::RootCertStore::empty()
};

let config = match config_key.mode {
TlsVerifyMode::Disabled => ClientConfig::builder()
Expand Down Expand Up @@ -714,4 +708,79 @@ mod tests {
fn cn_from_empty_certs() {
assert_eq!(cn_from_certs(&[]), None);
}

#[test]
fn load_ca_bundle_loads_full_chain() {
crate::logger();

let chain = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/ca_chain.pem");
let roots = super::load_ca_bundle(&chain, "client CA")
.expect("ca_chain.pem bundles root + intermediate");

assert_eq!(roots.len(), 2, "every PEM block becomes a trust anchor");
}

#[test]
fn load_ca_bundle_errors_on_missing_file() {
crate::logger();
let missing = PathBuf::from("/tmp/pgdog_nonexistent_ca.pem");
assert!(super::load_ca_bundle(&missing, "client CA").is_err());
}

#[test]
fn client_cert_verifier_accepts_intermediate_signed_cert() {
crate::logger();

let chain = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/ca_chain.pem");
let verifier =
super::build_client_cert_verifier(&chain).expect("verifier builds from chain bundle");

let client_pem = include_str!("../../tests/tls/client_signed_by_intermediate.pem");
let leaf: CertificateDer<'static> =
rustls_pki_types::CertificateDer::pem_slice_iter(client_pem.as_bytes())
.next()
.expect("client cert PEM has one block")
.expect("client cert parses");

// Use the cert's notBefore as "now" so the test does not drift if the fixture
// gets regenerated with a non-current validity window.
use x509_parser::certificate::X509Certificate;
let (_, parsed) = X509Certificate::from_der(&leaf).expect("parse leaf cert");
let now = rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(
parsed.validity().not_before.timestamp() as u64 + 60,
));

verifier
.verify_client_cert(&leaf, &[], now)
.expect("intermediate trust anchor accepts leaf signed by it");
}

#[test]
fn client_cert_verifier_rejects_unknown_signer_when_only_root_loaded() {
crate::logger();

// Trust store contains only the root; client presents only the leaf
// (no intermediate in the handshake), so webpki cannot build the chain.
let root_only = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/ca_root.pem");
let verifier = super::build_client_cert_verifier(&root_only)
.expect("verifier builds from root-only bundle");

let client_pem = include_str!("../../tests/tls/client_signed_by_intermediate.pem");
let leaf: CertificateDer<'static> =
rustls_pki_types::CertificateDer::pem_slice_iter(client_pem.as_bytes())
.next()
.expect("client cert PEM has one block")
.expect("client cert parses");

use x509_parser::certificate::X509Certificate;
let (_, parsed) = X509Certificate::from_der(&leaf).expect("parse leaf cert");
let now = rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(
parsed.validity().not_before.timestamp() as u64 + 60,
));

assert!(
verifier.verify_client_cert(&leaf, &[], now).is_err(),
"leaf signed by missing intermediate must be rejected"
);
}
}
39 changes: 39 additions & 0 deletions pgdog/tests/tls/ca_chain.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-----BEGIN CERTIFICATE-----
MIIDMDCCAhigAwIBAgIUNbZiJgI3lNf/AKcHxZtJJhML058wDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPUGdEb2cgVGVzdCBSb290MB4XDTI2MDUyMjE2NDAwOFoX
DTM2MDUxOTE2NDAwOFowIjEgMB4GA1UEAwwXUGdEb2cgVGVzdCBJbnRlcm1lZGlh
dGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzjL2kYgLdLOJpy+YC
V9+S2trjzw9pCfFmE0QQ2UOJNu0pDW1x5or+YbLBygz4mWBzVBLF2t8qFpX9MKGX
O0vh1rcmHqC5Ih0ZDko2S1aQBPbyDp+ZWCotEImRsBzQvIdHP3ONWkj2aMo1G9Vz
8OfRgViTaR8NwRswhFi0YZTzccltvuPE4H2fgfsMOa2+/uuTRBc9mrmTpkurKO4x
arWB9D8xBwXUClC4PWa8mXwyzMxxpUVBlb/mD3Dxojgf5+8/g0bQ81CBYpf/pUoy
jDzct6wYdOsvx4+CHX6mWntQ0vKF3uk4Kbg1aUW9xfXr4i97JgAuqYnJN9KquuhB
7sgDAgMBAAGjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEG
MB0GA1UdDgQWBBSj7hC3e+fc6Ch7v3bj6YsKaopSozAfBgNVHSMEGDAWgBTNRDLr
N0oIVI6H9uQifnNL2St+QTANBgkqhkiG9w0BAQsFAAOCAQEAjS4XY/XVkdM0aG2t
cZR1Dcx9/WR4NGq8QHoJufL/4mVJBN3vmEan1FoGnG+I9XKntxHFrnkY7Rok4kjK
HFOlMOaVa39YobK46LbWbstvuigCLgAq3c/bRHDugH/FovSiK+DpNbAv9gRZKsEg
HtTGQ/66kEGttNBwBejlk2gR9/4lEWT39YLjUKwuac4q4XV3JZ4LuovNNnkJD3Bg
wZHJ46yegcUsAtvQz97p1pWlWLMjvMhe1OGQFoF3rXo2IpNxtZRskIFCFzlx7qpH
S9iSapONS0Xmb/LeVtieNMyHfPT5lMIG7FQyyK8AqsL6Cft5burivqtRFtasDGMx
5q6SHA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUMStMbZtebPcG1K2pH+cHix7A3bgwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPUGdEb2cgVGVzdCBSb290MB4XDTI2MDUyMjE2NDAwMloX
DTM2MDUxOTE2NDAwMlowGjEYMBYGA1UEAwwPUGdEb2cgVGVzdCBSb290MIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq9iPP2qLXtYPlWTNLzN99Gn8hZXc
DNnZuPzjHcGyDv+o+6qxhlNASiAwLI1ieETTtYvLMoOoZKTN0GpMtZJzNAlw0bbS
yxB2bIMIVUqSkwhZG3r288nPo7ioF3vKccr5ymAB7BxIx66O2F4qbq3fQCNCJcnl
jooa4b991D+4aCHDBixdTg+U+KSoTobp9+YnF6lEwv2LsB81cDBjE5DxBtrJhcOD
or6N23xkULeHnpRzCRl/HfkIaCiY85awGHuMeYnfIhbxeyqDmroO4q4oxB80Ft3D
/BHj5ccIHRBOI7dkjNZR4PM60rdUn4AAnqM9+wYtW8vDEAha9FQ64itBowIDAQAB
o2MwYTAdBgNVHQ4EFgQUzUQy6zdKCFSOh/bkIn5zS9krfkEwHwYDVR0jBBgwFoAU
zUQy6zdKCFSOh/bkIn5zS9krfkEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E
BAMCAQYwDQYJKoZIhvcNAQELBQADggEBAAOBg8RHwjRsGBh+zJoXlXgc6uibmd13
wYnGriI2k/xAEFmnq+QqjU9U87HDJZWY6bfaXvcnsymsTh/G41uRF0L9ZIcWuAOw
CN+O4PSRbJSoqfcN5ptgQtkIaZvtaVPKQriSCpcr/VYL4DYxbwe4b2Hb7rA/RqXR
snaJs7h0GuWYUAVRZEkmcPlF3uZZo8xSW7UGywO84hH3fah3Y3cn4hreNkzsKjb/
elpcleR0nrDmzPvNp1t/VvZehTp25ocqyP5T55ccthEnFIoubpRPiXNm4GBQ2zDp
LmSEfQesuZ9uFj/xMeDUxrOMg+qhoxLSgr9kkkt4ArwataFMa9re6pI=
-----END CERTIFICATE-----
20 changes: 20 additions & 0 deletions pgdog/tests/tls/ca_intermediate.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDMDCCAhigAwIBAgIUNbZiJgI3lNf/AKcHxZtJJhML058wDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPUGdEb2cgVGVzdCBSb290MB4XDTI2MDUyMjE2NDAwOFoX
DTM2MDUxOTE2NDAwOFowIjEgMB4GA1UEAwwXUGdEb2cgVGVzdCBJbnRlcm1lZGlh
dGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzjL2kYgLdLOJpy+YC
V9+S2trjzw9pCfFmE0QQ2UOJNu0pDW1x5or+YbLBygz4mWBzVBLF2t8qFpX9MKGX
O0vh1rcmHqC5Ih0ZDko2S1aQBPbyDp+ZWCotEImRsBzQvIdHP3ONWkj2aMo1G9Vz
8OfRgViTaR8NwRswhFi0YZTzccltvuPE4H2fgfsMOa2+/uuTRBc9mrmTpkurKO4x
arWB9D8xBwXUClC4PWa8mXwyzMxxpUVBlb/mD3Dxojgf5+8/g0bQ81CBYpf/pUoy
jDzct6wYdOsvx4+CHX6mWntQ0vKF3uk4Kbg1aUW9xfXr4i97JgAuqYnJN9KquuhB
7sgDAgMBAAGjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEG
MB0GA1UdDgQWBBSj7hC3e+fc6Ch7v3bj6YsKaopSozAfBgNVHSMEGDAWgBTNRDLr
N0oIVI6H9uQifnNL2St+QTANBgkqhkiG9w0BAQsFAAOCAQEAjS4XY/XVkdM0aG2t
cZR1Dcx9/WR4NGq8QHoJufL/4mVJBN3vmEan1FoGnG+I9XKntxHFrnkY7Rok4kjK
HFOlMOaVa39YobK46LbWbstvuigCLgAq3c/bRHDugH/FovSiK+DpNbAv9gRZKsEg
HtTGQ/66kEGttNBwBejlk2gR9/4lEWT39YLjUKwuac4q4XV3JZ4LuovNNnkJD3Bg
wZHJ46yegcUsAtvQz97p1pWlWLMjvMhe1OGQFoF3rXo2IpNxtZRskIFCFzlx7qpH
S9iSapONS0Xmb/LeVtieNMyHfPT5lMIG7FQyyK8AqsL6Cft5burivqtRFtasDGMx
5q6SHA==
-----END CERTIFICATE-----
19 changes: 19 additions & 0 deletions pgdog/tests/tls/ca_root.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUMStMbZtebPcG1K2pH+cHix7A3bgwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPUGdEb2cgVGVzdCBSb290MB4XDTI2MDUyMjE2NDAwMloX
DTM2MDUxOTE2NDAwMlowGjEYMBYGA1UEAwwPUGdEb2cgVGVzdCBSb290MIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq9iPP2qLXtYPlWTNLzN99Gn8hZXc
DNnZuPzjHcGyDv+o+6qxhlNASiAwLI1ieETTtYvLMoOoZKTN0GpMtZJzNAlw0bbS
yxB2bIMIVUqSkwhZG3r288nPo7ioF3vKccr5ymAB7BxIx66O2F4qbq3fQCNCJcnl
jooa4b991D+4aCHDBixdTg+U+KSoTobp9+YnF6lEwv2LsB81cDBjE5DxBtrJhcOD
or6N23xkULeHnpRzCRl/HfkIaCiY85awGHuMeYnfIhbxeyqDmroO4q4oxB80Ft3D
/BHj5ccIHRBOI7dkjNZR4PM60rdUn4AAnqM9+wYtW8vDEAha9FQ64itBowIDAQAB
o2MwYTAdBgNVHQ4EFgQUzUQy6zdKCFSOh/bkIn5zS9krfkEwHwYDVR0jBBgwFoAU
zUQy6zdKCFSOh/bkIn5zS9krfkEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E
BAMCAQYwDQYJKoZIhvcNAQELBQADggEBAAOBg8RHwjRsGBh+zJoXlXgc6uibmd13
wYnGriI2k/xAEFmnq+QqjU9U87HDJZWY6bfaXvcnsymsTh/G41uRF0L9ZIcWuAOw
CN+O4PSRbJSoqfcN5ptgQtkIaZvtaVPKQriSCpcr/VYL4DYxbwe4b2Hb7rA/RqXR
snaJs7h0GuWYUAVRZEkmcPlF3uZZo8xSW7UGywO84hH3fah3Y3cn4hreNkzsKjb/
elpcleR0nrDmzPvNp1t/VvZehTp25ocqyP5T55ccthEnFIoubpRPiXNm4GBQ2zDp
LmSEfQesuZ9uFj/xMeDUxrOMg+qhoxLSgr9kkkt4ArwataFMa9re6pI=
-----END CERTIFICATE-----
20 changes: 20 additions & 0 deletions pgdog/tests/tls/client_signed_by_intermediate.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDQTCCAimgAwIBAgIUNppAX0RIYSjUHmhXm4DbXTSi+xcwDQYJKoZIhvcNAQEL
BQAwIjEgMB4GA1UEAwwXUGdEb2cgVGVzdCBJbnRlcm1lZGlhdGUwHhcNMjYwNTIy
MTY0MTIzWhcNMzYwNTE5MTY0MTIzWjAcMRowGAYDVQQDDBFwZ2RvZy10ZXN0LWNs
aWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMICD7yKxp3chqMd
PczFZArTX4D+XmuuJHueeuaOf6X7q5bR9u629t3E3dKABmQwG+tKEZQkMAINj0k4
cpPI3spul2Ir+/h22225SQb3cSrAKlQwLF/MAqb1RmxJQrzVgO/F+ChBN+1voe9O
9nIbNI53BJLEKHvtXmgc6RSjhPyMyZ3PXznnWcak5iFTXwCv4xskSzW9XmkPHv9H
Fnw7So6JTEmKiXa+4Q86GYnNbo5q8RhKy11aTuejkT5KTn3WMjhPabzC/Vzj4nPg
X0qruY5bBLcAkpEpjOy16AdUK0kjkxy/PLA7fG5cxvDjxK6hzI6+/HBAY0qKUjk0
dIbj8zECAwEAAaN1MHMwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYD
VR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFDCamTERPmqBXCHpBuMsrfmCaNwn
MB8GA1UdIwQYMBaAFKPuELd759zoKHu/duPpiwpqilKjMA0GCSqGSIb3DQEBCwUA
A4IBAQBdM/3mPl38TMLAoRJpYXETh/Wrpt0zRHbKL1SHzVx9ttrx9P2JkEhfUCQo
7s6XRuvEjnolT+WY1L+qjjTs/xw0ssZgnxKmgIoXaDYu3KwBq1Hci27VtawRxie9
Wk10BtFRVyS7OE4vVFsrfRz3uOgw5qwwgndm+P5p2A4oLlDYGBhtjYiF9v/ylOGk
hGIbzeOKxFB0wP/QT0sbtdPNy/sHXFNRs9cWRYhVzVkiRmpKLBJWxyKb67ztyB2u
PE1t5AbzF2BcYAK5f8XamHxNneNnVIl5gQsJ0ldPhIGvN3JoQBMoL6ez6/F71N+G
Xp7w+9r2WOVBaerUknu48KOgZJmv
-----END CERTIFICATE-----
Loading