use areev_core::error::{AreevError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SslMode {
Disable,
Prefer,
Require,
VerifyCa,
VerifyFull,
}
impl SslMode {
fn parse(v: &str) -> Result<Self> {
match v {
"disable" => Ok(Self::Disable),
"allow" | "prefer" => Ok(Self::Prefer),
"require" => Ok(Self::Require),
"verify-ca" => Ok(Self::VerifyCa),
"verify-full" => Ok(Self::VerifyFull),
other => Err(AreevError::Validation(format!(
"unknown sslmode {other:?} — expected one of disable, allow, prefer, \
require, verify-ca, verify-full"
))),
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Disable => "disable",
Self::Prefer => "prefer",
Self::Require => "require",
Self::VerifyCa => "verify-ca",
Self::VerifyFull => "verify-full",
}
}
#[cfg(not(feature = "postgres-tls"))]
pub(crate) fn demands_tls(self) -> bool {
matches!(self, Self::Require | Self::VerifyCa | Self::VerifyFull)
}
#[cfg(feature = "postgres-tls")]
fn verifies_chain(self) -> bool {
matches!(self, Self::VerifyCa | Self::VerifyFull)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SslRequest {
pub(crate) dsn: String,
pub(crate) mode: SslMode,
pub(crate) root_cert: Option<String>,
}
impl SslRequest {
pub(crate) fn split(url: &str) -> Result<Self> {
let Some((base, query)) = url.split_once('?') else {
return Ok(Self { dsn: url.to_string(), mode: SslMode::Prefer, root_cert: None });
};
let mut mode = SslMode::Prefer;
let mut root_cert = None;
let mut rest: Vec<&str> = Vec::new();
for pair in query.split('&') {
match pair.split_once('=') {
Some(("sslmode", v)) => mode = SslMode::parse(v)?,
Some(("sslrootcert", v)) if !v.is_empty() => root_cert = Some(v.to_string()),
Some(("provision", _)) | Some(("pool", _)) | Some(("pool_idle_secs", _)) => {}
_ => rest.push(pair),
}
}
let dsn = if rest.is_empty() {
base.to_string()
} else {
format!("{base}?{}", rest.join("&"))
};
Ok(Self { dsn, mode, root_cert })
}
}
#[cfg(not(feature = "postgres-tls"))]
fn compiled_out(mode: SslMode) -> AreevError {
AreevError::TlsUnavailable(format!(
"the DSN asks for sslmode={} but Postgres TLS was not compiled into this build \
(cargo feature \"postgres-tls\"). Refusing rather than connecting in plaintext. \
Rebuild with the feature, or terminate TLS in a local proxy (Cloud SQL Auth Proxy, \
PgBouncer with a TLS upstream, session mode) and point the DSN at it with \
sslmode=disable",
mode.as_str()
))
}
pub(crate) fn connect(
rt: &tokio::runtime::Runtime,
url: &str,
) -> Result<tokio_postgres::Client> {
let req = SslRequest::split(url)?;
let mut cfg: tokio_postgres::Config = req.dsn.parse().map_err(crate::pg::pg_err)?;
if cfg.get_application_name().is_none() {
cfg.application_name("areev");
}
cfg.ssl_mode(match req.mode {
SslMode::Disable => tokio_postgres::config::SslMode::Disable,
SslMode::Prefer => tokio_postgres::config::SslMode::Prefer,
SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => {
tokio_postgres::config::SslMode::Require
}
});
#[cfg(not(feature = "postgres-tls"))]
{
if req.mode.demands_tls() {
return Err(compiled_out(req.mode));
}
if req.root_cert.is_some() {
return Err(AreevError::Validation(
"the DSN sets sslrootcert but Postgres TLS was not compiled into this build \
(cargo feature \"postgres-tls\")"
.into(),
));
}
spawn_plaintext(rt, &cfg)
}
#[cfg(feature = "postgres-tls")]
{
if req.mode == SslMode::Disable {
if req.root_cert.is_some() {
return Err(AreevError::Validation(
"the DSN sets both sslmode=disable and sslrootcert — the connection would \
be plaintext and the root bundle unused; drop one"
.into(),
));
}
return spawn_plaintext(rt, &cfg);
}
let tls = tokio_postgres_rustls::MakeRustlsConnect::new(rustls_config(&req)?);
let (client, connection) = rt.block_on(cfg.connect(tls)).map_err(crate::pg::pg_err)?;
rt.spawn(async move {
let _ = connection.await;
});
Ok(client)
}
}
fn spawn_plaintext(
rt: &tokio::runtime::Runtime,
cfg: &tokio_postgres::Config,
) -> Result<tokio_postgres::Client> {
let (client, connection) = rt
.block_on(cfg.connect(tokio_postgres::NoTls))
.map_err(crate::pg::pg_err)?;
rt.spawn(async move {
let _ = connection.await;
});
Ok(client)
}
#[cfg(feature = "postgres-tls")]
mod verify {
use std::sync::Arc;
use areev_core::error::{AreevError, Result};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::CryptoProvider;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{CertificateError, DigitallySignedStruct, Error as TlsError, SignatureScheme};
use super::{SslMode, SslRequest};
#[derive(Debug)]
pub(super) struct AcceptAny(Arc<CryptoProvider>);
impl ServerCertVerifier for AcceptAny {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, TlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, TlsError> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, TlsError> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
#[derive(Debug)]
pub(super) struct AnyHostname(Arc<dyn ServerCertVerifier>);
impl ServerCertVerifier for AnyHostname {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp: &[u8],
now: UnixTime,
) -> std::result::Result<ServerCertVerified, TlsError> {
match self.0.verify_server_cert(end_entity, intermediates, server_name, ocsp, now) {
Err(TlsError::InvalidCertificate(
CertificateError::NotValidForName
| CertificateError::NotValidForNameContext { .. },
)) => Ok(ServerCertVerified::assertion()),
other => other,
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, TlsError> {
self.0.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, TlsError> {
self.0.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.supported_verify_schemes()
}
}
pub(super) fn roots(req: &SslRequest) -> Result<rustls::RootCertStore> {
let mut store = rustls::RootCertStore::empty();
match req.root_cert.as_deref() {
None | Some("system") => {
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
Some(path) => {
use rustls::pki_types::pem::PemObject;
let certs = CertificateDer::pem_file_iter(path).map_err(|e| {
AreevError::Validation(format!("sslrootcert {path:?}: {e}"))
})?;
for cert in certs {
let cert = cert.map_err(|e| {
AreevError::Validation(format!("sslrootcert {path:?}: {e}"))
})?;
store
.add(cert)
.map_err(|e| AreevError::Validation(format!("sslrootcert {path:?}: {e}")))?;
}
if store.is_empty() {
return Err(AreevError::Validation(format!(
"sslrootcert {path:?} contained no PEM certificates"
)));
}
}
}
Ok(store)
}
pub(super) fn verifier(
req: &SslRequest,
provider: &Arc<CryptoProvider>,
) -> Result<Arc<dyn ServerCertVerifier>> {
if !req.mode.verifies_chain() {
return Ok(Arc::new(AcceptAny(Arc::clone(provider))));
}
let webpki = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots(req)?),
Arc::clone(provider),
)
.build()
.map_err(|e| {
AreevError::Validation(format!(
"postgres TLS verifier for sslmode={}: {e}",
req.mode.as_str()
))
})?;
Ok(match req.mode {
SslMode::VerifyFull => webpki,
_ => Arc::new(AnyHostname(webpki)),
})
}
}
#[cfg(feature = "postgres-tls")]
fn rustls_config(req: &SslRequest) -> Result<rustls::ClientConfig> {
use std::sync::Arc;
let provider = Arc::new(rustls::crypto::ring::default_provider());
let verifier = verify::verifier(req, &provider)?;
Ok(rustls::ClientConfig::builder_with_provider(Arc::clone(&provider))
.with_safe_default_protocol_versions()
.map_err(|e| AreevError::Validation(format!("postgres TLS: {e}")))?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_our_two_parameters_and_passes_the_rest_through() {
let r = SslRequest::split(
"postgres://u:p@h:5432/db?sslmode=verify-full&application_name=areev\
&sslrootcert=/etc/ssl/azure.pem",
)
.unwrap();
assert_eq!(r.dsn, "postgres://u:p@h:5432/db?application_name=areev");
assert_eq!(r.mode, SslMode::VerifyFull);
assert_eq!(r.root_cert.as_deref(), Some("/etc/ssl/azure.pem"));
}
#[test]
fn provision_is_stripped_before_the_driver_sees_it() {
let r = SslRequest::split("postgres://h/db?provision=never&application_name=x").unwrap();
assert_eq!(r.dsn, "postgres://h/db?application_name=x");
let r = SslRequest::split("postgres://h/db?provision=auto").unwrap();
assert_eq!(r.dsn, "postgres://h/db");
}
#[test]
fn a_query_that_was_only_ours_leaves_no_dangling_question_mark() {
let r = SslRequest::split("postgres://h/db?sslmode=require").unwrap();
assert_eq!(r.dsn, "postgres://h/db");
assert_eq!(r.mode, SslMode::Require);
}
#[test]
fn no_sslmode_means_prefer() {
assert_eq!(SslRequest::split("postgres://h/db").unwrap().mode, SslMode::Prefer);
assert_eq!(
SslRequest::split("postgres://h/db?application_name=x").unwrap().mode,
SslMode::Prefer
);
}
#[test]
fn the_whole_libpq_ladder_parses() {
for (s, want) in [
("disable", SslMode::Disable),
("allow", SslMode::Prefer),
("prefer", SslMode::Prefer),
("require", SslMode::Require),
("verify-ca", SslMode::VerifyCa),
("verify-full", SslMode::VerifyFull),
] {
let r = SslRequest::split(&format!("postgres://h/db?sslmode={s}")).unwrap();
assert_eq!(r.mode, want, "sslmode={s}");
}
}
#[test]
fn an_unknown_sslmode_is_refused_by_name() {
let e = SslRequest::split("postgres://h/db?sslmode=verify_full").unwrap_err();
assert_eq!(e.code(), "VAL-E001");
assert!(e.to_string().contains("verify_full"), "{e}");
}
#[cfg(not(feature = "postgres-tls"))]
#[test]
fn only_the_encrypting_rungs_demand_tls() {
assert!(!SslMode::Disable.demands_tls());
assert!(!SslMode::Prefer.demands_tls());
assert!(SslMode::Require.demands_tls());
assert!(SslMode::VerifyCa.demands_tls());
assert!(SslMode::VerifyFull.demands_tls());
}
#[cfg(not(feature = "postgres-tls"))]
#[test]
fn the_compiled_out_refusal_names_the_feature_and_the_mode() {
let e = compiled_out(SslMode::VerifyFull);
assert_eq!(e.code(), "STO-E003");
let msg = e.to_string();
assert!(msg.contains("postgres-tls"), "{msg}");
assert!(msg.contains("verify-full"), "{msg}");
assert!(msg.contains("plaintext"), "{msg}");
}
#[cfg(not(feature = "postgres-tls"))]
#[test]
fn a_build_without_tls_refuses_rather_than_downgrades() {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
for mode in ["require", "verify-ca", "verify-full"] {
let e = connect(&rt, &format!("postgres://u@127.0.0.1:1/db?sslmode={mode}"))
.expect_err("{mode} must be refused");
assert_eq!(e.code(), "STO-E003", "{mode}: {e}");
}
}
#[cfg(feature = "postgres-tls")]
#[test]
fn a_root_bundle_that_is_not_a_bundle_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.pem");
std::fs::write(&path, b"not a certificate\n").unwrap();
let req = SslRequest::split(&format!(
"postgres://h/db?sslmode=verify-full&sslrootcert={}",
path.display()
))
.unwrap();
let e = rustls_config(&req).unwrap_err();
assert!(e.to_string().contains("sslrootcert"), "{e}");
}
#[cfg(feature = "postgres-tls")]
#[test]
fn every_encrypting_mode_builds_a_client_config() {
for mode in ["prefer", "require", "verify-ca", "verify-full"] {
let req = SslRequest::split(&format!("postgres://h/db?sslmode={mode}")).unwrap();
rustls_config(&req).unwrap_or_else(|e| panic!("sslmode={mode}: {e}"));
}
}
}
#[cfg(all(test, feature = "postgres-tls"))]
mod handshake_tests {
use std::io::{Read, Write};
use std::sync::mpsc;
use super::*;
struct Pki {
ca_pem: String,
leaf_der: rustls::pki_types::CertificateDer<'static>,
leaf_key: rustls::pki_types::PrivateKeyDer<'static>,
}
fn pki(sans: &[&str]) -> Pki {
use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair};
let ca_key = KeyPair::generate().unwrap();
let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let issuer = Issuer::from_params(&ca_params, &ca_key);
let leaf_key = KeyPair::generate().unwrap();
let leaf_params =
CertificateParams::new(sans.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
let leaf_cert = leaf_params.signed_by(&leaf_key, &issuer).unwrap();
Pki {
ca_pem: ca_cert.pem(),
leaf_der: leaf_cert.der().clone(),
leaf_key: rustls::pki_types::PrivateKeyDer::try_from(leaf_key.serialize_der())
.unwrap()
.clone_key(),
}
}
fn serve(pki: &Pki) -> (u16, mpsc::Receiver<bool>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let cfg = rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![pki.leaf_der.clone()], pki.leaf_key.clone_key())
.unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let Ok((mut sock, _)) = listener.accept() else {
let _ = tx.send(false);
return;
};
let mut hello = [0u8; 8];
if sock.read_exact(&mut hello).is_err() || sock.write_all(b"S").is_err() {
let _ = tx.send(false);
return;
}
let mut conn = rustls::ServerConnection::new(std::sync::Arc::new(cfg)).unwrap();
let _ = tx.send(conn.complete_io(&mut sock).is_ok());
});
(port, rx)
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap()
}
#[test]
fn require_encrypts_without_validating() {
let pki = pki(&["127.0.0.1"]);
let (port, handshake) = serve(&pki);
let err = connect(&rt(), &format!("postgres://u@127.0.0.1:{port}/db?sslmode=require"))
.expect_err("the fake server hangs up after the handshake");
assert!(handshake.recv().unwrap(), "TLS must have been established: {err}");
assert!(
!err.to_string().to_lowercase().contains("certificate"),
"require must not have failed on the certificate: {err}"
);
}
#[test]
fn verify_full_refuses_an_untrusted_issuer() {
let pki = pki(&["127.0.0.1"]);
let (port, handshake) = serve(&pki);
let err = connect(&rt(), &format!("postgres://u@127.0.0.1:{port}/db?sslmode=verify-full"))
.expect_err("an untrusted issuer must not connect");
assert!(!handshake.recv().unwrap(), "the client must abort the handshake: {err}");
assert!(
err.to_string().to_lowercase().contains("certificate"),
"the refusal must name the certificate: {err}"
);
}
#[test]
fn sslrootcert_makes_the_private_ca_trusted() {
let pki = pki(&["127.0.0.1"]);
let dir = tempfile::tempdir().unwrap();
let ca = dir.path().join("ca.pem");
std::fs::write(&ca, pki.ca_pem.as_bytes()).unwrap();
let (port, handshake) = serve(&pki);
let err = connect(
&rt(),
&format!(
"postgres://u@127.0.0.1:{port}/db?sslmode=verify-full&sslrootcert={}",
ca.display()
),
)
.expect_err("the fake server hangs up after the handshake");
assert!(handshake.recv().unwrap(), "the named CA must be trusted: {err}");
assert!(
!err.to_string().to_lowercase().contains("certificate"),
"the certificate must have verified: {err}"
);
}
#[test]
fn verify_ca_keeps_the_chain_and_drops_the_hostname() {
let pki = pki(&["localhost"]);
let dir = tempfile::tempdir().unwrap();
let ca = dir.path().join("ca.pem");
std::fs::write(&ca, pki.ca_pem.as_bytes()).unwrap();
let (port, handshake) = serve(&pki);
let err = connect(
&rt(),
&format!(
"postgres://u@127.0.0.1:{port}/db?sslmode=verify-ca&sslrootcert={}",
ca.display()
),
)
.expect_err("the fake server hangs up after the handshake");
assert!(handshake.recv().unwrap(), "verify-ca must ignore the name mismatch: {err}");
let (port, handshake) = serve(&pki);
let err = connect(
&rt(),
&format!(
"postgres://u@127.0.0.1:{port}/db?sslmode=verify-full&sslrootcert={}",
ca.display()
),
)
.expect_err("verify-full must reject the name mismatch");
assert!(!handshake.recv().unwrap(), "verify-full must abort on the name: {err}");
}
}