diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 98ea64cce3..97bfb7c769 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,8 +12,8 @@ jobs: name: Publish runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - uses: Swatinem/rust-cache@dd05243424bd5c0e585e4b55eb2d7615cdd32f1f + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - run: | cargo publish ${ARGS} --package sqlx-rt-oldapi cargo publish ${ARGS} --package sqlx-core-oldapi diff --git a/.github/workflows/sqlx.yml b/.github/workflows/sqlx.yml index 90dbb4f633..3b549c9aa0 100644 --- a/.github/workflows/sqlx.yml +++ b/.github/workflows/sqlx.yml @@ -11,7 +11,7 @@ jobs: name: Format runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: cargo fmt --all -- --check check: @@ -22,8 +22,8 @@ jobs: runtime: [async-std, tokio, actix] tls: [native-tls, rustls] steps: - - uses: actions/checkout@v3 - - uses: Swatinem/rust-cache@dd05243424bd5c0e585e4b55eb2d7615cdd32f1f + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - run: cargo check --manifest-path sqlx-core/Cargo.toml @@ -51,7 +51,7 @@ jobs: rustls ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - run: cargo test @@ -79,7 +79,7 @@ jobs: # bin: target/debug/cargo-sqlx steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - run: cargo build @@ -101,7 +101,7 @@ jobs: tls: [native-tls, rustls] needs: check steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: mkdir /tmp/sqlite3-lib && wget -O /tmp/sqlite3-lib/ipaddr.so https://github.com/nalgeon/sqlean/releases/download/0.15.2/ipaddr.so - uses: Swatinem/rust-cache@v2 - run: @@ -125,7 +125,7 @@ jobs: tls: [native-tls, rustls] needs: check steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: @@ -170,11 +170,26 @@ jobs: --no-default-features --features any,postgres,macros,migrate,all-types,runtime-${{ matrix.runtime }}-${{ matrix.tls }} env: - DATABASE_URL: postgres://postgres:password@localhost:5432/sqlx?sslmode=verify-ca&sslrootcert=.%2Ftests%2Fcerts%2Fca.crt + DATABASE_URL: postgres://postgres:password@localhost:5432/sqlx?sslmode=verify-ca&sslrootcert=./tests/certs/ca.crt # FIXME: needed to disable `ltree` tests in Postgres 9.6 # but `PgLTree` should just fall back to text format RUSTFLAGS: --cfg postgres_${{ matrix.postgres }} + postgres_ssl_client_cert: + name: Postgres with SSL client cert + runs-on: ubuntu-22.04 + needs: check + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + key: linux-postgres-ssl-client-cert + - run: docker compose up --wait postgres_16 + working-directory: tests + - run: cargo test --no-default-features --features any,postgres,macros,all-types,runtime-actix-rustls + env: + DATABASE_URL: postgres://postgres@localhost:5432/sqlx?sslmode=verify-ca&sslrootcert=./tests/certs/ca.crt&sslcert=./tests/certs/client.crt&sslkey=./tests/keys/client.key + mysql: name: MySQL runs-on: ubuntu-22.04 @@ -185,7 +200,7 @@ jobs: tls: [native-tls, rustls] needs: check steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: @@ -236,7 +251,7 @@ jobs: tls: [native-tls, rustls] needs: check steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: @@ -276,7 +291,7 @@ jobs: tls: [native-tls, rustls] needs: check steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: diff --git a/sqlx-core/src/error.rs b/sqlx-core/src/error.rs index b06539bf3e..f2118cc1ef 100644 --- a/sqlx-core/src/error.rs +++ b/sqlx-core/src/error.rs @@ -133,6 +133,12 @@ impl Error { pub(crate) fn config(err: impl StdError + Send + Sync + 'static) -> Self { Error::Configuration(err.into()) } + + #[allow(dead_code)] + #[inline] + pub(crate) fn tls>(err: T) -> Self { + Error::Tls(err.into()) + } } pub(crate) fn mismatched_types>(ty: &DB::TypeInfo) -> BoxDynError { diff --git a/sqlx-core/src/lib.rs b/sqlx-core/src/lib.rs index 8ec28aa593..5b69a908c8 100644 --- a/sqlx-core/src/lib.rs +++ b/sqlx-core/src/lib.rs @@ -2,7 +2,7 @@ //! Not intended to be used directly. #![recursion_limit = "512"] #![warn(future_incompatible, rust_2018_idioms)] -#![allow(clippy::needless_doctest_main, clippy::type_complexity)] +#![allow(clippy::needless_doctest_main, clippy::type_complexity, dead_code)] // See `clippy.toml` at the workspace root #![deny(clippy::disallowed_methods)] // diff --git a/sqlx-core/src/mysql/connection/tls.rs b/sqlx-core/src/mysql/connection/tls.rs index 468b638fa8..d536ef4fb0 100644 --- a/sqlx-core/src/mysql/connection/tls.rs +++ b/sqlx-core/src/mysql/connection/tls.rs @@ -3,6 +3,7 @@ use crate::mysql::connection::MySqlStream; use crate::mysql::protocol::connect::SslRequest; use crate::mysql::protocol::Capabilities; use crate::mysql::{MySqlConnectOptions, MySqlSslMode}; +use crate::net::TlsConfig; pub(super) async fn maybe_upgrade( stream: &mut MySqlStream, @@ -45,16 +46,17 @@ async fn upgrade(stream: &mut MySqlStream, options: &MySqlConnectOptions) -> Res options.ssl_mode, MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity ); - let accept_invalid_host_names = !matches!(options.ssl_mode, MySqlSslMode::VerifyIdentity); - - stream - .upgrade( - &options.host, - accept_invalid_certs, - accept_invalid_host_names, - options.ssl_ca.as_ref(), - ) - .await?; + let accept_invalid_hostnames = !matches!(options.ssl_mode, MySqlSslMode::VerifyIdentity); + + let tls_config = TlsConfig { + accept_invalid_certs, + hostname: &options.host, + accept_invalid_hostnames, + root_cert_path: options.ssl_ca.as_ref(), + client_cert_path: options.ssl_client_cert.as_ref(), + client_key_path: options.ssl_client_key.as_ref(), + }; + stream.upgrade(tls_config).await?; Ok(true) } diff --git a/sqlx-core/src/mysql/options/mod.rs b/sqlx-core/src/mysql/options/mod.rs index 5c772960b4..457026a0e0 100644 --- a/sqlx-core/src/mysql/options/mod.rs +++ b/sqlx-core/src/mysql/options/mod.rs @@ -24,6 +24,8 @@ pub use ssl_mode::MySqlSslMode; /// |---------|-------|-----------| /// | `ssl-mode` | `PREFERRED` | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated. See [`MySqlSslMode`]. | /// | `ssl-ca` | `None` | Sets the name of a file containing a list of trusted SSL Certificate Authorities. | +/// | `ssl-cert` | `None` | Sets the name of a file containing a client SSL certificate to authenticate the connection to the server | +/// | `ssl-key` | `None` | Sets the name of a file containing a secret SSL key for the client certificate. | /// | `statement-cache-capacity` | `100` | The maximum number of prepared statements stored in the cache. Set to `0` to disable. | /// | `socket` | `None` | Path to the unix domain socket, which will be used instead of TCP if set. | /// @@ -61,6 +63,8 @@ pub struct MySqlConnectOptions { pub(crate) database: Option, pub(crate) ssl_mode: MySqlSslMode, pub(crate) ssl_ca: Option, + pub(crate) ssl_client_cert: Option, + pub(crate) ssl_client_key: Option, pub(crate) statement_cache_capacity: usize, pub(crate) charset: String, pub(crate) collation: Option, @@ -88,6 +92,8 @@ impl MySqlConnectOptions { collation: None, ssl_mode: MySqlSslMode::Preferred, ssl_ca: None, + ssl_client_cert: None, + ssl_client_key: None, statement_cache_capacity: 100, log_settings: Default::default(), pipes_as_concat: true, @@ -186,6 +192,36 @@ impl MySqlConnectOptions { self } + /// Sets the name of a file containing SSL client certificate. + /// + /// # Example + /// + /// ```rust + /// # use sqlx_core_oldapi::mysql::{MySqlSslMode, MySqlConnectOptions}; + /// let options = MySqlConnectOptions::new() + /// .ssl_mode(MySqlSslMode::VerifyCa) + /// .ssl_client_cert("path/to/client.crt"); + /// ``` + pub fn ssl_client_cert(mut self, cert: impl AsRef) -> Self { + self.ssl_client_cert = Some(CertificateInput::File(cert.as_ref().to_path_buf())); + self + } + + /// Sets the name of a file containing SSL client key. + /// + /// # Example + /// + /// ```rust + /// # use sqlx_core_oldapi::mysql::{MySqlSslMode, MySqlConnectOptions}; + /// let options = MySqlConnectOptions::new() + /// .ssl_mode(MySqlSslMode::VerifyCa) + /// .ssl_client_key("path/to/client.key"); + /// ``` + pub fn ssl_client_key(mut self, key: impl AsRef) -> Self { + self.ssl_client_key = Some(CertificateInput::File(key.as_ref().to_path_buf())); + self + } + /// Sets the capacity of the connection's statement cache in a number of stored /// distinct statements. Caching is handled using LRU, meaning when the /// amount of queries hits the defined limit, the oldest statement will get diff --git a/sqlx-core/src/mysql/options/parse.rs b/sqlx-core/src/mysql/options/parse.rs index 0ce32b5d71..b1a2e98485 100644 --- a/sqlx-core/src/mysql/options/parse.rs +++ b/sqlx-core/src/mysql/options/parse.rs @@ -43,11 +43,11 @@ impl FromStr for MySqlConnectOptions { for (key, value) in url.query_pairs().into_iter() { match &*key { - "ssl-mode" => { + "sslmode" | "ssl-mode" => { options = options.ssl_mode(value.parse().map_err(Error::config)?); } - "ssl-ca" => { + "sslca" | "ssl-ca" => { options = options.ssl_ca(&*value); } @@ -68,6 +68,10 @@ impl FromStr for MySqlConnectOptions { options = options.socket(&*value); } + "sslcert" | "ssl-cert" => options = options.ssl_client_cert(&*value), + + "sslkey" | "ssl-key" => options = options.ssl_client_key(&*value), + _ => {} } } diff --git a/sqlx-core/src/net/mod.rs b/sqlx-core/src/net/mod.rs index c265c26620..640038f906 100644 --- a/sqlx-core/src/net/mod.rs +++ b/sqlx-core/src/net/mod.rs @@ -6,6 +6,8 @@ pub use socket::Socket; pub use tls::CertificateInput; #[allow(unused_imports)] pub use tls::MaybeTlsStream; +#[allow(unused_imports)] +pub use tls::TlsConfig; #[cfg(feature = "_rt-async-std")] type PollReadBuf<'a> = [u8]; diff --git a/sqlx-core/src/net/tls/mod.rs b/sqlx-core/src/net/tls/mod.rs index 1d6837ab71..7473936505 100644 --- a/sqlx-core/src/net/tls/mod.rs +++ b/sqlx-core/src/net/tls/mod.rs @@ -35,11 +35,16 @@ impl From for CertificateInput { } impl CertificateInput { - async fn data(&self) -> Result, std::io::Error> { + async fn data(&self) -> Result, Error> { use sqlx_rt::fs; match self { CertificateInput::Inline(v) => Ok(v.clone()), - CertificateInput::File(path) => fs::read(path).await, + CertificateInput::File(path) => fs::read(path).await.map_err(|e| { + Error::tls(CertificateReadError { + path: path.clone(), + source: e, + }) + }), } } } @@ -53,6 +58,38 @@ impl std::fmt::Display for CertificateInput { } } +#[derive(Debug)] +struct CertificateReadError { + path: PathBuf, + source: io::Error, +} + +impl std::fmt::Display for CertificateReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "failed to read certificate file '{}': {}", + self.path.display(), + self.source + ) + } +} + +impl std::error::Error for CertificateReadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +pub struct TlsConfig<'a> { + pub accept_invalid_certs: bool, + pub accept_invalid_hostnames: bool, + pub hostname: &'a str, + pub root_cert_path: Option<&'a CertificateInput>, + pub client_cert_path: Option<&'a CertificateInput>, + pub client_key_path: Option<&'a CertificateInput>, +} + #[cfg(feature = "_tls-rustls")] mod rustls; @@ -74,19 +111,9 @@ where matches!(self, Self::Tls(_)) } - pub async fn upgrade( - &mut self, - host: &str, - accept_invalid_certs: bool, - accept_invalid_hostnames: bool, - root_cert_path: Option<&CertificateInput>, - ) -> Result<(), Error> { - let connector = configure_tls_connector( - accept_invalid_certs, - accept_invalid_hostnames, - root_cert_path, - ) - .await?; + pub async fn upgrade(&mut self, config: TlsConfig<'_>) -> Result<(), Error> { + let host = config.hostname; + let connector = configure_tls_connector(config).await?; let stream = match replace(self, MaybeTlsStream::Upgrading) { MaybeTlsStream::Raw(stream) => stream, @@ -115,20 +142,16 @@ where } #[cfg(feature = "_tls-native-tls")] -async fn configure_tls_connector( - accept_invalid_certs: bool, - accept_invalid_hostnames: bool, - root_cert_path: Option<&CertificateInput>, -) -> Result { - use sqlx_rt::native_tls::{Certificate, TlsConnector}; +async fn configure_tls_connector(config: TlsConfig<'_>) -> Result { + use sqlx_rt::native_tls::{Certificate, Identity, TlsConnector}; let mut builder = TlsConnector::builder(); builder - .danger_accept_invalid_certs(accept_invalid_certs) - .danger_accept_invalid_hostnames(accept_invalid_hostnames); + .danger_accept_invalid_certs(config.accept_invalid_certs) + .danger_accept_invalid_hostnames(config.accept_invalid_hostnames); - if !accept_invalid_certs { - if let Some(ca) = root_cert_path { + if !config.accept_invalid_certs { + if let Some(ca) = config.root_cert_path { let data = ca.data().await?; let cert = Certificate::from_pem(&data)?; @@ -136,6 +159,14 @@ async fn configure_tls_connector( } } + // authentication using user's key-file and its associated certificate + if let (Some(cert_path), Some(key_path)) = (config.client_cert_path, config.client_key_path) { + let cert_path = cert_path.data().await?; + let key_path = key_path.data().await?; + let identity = Identity::from_pkcs8(&cert_path, &key_path).map_err(Error::tls)?; + builder.identity(identity); + } + #[cfg(not(feature = "_rt-async-std"))] let connector = builder.build()?.into(); diff --git a/sqlx-core/src/net/tls/rustls.rs b/sqlx-core/src/net/tls/rustls.rs index 154710722a..0be1921b5c 100644 --- a/sqlx-core/src/net/tls/rustls.rs +++ b/sqlx-core/src/net/tls/rustls.rs @@ -1,34 +1,31 @@ -use crate::net::CertificateInput; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::client::WebPkiServerVerifier; -use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; use rustls::{ CertificateError, ClientConfig, DigitallySignedStruct, Error as TlsError, RootCertStore, SignatureScheme, }; -use std::io::Cursor; +use std::io::{BufReader, Cursor}; use std::sync::Arc; use crate::error::Error; +use super::TlsConfig; + pub async fn configure_tls_connector( - accept_invalid_certs: bool, - accept_invalid_hostnames: bool, - root_cert_path: Option<&CertificateInput>, + tls_config: TlsConfig<'_>, ) -> Result { let config = ClientConfig::builder(); - - let config = if accept_invalid_certs { + let config = if tls_config.accept_invalid_certs { config .dangerous() .with_custom_certificate_verifier(Arc::new(DummyTlsVerifier)) - .with_no_client_auth() } else { let mut cert_store = RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(), }; - if let Some(ca) = root_cert_path { + if let Some(ca) = tls_config.root_cert_path { let data = ca.data().await?; let mut cursor = Cursor::new(data); @@ -39,7 +36,7 @@ pub async fn configure_tls_connector( } } - if accept_invalid_hostnames { + if tls_config.accept_invalid_hostnames { let verifier = WebPkiServerVerifier::builder(Arc::new(cert_store)) .build() .map_err(|err| Error::Tls(err.into()))?; @@ -47,17 +44,54 @@ pub async fn configure_tls_connector( config .dangerous() .with_custom_certificate_verifier(Arc::new(NoHostnameTlsVerifier { verifier })) - .with_no_client_auth() } else { + config.with_root_certificates(cert_store) + } + }; + + // authentication using user's key and its associated certificate + let config = match (tls_config.client_cert_path, tls_config.client_key_path) { + (Some(cert_path), Some(key_path)) => { + let cert_chain = certs_from_pem(cert_path.data().await?)?; + let key_der = private_key_from_pem(key_path.data().await?)?; config - .with_root_certificates(cert_store) - .with_no_client_auth() + .with_client_auth_cert(cert_chain, key_der) + .map_err(Error::tls)? + } + (None, None) => config.with_no_client_auth(), + (_, _) => { + return Err(Error::Configuration( + "user auth key and certs must be given together".into(), + )) } }; Ok(Arc::new(config).into()) } +fn certs_from_pem(pem: Vec) -> std::io::Result>> { + let cur = Cursor::new(pem); + let mut reader = BufReader::new(cur); + rustls_pemfile::certs(&mut reader).collect() +} + +fn private_key_from_pem(pem: Vec) -> Result, Error> { + let cur = Cursor::new(pem); + let mut reader = BufReader::new(cur); + + loop { + match rustls_pemfile::read_one(&mut reader)? { + Some(rustls_pemfile::Item::Sec1Key(key)) => return Ok(PrivateKeyDer::Sec1(key)), + Some(rustls_pemfile::Item::Pkcs8Key(key)) => return Ok(PrivateKeyDer::Pkcs8(key)), + Some(rustls_pemfile::Item::Pkcs1Key(key)) => return Ok(PrivateKeyDer::Pkcs1(key)), + None => break, + _ => {} + } + } + + Err(Error::Configuration("no keys found pem file".into())) +} + #[derive(Debug)] struct DummyTlsVerifier; diff --git a/sqlx-core/src/postgres/connection/tls.rs b/sqlx-core/src/postgres/connection/tls.rs index 0c780f401a..b348dd1eb1 100644 --- a/sqlx-core/src/postgres/connection/tls.rs +++ b/sqlx-core/src/postgres/connection/tls.rs @@ -1,6 +1,7 @@ use bytes::Bytes; use crate::error::Error; +use crate::net::TlsConfig; use crate::postgres::connection::stream::PgStream; use crate::postgres::message::SslRequest; use crate::postgres::{PgConnectOptions, PgSslMode}; @@ -65,14 +66,15 @@ async fn upgrade(stream: &mut PgStream, options: &PgConnectOptions) -> Result = TlsConfig { + accept_invalid_certs, + accept_invalid_hostnames, + root_cert_path: options.ssl_root_cert.as_ref(), + hostname: &options.host, + client_cert_path: options.ssl_client_cert.as_ref(), + client_key_path: options.ssl_client_key.as_ref(), + }; + stream.upgrade(tls_config).await?; Ok(true) } diff --git a/sqlx-core/src/postgres/options/mod.rs b/sqlx-core/src/postgres/options/mod.rs index ae3c867ed2..2a9ef9f53b 100644 --- a/sqlx-core/src/postgres/options/mod.rs +++ b/sqlx-core/src/postgres/options/mod.rs @@ -27,6 +27,8 @@ pub use ssl_mode::PgSslMode; /// |---------|-------|-----------| /// | `sslmode` | `prefer` | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated. See [`PgSslMode`]. | /// | `sslrootcert` | `None` | Sets the name of a file containing a list of trusted SSL Certificate Authorities. | +/// | `sslcert` | `None` | Sets the name of a file containing a client SSL certificate to authenticate the connection to the server. | +/// | `sslkey` | `None` | Sets the name of a file containing a secret SSL key for the client certificate. | /// | `statement-cache-capacity` | `100` | The maximum number of prepared statements stored in the cache. Set to `0` to disable. | /// | `host` | `None` | Path to the directory containing a PostgreSQL unix domain socket, which will be used instead of TCP if set. | /// | `hostaddr` | `None` | Same as `host`, but only accepts IP addresses. | @@ -85,6 +87,8 @@ pub struct PgConnectOptions { pub(crate) database: Option, pub(crate) ssl_mode: PgSslMode, pub(crate) ssl_root_cert: Option, + pub(crate) ssl_client_cert: Option, + pub(crate) ssl_client_key: Option, pub(crate) statement_cache_capacity: usize, pub(crate) application_name: Option, pub(crate) log_settings: LogSettings, @@ -110,6 +114,8 @@ impl PgConnectOptions { /// * `PGPASSWORD` /// * `PGDATABASE` /// * `PGSSLROOTCERT` + /// * `PGSSLCERT` + /// * `PGSSLKEY` /// * `PGSSLMODE` /// * `PGAPPNAME` /// @@ -143,6 +149,8 @@ impl PgConnectOptions { password: var("PGPASSWORD").ok(), database, ssl_root_cert: var("PGSSLROOTCERT").ok().map(CertificateInput::from), + ssl_client_cert: var("PGSSLCERT").ok().map(CertificateInput::from), + ssl_client_key: var("PGSSLKEY").ok().map(CertificateInput::from), ssl_mode: var("PGSSLMODE") .ok() .and_then(|v| v.parse().ok()) @@ -328,6 +336,90 @@ impl PgConnectOptions { self } + /// Sets the name of a file containing SSL client certificate. + /// + /// # Example + /// + /// ```rust + /// # use sqlx_core_oldapi::postgres::{PgSslMode, PgConnectOptions}; + /// let options = PgConnectOptions::new() + /// // Providing a CA certificate with less than VerifyCa is pointless + /// .ssl_mode(PgSslMode::VerifyCa) + /// .ssl_client_cert("./client.crt"); + /// ``` + pub fn ssl_client_cert(mut self, cert: impl AsRef) -> Self { + self.ssl_client_cert = Some(CertificateInput::File(cert.as_ref().to_path_buf())); + self + } + + /// Sets the SSL client certificate as a PEM-encoded byte slice. + /// + /// This should be an ASCII-encoded blob that starts with `-----BEGIN CERTIFICATE-----`. + /// + /// # Example + /// Note: embedding SSL certificates and keys in the binary is not advised. + /// This is for illustration purposes only. + /// + /// ```rust + /// # use sqlx_core_oldapi::postgres::{PgSslMode, PgConnectOptions}; + /// + /// const CERT: &[u8] = b"\ + /// -----BEGIN CERTIFICATE----- + /// + /// -----END CERTIFICATE-----"; + /// + /// let options = PgConnectOptions::new() + /// // Providing a CA certificate with less than VerifyCa is pointless + /// .ssl_mode(PgSslMode::VerifyCa) + /// .ssl_client_cert_from_pem(CERT); + /// ``` + pub fn ssl_client_cert_from_pem(mut self, cert: impl AsRef<[u8]>) -> Self { + self.ssl_client_cert = Some(CertificateInput::Inline(cert.as_ref().to_vec())); + self + } + + /// Sets the name of a file containing SSL client key. + /// + /// # Example + /// + /// ```rust + /// # use sqlx_core_oldapi::postgres::{PgSslMode, PgConnectOptions}; + /// let options = PgConnectOptions::new() + /// // Providing a CA certificate with less than VerifyCa is pointless + /// .ssl_mode(PgSslMode::VerifyCa) + /// .ssl_client_key("./client.key"); + /// ``` + pub fn ssl_client_key(mut self, key: impl AsRef) -> Self { + self.ssl_client_key = Some(CertificateInput::File(key.as_ref().to_path_buf())); + self + } + + /// Sets the SSL client key as a PEM-encoded byte slice. + /// + /// This should be an ASCII-encoded blob that starts with `-----BEGIN PRIVATE KEY-----`. + /// + /// # Example + /// Note: embedding SSL certificates and keys in the binary is not advised. + /// This is for illustration purposes only. + /// + /// ```rust + /// # use sqlx_core_oldapi::postgres::{PgSslMode, PgConnectOptions}; + /// + /// const KEY: &[u8] = b"\ + /// -----BEGIN PRIVATE KEY----- + /// + /// -----END PRIVATE KEY-----"; + /// + /// let options = PgConnectOptions::new() + /// // Providing a CA certificate with less than VerifyCa is pointless + /// .ssl_mode(PgSslMode::VerifyCa) + /// .ssl_client_key_from_pem(KEY); + /// ``` + pub fn ssl_client_key_from_pem(mut self, key: impl AsRef<[u8]>) -> Self { + self.ssl_client_key = Some(CertificateInput::Inline(key.as_ref().to_vec())); + self + } + /// Sets the capacity of the connection's statement cache in a number of stored /// distinct statements. Caching is handled using LRU, meaning when the /// amount of queries hits the defined limit, the oldest statement will get diff --git a/sqlx-core/src/postgres/options/parse.rs b/sqlx-core/src/postgres/options/parse.rs index 65d2baeb49..30a5cf75ec 100644 --- a/sqlx-core/src/postgres/options/parse.rs +++ b/sqlx-core/src/postgres/options/parse.rs @@ -57,6 +57,10 @@ impl FromStr for PgConnectOptions { options = options.ssl_root_cert(&*value); } + "sslcert" | "ssl-cert" => options = options.ssl_client_cert(&*value), + + "sslkey" | "ssl-key" => options = options.ssl_client_key(&*value), + "statement-cache-capacity" => { options = options.statement_cache_capacity(value.parse().map_err(Error::config)?); diff --git a/sqlx-core/src/postgres/options/ssl_mode.rs b/sqlx-core/src/postgres/options/ssl_mode.rs index 936e862d0f..60125e4670 100644 --- a/sqlx-core/src/postgres/options/ssl_mode.rs +++ b/sqlx-core/src/postgres/options/ssl_mode.rs @@ -48,7 +48,7 @@ impl FromStr for PgSslMode { _ => { return Err(Error::Configuration( - format!("unknown value {:?} for `ssl_mode`", s).into(), + format!("unknown value {s:?} for `ssl_mode`").into(), )); } }) diff --git a/tests/.dockerignore b/tests/.dockerignore index 6c513a8a33..6e86a492fa 100644 --- a/tests/.dockerignore +++ b/tests/.dockerignore @@ -3,3 +3,5 @@ !keys/* !mssql/*.sh !*/*.sql +!postgres/pg_hba.conf +!postgres/postgresql.conf \ No newline at end of file diff --git a/tests/certs/ca.srl b/tests/certs/ca.srl new file mode 100644 index 0000000000..ef2f121531 --- /dev/null +++ b/tests/certs/ca.srl @@ -0,0 +1 @@ +B6C71F4B11C0A189 diff --git a/tests/certs/client.crt b/tests/certs/client.crt new file mode 100644 index 0000000000..0edde05434 --- /dev/null +++ b/tests/certs/client.crt @@ -0,0 +1,57 @@ +Certificate: + Data: + Version: 1 (0x0) + Serial Number: + e0:be:1f:7a:49:1e:49:ec + Signature Algorithm: NULL + Issuer: CN = postgres + Validity + Not Before: Apr 10 20:59:23 2021 GMT + Not After : Apr 8 20:59:23 2031 GMT + Subject: CN = postgres + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:bf:4f:18:ca:d8:ff:a3:93:aa:9a:3b:90:35:c7: + ff:82:65:d1:d0:e8:65:9d:9c:6c:cb:70:4e:31:7e: + 7e:52:ce:2d:85:7a:83:ee:b8:eb:f1:ba:37:0e:34: + 66:3d:b6:db:cb:45:6f:64:0f:5c:4d:ba:53:25:c9: + ff:e0:a1:39:9b:82:c9:c0:08:e8:17:6b:01:6a:99: + 47:05:d8:c5:2f:83:f3:33:f7:ad:bb:f3:dd:5f:6a: + 95:4f:d9:8e:1d:bc:ff:84:78:77:eb:98:40:36:2d: + 9a:a3:29:a6:ba:58:90:c1:92:88:5f:07:c3:a8:a6: + 06:f0:ca:f8:81:40:13:65:1d:08:6c:97:9f:d4:b4: + 8d:f7:77:32:f6:2c:d4:9b:07:b3:86:3a:62:7f:da: + 3d:3c:e9:96:71:cc:62:2e:ac:6d:00:ca:ac:6c:a1: + b4:68:28:67:18:be:4b:31:e7:f1:c3:1d:a4:ad:05: + 50:59:44:30:09:b1:91:e1:86:5d:ec:75:06:a9:70: + 43:6b:81:5c:ff:98:fd:22:5c:3a:0e:08:2e:e3:b3: + c4:e0:65:dd:cd:e7:f2:69:08:0a:1b:90:c4:06:c1: + 06:ee:75:ee:d3:3c:ab:a2:9c:51:00:1c:56:fe:24: + 92:36:ee:e1:f3:6f:0c:14:79:32:07:f9:12:2b:26: + 79:c1 + Exponent: 65537 (0x10001) + Signature Algorithm: NULL +-----BEGIN CERTIFICATE----- +MIIDjjCCAfYCCQC2xx9LEcChiTANBgkqhkiG9w0BAQsFADB/MR4wHAYDVQQKExVt +a2NlcnQgZGV2ZWxvcG1lbnQgQ0ExKjAoBgNVBAsMIW1laGNvZGVAR29sZW0ubG9j +YWwgKFJ5YW4gTGVja2V5KTExMC8GA1UEAwwobWtjZXJ0IG1laGNvZGVAR29sZW0u +bG9jYWwgKFJ5YW4gTGVja2V5KTAeFw0yMTA0MTAyMDU5MjNaFw0zMTA0MDgyMDU5 +MjNaMBMxETAPBgNVBAMMCHBvc3RncmVzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAv08Yytj/o5OqmjuQNcf/gmXR0OhlnZxsy3BOMX5+Us4thXqD7rjr +8bo3DjRmPbbby0VvZA9cTbpTJcn/4KE5m4LJwAjoF2sBaplHBdjFL4PzM/etu/Pd +X2qVT9mOHbz/hHh365hANi2aoymmuliQwZKIXwfDqKYG8Mr4gUATZR0IbJef1LSN +93cy9izUmwezhjpif9o9POmWccxiLqxtAMqsbKG0aChnGL5LMefxwx2krQVQWUQw +CbGR4YZd7HUGqXBDa4Fc/5j9Ilw6Dggu47PE4GXdzefyaQgKG5DEBsEG7nXu0zyr +opxRABxW/iSSNu7h828MFHkyB/kSKyZ5wQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB +gQBxzRXtmp1gXzNTnwQ+acdZ2mRkjoEkr00e5wQTXCcOhfsXG/udQaEU1SUhaCyV +HppmxDB4i3aHhiGKztk6JU/SE9o4B//BbdLfmv741lwrE/5Lgx2YSBnATqDWC7rI +W2Tj33Sf06y7MKgkG5TszkM2cGdYhowhsyhhpww50gKfoRBNTp935jLo3nytShiM +NeQpf7/Wjcd1yIRYbWefTDJDSwGnzBoPCNHIEhAT15RUV2jGe9ctSMU2zQWInDll +U8dkWRZp9cZpQCvx2HkMy7oqsigoHxSSnsMzc8gtJHdhovjoLAVu9y5mAtEjHnTd +2ud1woYVo5dDoQEaFMp1Ll4qotLhMRVDl3SBPJoKOrEQfS/4JwITzuS8C7RSlmxE +UR2gPw7R39ocTE/rigUnE4WHf4q18kWrkRRZoMsvitv9FSyMkN1yaL0IintkRXzg +ZkSZbzxVriE1dZ5u+Ie1zNaa5rB+yb/nzRC9HMbBtZbVgHe1ngr+pEyAMWFd4U8N +HRQ= +-----END CERTIFICATE----- diff --git a/tests/certs/client.csr b/tests/certs/client.csr new file mode 100644 index 0000000000..619f572efa --- /dev/null +++ b/tests/certs/client.csr @@ -0,0 +1,60 @@ +Certificate Request: + Data: + Version: 1 (0x0) + Subject: CN = postgres + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:bf:4f:18:ca:d8:ff:a3:93:aa:9a:3b:90:35:c7: + ff:82:65:d1:d0:e8:65:9d:9c:6c:cb:70:4e:31:7e: + 7e:52:ce:2d:85:7a:83:ee:b8:eb:f1:ba:37:0e:34: + 66:3d:b6:db:cb:45:6f:64:0f:5c:4d:ba:53:25:c9: + ff:e0:a1:39:9b:82:c9:c0:08:e8:17:6b:01:6a:99: + 47:05:d8:c5:2f:83:f3:33:f7:ad:bb:f3:dd:5f:6a: + 95:4f:d9:8e:1d:bc:ff:84:78:77:eb:98:40:36:2d: + 9a:a3:29:a6:ba:58:90:c1:92:88:5f:07:c3:a8:a6: + 06:f0:ca:f8:81:40:13:65:1d:08:6c:97:9f:d4:b4: + 8d:f7:77:32:f6:2c:d4:9b:07:b3:86:3a:62:7f:da: + 3d:3c:e9:96:71:cc:62:2e:ac:6d:00:ca:ac:6c:a1: + b4:68:28:67:18:be:4b:31:e7:f1:c3:1d:a4:ad:05: + 50:59:44:30:09:b1:91:e1:86:5d:ec:75:06:a9:70: + 43:6b:81:5c:ff:98:fd:22:5c:3a:0e:08:2e:e3:b3: + c4:e0:65:dd:cd:e7:f2:69:08:0a:1b:90:c4:06:c1: + 06:ee:75:ee:d3:3c:ab:a2:9c:51:00:1c:56:fe:24: + 92:36:ee:e1:f3:6f:0c:14:79:32:07:f9:12:2b:26: + 79:c1 + Exponent: 65537 (0x10001) + Attributes: + a0:00 + Signature Algorithm: sha256WithRSAEncryption + b1:1f:11:89:d3:6a:a3:b3:fb:9e:9d:de:b4:cb:5c:44:0f:86: + 69:c7:c5:81:f8:cc:42:24:6d:92:1c:e8:85:bc:22:ba:49:6f: + d4:f0:89:21:6c:39:9d:29:31:a5:2a:21:81:76:58:1b:0a:1b: + fb:46:9a:59:fd:e3:c8:7b:54:25:ad:ca:86:0f:2b:e7:aa:79: + 92:d4:f5:c7:91:5d:f2:f8:ff:fe:d1:5f:0c:30:8a:1a:89:0d: + 3a:d1:1b:f2:a4:77:bd:fb:3b:5a:c9:6c:15:e5:54:f9:10:ba: + 58:6a:a2:ee:7e:32:dc:fa:ef:51:f8:52:63:67:6e:e8:fa:fc: + 21:79:46:fb:f2:6d:16:34:6c:79:96:ae:1c:8b:2c:1b:c5:ab: + b7:ac:ad:14:25:55:de:41:76:a1:47:34:0e:b4:c7:48:b1:73: + e6:74:ed:17:5f:d9:f2:d0:ec:6a:6a:97:bd:7c:81:b9:22:09: + 14:d0:e0:5e:b8:14:70:f3:3d:b1:aa:2e:43:c8:10:7d:00:85: + 90:9c:80:9f:3d:03:c3:6c:df:f3:da:50:19:e7:5e:a0:0e:17: + f9:5c:ed:83:35:38:c2:9a:5b:ea:ea:ec:8b:27:1d:51:38:8b: + 94:eb:d0:69:4a:87:dd:52:49:dc:75:86:ce:5e:ee:ec:33:ff: + 8d:0c:30:40 +-----BEGIN CERTIFICATE REQUEST----- +MIICWDCCAUACAQAwEzERMA8GA1UEAwwIcG9zdGdyZXMwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC/TxjK2P+jk6qaO5A1x/+CZdHQ6GWdnGzLcE4xfn5S +zi2FeoPuuOvxujcONGY9ttvLRW9kD1xNulMlyf/goTmbgsnACOgXawFqmUcF2MUv +g/Mz9627891fapVP2Y4dvP+EeHfrmEA2LZqjKaa6WJDBkohfB8OopgbwyviBQBNl +HQhsl5/UtI33dzL2LNSbB7OGOmJ/2j086ZZxzGIurG0AyqxsobRoKGcYvksx5/HD +HaStBVBZRDAJsZHhhl3sdQapcENrgVz/mP0iXDoOCC7js8TgZd3N5/JpCAobkMQG +wQbude7TPKuinFEAHFb+JJI27uHzbwwUeTIH+RIrJnnBAgMBAAGgADANBgkqhkiG +9w0BAQsFAAOCAQEAsR8RidNqo7P7np3etMtcRA+GacfFgfjMQiRtkhzohbwiuklv +1PCJIWw5nSkxpSohgXZYGwob+0aaWf3jyHtUJa3Khg8r56p5ktT1x5Fd8vj//tFf +DDCKGokNOtEb8qR3vfs7WslsFeVU+RC6WGqi7n4y3PrvUfhSY2du6Pr8IXlG+/Jt +FjRseZauHIssG8Wrt6ytFCVV3kF2oUc0DrTHSLFz5nTtF1/Z8tDsamqXvXyBuSIJ +FNDgXrgUcPM9saouQ8gQfQCFkJyAnz0Dw2zf89pQGedeoA4X+VztgzU4wppb6urs +iycdUTiLlOvQaUqH3VJJ3HWGzl7u7DP/jQwwQA== +-----END CERTIFICATE REQUEST----- diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index bd8ce46297..5d705e31ba 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -79,6 +79,25 @@ services: # https://www.postgresql.org/support/versioning/ # + postgres_16: # Test postgres 16 with required client cert + build: + context: . + dockerfile: postgres/Dockerfile + args: + VERSION: 16 + ports: + - 5432:5432 + environment: + POSTGRES_DB: sqlx + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_INITDB_ARGS: --auth-host=trust + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + volumes: + - "./postgres/setup.sql:/docker-entrypoint-initdb.d/setup.sql" + command: > + -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key -c ssl_ca_file=/var/lib/postgresql/ca.crt -c hba_file=/var/lib/postgresql/pg_hba.conf + postgres_14: build: context: . diff --git a/tests/keys/client.key b/tests/keys/client.key new file mode 100644 index 0000000000..c5a237241e --- /dev/null +++ b/tests/keys/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC/TxjK2P+jk6qa +O5A1x/+CZdHQ6GWdnGzLcE4xfn5Szi2FeoPuuOvxujcONGY9ttvLRW9kD1xNulMl +yf/goTmbgsnACOgXawFqmUcF2MUvg/Mz9627891fapVP2Y4dvP+EeHfrmEA2LZqj +Kaa6WJDBkohfB8OopgbwyviBQBNlHQhsl5/UtI33dzL2LNSbB7OGOmJ/2j086ZZx +zGIurG0AyqxsobRoKGcYvksx5/HDHaStBVBZRDAJsZHhhl3sdQapcENrgVz/mP0i +XDoOCC7js8TgZd3N5/JpCAobkMQGwQbude7TPKuinFEAHFb+JJI27uHzbwwUeTIH ++RIrJnnBAgMBAAECggEAKNCZO328XIu+lBUtGSxIKOvMLcPHGi8rTuPw6sJP9R6j +u5x91UqCnBnccR1gyr3eeqmfsDtOuA6Oertz6dq7zZ/Dp0K/MW/U54c4DdlHiHGg +S3AGEtleW2MD4/tIRLPz17FT9GGRIX3tRe428f6/M20txwiDB9IUHP9QsVKYULPX +pzX+BMMINj70U1CcwcsIkPH9znDhwdMfjphC/eJUgITDle9EynYRhBHz55ajTTeE +hPsttRPYvbXdxd1WdSnt/Xv4+N10RKcEnrPE17WrbUs9RvqOz7hW4e4QifsBf5dR +0Sw1AemmdOK5xTrA0K9D7gRv6qC8QHuTDjIntVd8gQKBgQD+PyQUNJpvUre1zK1A +HBTVbX7uIqYrX6FWXFFE55HtcnrhEWIY5QCBPfOFsVdcvBJrqclkInpELjdnVbeP +25ETIKhhiP3FnJjJlNZiFXD85NmHbRJzABvNxspb+9UOuIJfB2ixSGmnEKEQIeJf +QmUzz/PJ9+2ct8/rXobZ90Is6QKBgQDAoNe/bUGc/ZmKq132hCcBeHFcjdtW6fkE +6d8giLx90b1kQzYJaM+4jhYF4s1job32ZPlykAlGUCtWBGirhonioJVgiGy4fOc0 +SlIcKg2Gh68FDdKGHcBN5duk0nCc+uLT1fNPqo98jy1DI6VCVej0xWBrFkMFXZ3S +qJ5PWtT/GQKBgFLBkqjRBoO91PZkDPCVM2LVJT+2H4h2tDk8C2f2SFWVsdGYqumX +gLaQx7d4pgsVXJmWxmrFni6bLIWCLSGyQmKLesNkp9Wuxzy2KaH7gK+Qfg3KvvqX +ynUMg8m1CwCjpivwaW9rNpienQ53OQvwvKhExAG1pa4hVpgySIqiJPQhAoGAeFUB +8cdisZuKiyG6NQEhDL4csuC7IHRQ50zh4gUJGuAnG7cQzpf3CydXgp3ICHFFpeI2 +IebwpEf4imd+q4gEItqF9iPDJwx/sh6rZISwplWkc9fKp5V2SDNLHo+HYckoYYTJ +1f6KXBllAQgHeIUKXb3fGYZyn6t3p91F5/SqEiECgYBzjAYDWIRi7IpMkckts2ZQ +p7YXZCbUP4MALTLuWulrI5IFv7gOjW20US/CArNMc4wPv5WtY1uE59pd8Td2CW9X +BX1nQXqaVlF6xLOzgqsWPxloRk7y692J9nYMKcB6VxlkFVUQfbRZksFCsn2I4Y5Z +ZtG/bPbIR6NgZ6ntNa+KIg== +-----END PRIVATE KEY----- diff --git a/tests/postgres/Dockerfile b/tests/postgres/Dockerfile index 184f062457..7c40566022 100644 --- a/tests/postgres/Dockerfile +++ b/tests/postgres/Dockerfile @@ -3,7 +3,10 @@ FROM postgres:${VERSION}-alpine # Copy SSL certificate (and key) COPY certs/server.crt /var/lib/postgresql/server.crt +COPY certs/ca.crt /var/lib/postgresql/ca.crt COPY keys/server.key /var/lib/postgresql/server.key +COPY postgres/pg_hba.conf /var/lib/postgresql/pg_hba.conf +COPY postgres/postgresql.conf /etc/postgresql/postgresql.conf # Fix permissions RUN chown 70:70 /var/lib/postgresql/server.crt /var/lib/postgresql/server.key diff --git a/tests/postgres/pg_hba.conf b/tests/postgres/pg_hba.conf new file mode 100644 index 0000000000..be33c4bb10 --- /dev/null +++ b/tests/postgres/pg_hba.conf @@ -0,0 +1,4 @@ +# only needed for certificate authentication tests +# omit host to prevent fallback to non certificate authentication +local all all trust +hostssl all all all cert diff --git a/tests/postgres/postgresql.conf b/tests/postgres/postgresql.conf new file mode 100644 index 0000000000..8e7986f743 --- /dev/null +++ b/tests/postgres/postgresql.conf @@ -0,0 +1,8 @@ +log_line_prefix = '%m [%p] %q%u@%d ' +log_connections = on +log_disconnections = on +log_duration = on +log_hostname = on + +# - Connection Settings - +listen_addresses = '*' \ No newline at end of file