diff --git a/AUTHORS b/AUTHORS index 4c19098a6a83787c0ba005666b8551dfc1ebcd62..16ae5e9566e2d990ce708cbc7b2d9d983ce9a1c9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -32,6 +32,7 @@ to add your name to it. - Konstantin Osipov - Maksim Kaitmazian - Roman Khait +- Roman Kuzmin - Sergey Vustin - Valentin Syrovatskiy - Vartan Babayan diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f79b0c99e366746619da87e1e5a9ead1b63787f..b2647bafe023da703f25ce62309f33a061d59615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,16 @@ users must specify the expected type of the parameter: - Ability to change cluster properties via SQL `ALTER SYSTEM` command +- Mutual TLS authentication for Pgproto. + + 1. Set `instance.pg.ssl` configuration parameter to `true` + 1. Put PEM-encoded `ca.crt` file into instance's data directory along with `server.crt` and `server.key`. + + As a result pgproto server will only accept connection if client has presented a certificate + which was signed by `ca.crt` or it's derivatives. + + If `ca.crt` is absent in instance's data directory, then client certificates are not requested and not validated. + ### Compatibility - New index for the system table `_pico_replicaset` - `_pico_replicaset_uuid` diff --git a/src/pgproto.rs b/src/pgproto.rs index 07e8f812c72015de493ec8e61e00a6470a70467e..0082badf086f7f99e1ae6838344a505101b5beff 100644 --- a/src/pgproto.rs +++ b/src/pgproto.rs @@ -114,12 +114,28 @@ impl Context { .map_err(Error::invalid_configuration)?; let addr = (host, port); - tlog!(Info, "starting postgres server at {:?}...", addr); - let server = server::new_listener(addr)?; + + let tls_note = match &tls_acceptor { + Some(acceptor) => { + if acceptor.mtls() { + " with mTLS" + } else { + " with TLS" + } + } + _ => "", + }; + + tlog!( + Info, + "starting postgres server at {:?}{}...", + addr, + tls_note + ); Ok(Self { - server, tls_acceptor, + server: server::new_listener(addr)?, }) } } diff --git a/src/pgproto/tls.rs b/src/pgproto/tls.rs index bb57160477603d08d3726c43efda16d1a52b68fa..45eb9fb199e882152d8b4e0a75191d3d3ff08626 100644 --- a/src/pgproto/tls.rs +++ b/src/pgproto/tls.rs @@ -1,7 +1,11 @@ +use openssl::ssl::SslVerifyMode; +use openssl::x509::store::X509StoreBuilder; +use openssl::x509::X509; use openssl::{ error::ErrorStack, ssl::{self, HandshakeError, SslFiletype, SslMethod, SslStream}, }; + use std::{fs, io, path::Path, path::PathBuf, rc::Rc}; use thiserror::Error; @@ -24,12 +28,17 @@ pub enum TlsConfigError { #[error("key file error '{0}': {1}")] KeyFile(PathBuf, std::io::Error), + + #[error("ca file error '{0}': {1}")] + CaFile(PathBuf, std::io::Error), } #[derive(Debug)] pub struct TlsConfig { cert: PathBuf, key: PathBuf, + // Optional CA certificate for peer certificates verification (mTLS). + ca_cert: Option<PathBuf>, } impl TlsConfig { @@ -42,9 +51,16 @@ impl TlsConfig { let key = data_dir.join("server.key"); let key = fs::canonicalize(&key).map_err(|e| TlsConfigError::KeyFile(key, e))?; + let ca_cert = data_dir.join("ca.crt"); + let ca_cert = match fs::canonicalize(&ca_cert) { + Ok(path) => Some(path), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => Err(TlsConfigError::CaFile(ca_cert, e))?, + }; + // TODO: Make sure that the file permissions are set to 0640 or 0600. // See https://www.postgresql.org/docs/current/ssl-tcp.html#SSL-SETUP for details. - Ok(Self { key, cert }) + Ok(Self { key, cert, ca_cert }) } } @@ -58,6 +74,18 @@ impl TlsAcceptor { let mut builder = ssl::SslAcceptor::mozilla_intermediate_v5(SslMethod::tls())?; builder.set_certificate_chain_file(&config.cert)?; builder.set_private_key_file(&config.key, SslFiletype::PEM)?; + + if let Some(path) = &config.ca_cert { + let pem = fs::read(path).map_err(|e| TlsConfigError::CaFile(path.clone(), e))?; + let mut store_builder = X509StoreBuilder::new()?; + store_builder.add_cert(X509::from_pem(&pem)?)?; + builder.set_verify_cert_store(store_builder.build())?; + + let mut verify_mode = SslVerifyMode::PEER; + verify_mode.insert(SslVerifyMode::FAIL_IF_NO_PEER_CERT); + builder.set_verify(verify_mode); + } + Ok(Self(builder.build().into())) } @@ -75,4 +103,8 @@ impl TlsAcceptor { _ => TlsHandshakeError::HandshakeFailure, }) } + + pub fn mtls(&self) -> bool { + self.0.context().verify_mode().contains(SslVerifyMode::PEER) + } } diff --git a/test/conftest.py b/test/conftest.py index c0dfb6c4810699b1534de7d8b7f764b5fd993951..ff56c991bc9ab16eae80dffd750dc77ebe4b1c73 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2237,12 +2237,13 @@ class AuditServer: class Postgres: cluster: Cluster - def __init__(self, cluster: Cluster, ssl: bool = False): + def __init__(self, cluster: Cluster, ssl: bool = False, ssl_verify: bool = False): # use random port in order to avoid "cannot assign requested address" error self.port = random.randint(2000, 30000) self.host = "localhost" self.cluster = cluster self.ssl = ssl + self.ssl_verify = ssl_verify def install(self): self.cluster.set_config_file( @@ -2262,6 +2263,10 @@ instance: instance_dir.mkdir() shutil.copyfile(ssl_dir / "server.crt", instance_dir / "server.crt") shutil.copyfile(ssl_dir / "server.key", instance_dir / "server.key") + + if self.ssl_verify: + shutil.copyfile(ssl_dir / "root.crt", instance_dir / "ca.crt") + self.cluster.deploy(instance_count=1) return self @@ -2278,3 +2283,8 @@ def postgres(cluster: Cluster): @pytest.fixture def postgres_with_tls(cluster: Cluster): return Postgres(cluster, ssl=True).install() + + +@pytest.fixture +def postgres_with_mtls(cluster: Cluster): + return Postgres(cluster, ssl=True, ssl_verify=True).install() diff --git a/test/pgproto/ssl_test.py b/test/pgproto/ssl_test.py index 008b742ceb0c29a8779c0832fcfcd1699568b0a3..2e9752692c99c0df5a355d94528a9c70136ec202 100644 --- a/test/pgproto/ssl_test.py +++ b/test/pgproto/ssl_test.py @@ -45,22 +45,53 @@ def test_ssl_refuse(postgres: Postgres): def test_ssl_accept(postgres_with_tls: Postgres): - # where the server should find .crt and .key files - instance = postgres_with_tls.instance - host = postgres_with_tls.host - port = postgres_with_tls.port + conn = psycopg.connect(prepare_with_tls(postgres_with_tls, "")) + conn.close() + + +def test_mtls_with_known_cert(postgres_with_mtls: Postgres): + conn = psycopg.connect(prepare_with_tls(postgres_with_mtls, "server")) + conn.close() + + +def test_mtls_without_client_cert(postgres_with_mtls: Postgres): + with pytest.raises( + psycopg.OperationalError, + match="(certificate required)|(Connection refused)", + ): + conn = psycopg.connect(prepare_with_tls(postgres_with_mtls, "")) + conn.close() + +def test_mtls_with_unknown_cert(postgres_with_mtls: Postgres): + with pytest.raises( + psycopg.OperationalError, + match="(unknown ca)|(Connection refused)", + ): + conn = psycopg.connect(prepare_with_tls(postgres_with_mtls, "self-signed")) + conn.close() + + +def prepare_with_tls(pg: Postgres, client_tls_pair_name: str): + instance = pg.instance + host = pg.host + port = pg.port user = "user" password = "P@ssw0rd" + connection_string = f"\ + user = {user} \ + password={password} \ + host={host} \ + port={port} \ + sslmode=require" + instance.sql(f"CREATE USER \"{user}\" WITH PASSWORD '{password}' USING md5") - # where the client should find his certificate - test_dir = Path(os.path.realpath(__file__)).parent - client_cert_file = test_dir.parent / "ssl_certs" / "root.crt" - os.environ["SSL_CERT_FILE"] = str(client_cert_file) + if client_tls_pair_name != "": + ssl_dir = Path(os.path.realpath(__file__)).parent.parent / "ssl_certs" + client_cert_path = ssl_dir / (client_tls_pair_name + ".crt") + client_key_path = ssl_dir / (client_tls_pair_name + ".key") + connection_string += f" sslcert={client_cert_path} sslkey={client_key_path}" + os.chmod(client_key_path, 0o600) - os.environ["PGSSLMODE"] = "require" - conn = psycopg.connect( - f"user = {user} password={password} host={host} port={port} sslmode=require" - ) - conn.close() + return connection_string diff --git a/test/ssl_certs/self-signed.crt b/test/ssl_certs/self-signed.crt new file mode 100644 index 0000000000000000000000000000000000000000..2fa5270c15f0801269ab07e47d6c90ffe1e51a68 --- /dev/null +++ b/test/ssl_certs/self-signed.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFbTCCA1WgAwIBAgIUOmG5dwuSKo9fXeqJnJ93KVYElM4wDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yNDA4MzAyMDA0MTRaGA8yMTI0 +MDgwNjIwMDQxNFowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx +ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAJPapiAtAi/s/H0M/cw/j7wg5ZSovprLFggteNjK +Ebr2KXLySG0NqmyPrYedv2aRnl6oKEZP60nf4j+qHi5wK9FuCg/+gKWyKGBDaYzU +76gJGDONWHlG972uV/Gu+Li4vUXi5kjnFJV79xMF1G9DDvqhA1Y9g4+LaqzfuXrW +7DuUzuTDII6/1NJrvFyr6PcGWgU4SdE9UByev+QbV55X+bWRnDW7DbJZXoCzi+Vr +odewzs2K8TxIJAYxB2TjUCyLMDPMXBnIMFS7XSqXDz/d3fytrsloZC1qumFGWKBj +YgpFUP9h9QWr3ddhk6fXDr6KgZeXjpqsBn33v0xhJ42JLmBpuH5JWlvzQ0U9xYiY +ODbWJeseUjV1dC1WLlXopBmlxIlABEQFGIXSracWaxFrl13ZgvoAutCJB5jSUzEl +j/Z7h72ju8vBNsgdtuQmdrqQolsY7gXUObdYqr/uaSP+VUZUSl0/DrvIJlGW8IyZ +WlnWxtwRY0RGC58wO3LG6S/MLgeE8houepA71h7PHctcohAbGYIYcKjqs7rVDewJ +kMQmxqSBO4n8Wyhyuyc3A7qfcfUOUH0h8tX8cA78NEqG9MphCt0DXXcnTrec7yWh +33pAUXuERJFHgP1ndoKWGwK1SpxTsIeoW/h6Z/HD+o8cJnQaVhtD984Fh2i+s58K +yju/AgMBAAGjUzBRMB0GA1UdDgQWBBQ8bB2fI79fWCEo8cTPPAT841dtRTAfBgNV +HSMEGDAWgBQ8bB2fI79fWCEo8cTPPAT841dtRTAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4ICAQCDsGZMaOLUDg85fqlNGiPcm41p+pF+rV9ZMlrzu76Y +Yz4WdGC5NCmoMfsaawHf6nQ+jj5D1xt9UxmCKdNHg8+9ufa26sOpFM4GcEbQC+B1 +hymbNuPhHXBd4+N9HwbzeK45UfUt6pC3IVk+qW7EAoXkFx4cMHbjsWWMBdfpn4HV +pAhxVLI8QT9BCyUMmc6ZxTwPjaSQ/t8IsWMRbkGw43jZBok6KOj5NvEbOD6sZ9VR +fjpF28TwA3RykjMVMOdZ5kBFmCBJQMx8rY5TSkYAIXffRl9Vw+NmUkq/DaHkRNTp +bsOByh/OzjTQQgy6PbwXKdUdpRBRW4voRJWyGtNiGkEXKSdbXHQpZ4R+7uD3Lp7h +MX8BswvGVCftVhXsbt1Y1/h3XU8qkF3V4ctw6QAmq2HpKCkVXBiVLPRa4NvfFsEh +3dsqoohXm2ZtSWP7X5ybzUsZyek/NCaUBA9XjnmD0AlZ8VpEVeqwqLRLEN3vct/r +4ZfgPz0bDwizP6Pj4B26h9Wz6GnH6ZXJT4cr+HPpQ+nzjaTV6HrvT6t6yKFOOAIQ +sXZEIV/HEYj/gLfvoSyvR4zVg9IrF5vNbQiZS7H8Fixxuhvj/tvdC5ClnQzjlRkF +ZBCZsJB5UR7L/FUIuCvoZXdYp2+uLWAnsY+MVkbwTz9IiEDDfyDkmbTspdhHuJCt +bg== +-----END CERTIFICATE----- diff --git a/test/ssl_certs/self-signed.key b/test/ssl_certs/self-signed.key new file mode 100644 index 0000000000000000000000000000000000000000..0ed4774f4d26939c6062fabfa4dd409b41214ff1 --- /dev/null +++ b/test/ssl_certs/self-signed.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +aYNDIzyuWaDLTxMd73PDZ8m3NJpW +-----END PRIVATE KEY-----