Skip to content

Commit ce9a0a7

Browse files
ThibsGlinuxuser586
authored andcommitted
Add client SSL authentication using key-file for Postgres, MySQL and MariaDB (#1850)
* use native-tls API * Add client cert and key to MySQL connector * Add client ssl tests for PostgreSQL * Add client ssl tests for MariaDB and MySQL * Adapt GA tests * Fix RUSTFLAGS to run all tests * Remove containers to free the DB port before running SSL auth tests * Fix CI bad naming * Use docker-compose down to remove also the network * Fix main rebase * Stop trying to stop service using docker-compose, simply use docker cmd * Fix RUSTFLAGS for Postgres * Name the Docker images for MariaDB and MySQL so we can stop them using their name * Add the exception for mysql 5.7 not supporting compatible TLS version with RusTLS * Rebase fixes * Set correctly tls struct (fix merge) * Handle Elliptic Curve variant for private key * Fix tests suite * Fix features in CI * Add tests for Postgres 15 + rebase * Python tests: fix exception for MySQL 5.7 + remove unneeded for loops * CI: run SSL tests only when building with TLS support --------- Co-authored-by: Barry Simons <[email protected]>
1 parent 738aebf commit ce9a0a7

File tree

23 files changed

+641
-43
lines changed

23 files changed

+641
-43
lines changed

.github/workflows/sqlx.yml

+59-2
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,26 @@ jobs:
232232
# but `PgLTree` should just fall back to text format
233233
RUSTFLAGS: --cfg postgres_${{ matrix.postgres }}
234234

235+
# client SSL authentication
236+
237+
- run: |
238+
docker stop postgres_${{ matrix.postgres }}
239+
docker-compose -f tests/docker-compose.yml run -d -p 5432:5432 --name postgres_${{ matrix.postgres }}_client_ssl postgres_${{ matrix.postgres }}_client_ssl
240+
docker exec postgres_${{ matrix.postgres }}_client_ssl bash -c "until pg_isready; do sleep 1; done"
241+
242+
- uses: actions-rs/cargo@v1
243+
if: matrix.tls != 'none'
244+
with:
245+
command: test
246+
args: >
247+
--no-default-features
248+
--features any,postgres,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
249+
env:
250+
DATABASE_URL: postgres://postgres@localhost:5432/sqlx?sslmode=verify-ca&sslrootcert=.%2Ftests%2Fcerts%2Fca.crt&sslkey=.%2Ftests%2Fkeys%2Fclient.key&sslcert=.%2Ftests%2Fcerts%2Fclient.crt
251+
# FIXME: needed to disable `ltree` tests in Postgres 9.6
252+
# but `PgLTree` should just fall back to text format
253+
RUSTFLAGS: --cfg postgres_${{ matrix.postgres }}_client_ssl
254+
235255
mysql:
236256
name: MySQL
237257
runs-on: ubuntu-20.04
@@ -260,7 +280,7 @@ jobs:
260280
args: >
261281
--features mysql,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
262282
263-
- run: docker-compose -f tests/docker-compose.yml run -d -p 3306:3306 mysql_${{ matrix.mysql }}
283+
- run: docker-compose -f tests/docker-compose.yml run -d -p 3306:3306 --name mysql_${{ matrix.mysql }} mysql_${{ matrix.mysql }}
264284
- run: sleep 60
265285

266286
- uses: actions-rs/cargo@v1
@@ -285,6 +305,25 @@ jobs:
285305
DATABASE_URL: mysql://root:password@localhost:3306/sqlx
286306
RUSTFLAGS: --cfg mysql_${{ matrix.mysql }}
287307

308+
# client SSL authentication
309+
310+
- run: |
311+
docker stop mysql_${{ matrix.mysql }}
312+
docker-compose -f tests/docker-compose.yml run -d -p 3306:3306 --name mysql_${{ matrix.mysql }}_client_ssl mysql_${{ matrix.mysql }}_client_ssl
313+
sleep 60
314+
315+
# MySQL 5.7 supports TLS but not TLSv1.3 as required by RusTLS.
316+
- uses: actions-rs/cargo@v1
317+
if: ${{ !(matrix.mysql == '5_7' && matrix.tls == 'rustls') && matrix.tls != 'none' }}
318+
with:
319+
command: test
320+
args: >
321+
--no-default-features
322+
--features any,mysql,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
323+
env:
324+
DATABASE_URL: mysql://root@localhost:3306/sqlx?sslmode=verify_ca&ssl-ca=.%2Ftests%2Fcerts%2Fca.crt&ssl-key=.%2Ftests%2Fkeys%2Fclient.key&ssl-cert=.%2Ftests%2Fcerts%2Fclient.crt
325+
RUSTFLAGS: --cfg mysql_${{ matrix.mysql }}
326+
288327
mariadb:
289328
name: MariaDB
290329
runs-on: ubuntu-20.04
@@ -313,7 +352,7 @@ jobs:
313352
args: >
314353
--features mysql,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
315354
316-
- run: docker-compose -f tests/docker-compose.yml run -d -p 3306:3306 mariadb_${{ matrix.mariadb }}
355+
- run: docker-compose -f tests/docker-compose.yml run -d -p 3306:3306 --name mariadb_${{ matrix.mariadb }} mariadb_${{ matrix.mariadb }}
317356
- run: sleep 30
318357

319358
- uses: actions-rs/cargo@v1
@@ -325,3 +364,21 @@ jobs:
325364
env:
326365
DATABASE_URL: mysql://root:password@localhost:3306/sqlx
327366
RUSTFLAGS: --cfg mariadb_${{ matrix.mariadb }}
367+
368+
# client SSL authentication
369+
370+
- run: |
371+
docker stop mariadb_${{ matrix.mariadb }}
372+
docker-compose -f tests/docker-compose.yml run -d -p 3306:3306 --name mariadb_${{ matrix.mariadb }}_client_ssl mariadb_${{ matrix.mariadb }}_client_ssl
373+
sleep 60
374+
375+
- uses: actions-rs/cargo@v1
376+
if: matrix.tls != 'none'
377+
with:
378+
command: test
379+
args: >
380+
--no-default-features
381+
--features any,mysql,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
382+
env:
383+
DATABASE_URL: mysql://root@localhost:3306/sqlx?sslmode=verify_ca&ssl-ca=.%2Ftests%2Fcerts%2Fca.crt&ssl-key=.%2Ftests%2Fkeys%2Fclient.key&ssl-cert=.%2Ftests%2Fcerts%2Fclient.crt
384+
RUSTFLAGS: --cfg mariadb_${{ matrix.mariadb }}

sqlx-core/src/net/tls/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ pub struct TlsConfig<'a> {
6161
pub accept_invalid_hostnames: bool,
6262
pub hostname: &'a str,
6363
pub root_cert_path: Option<&'a CertificateInput>,
64+
pub client_cert_path: Option<&'a CertificateInput>,
65+
pub client_key_path: Option<&'a CertificateInput>,
6466
}
6567

6668
pub async fn handshake<S, Ws>(

sqlx-core/src/net/tls/tls_native_tls.rs

+9-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::net::tls::TlsConfig;
66
use crate::net::Socket;
77
use crate::Error;
88

9-
use native_tls::HandshakeError;
9+
use native_tls::{HandshakeError, Identity};
1010
use std::task::{Context, Poll};
1111

1212
pub struct NativeTlsSocket<S: Socket> {
@@ -53,6 +53,14 @@ pub async fn handshake<S: Socket>(
5353
builder.add_root_certificate(native_tls::Certificate::from_pem(&data).map_err(Error::tls)?);
5454
}
5555

56+
// authentication using user's key-file and its associated certificate
57+
if let (Some(cert_path), Some(key_path)) = (config.client_cert_path, config.client_key_path) {
58+
let cert_path = cert_path.data().await?;
59+
let key_path = key_path.data().await?;
60+
let identity = Identity::from_pkcs8(&cert_path, &key_path).map_err(Error::tls)?;
61+
builder.identity(identity);
62+
}
63+
5664
let connector = builder.build().map_err(Error::tls)?;
5765

5866
let mut mid_handshake = match connector.connect(config.hostname, StdSocket::new(socket)) {

sqlx-core/src/net/tls/tls_rustls.rs

+72-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use futures_util::future;
2-
use std::io;
3-
use std::io::{Cursor, Read, Write};
2+
use rustls::{Certificate, PrivateKey};
3+
use std::io::{self, BufReader, Cursor, Read, Write};
44
use std::sync::Arc;
55
use std::task::{Context, Poll};
66
use std::time::SystemTime;
@@ -13,7 +13,7 @@ use rustls::{
1313
use crate::error::Error;
1414
use crate::io::ReadBuf;
1515
use crate::net::tls::util::StdSocket;
16-
use crate::net::tls::TlsConfig;
16+
use crate::net::tls::{CertificateInput, TlsConfig};
1717
use crate::net::Socket;
1818

1919
pub struct RustlsSocket<S: Socket> {
@@ -48,7 +48,7 @@ impl<S: Socket> Socket for RustlsSocket<S> {
4848
match self.state.writer().write(buf) {
4949
// Returns a zero-length write when the buffer is full.
5050
Ok(0) => Err(io::ErrorKind::WouldBlock.into()),
51-
other => return other,
51+
other => other,
5252
}
5353
}
5454

@@ -81,10 +81,32 @@ where
8181
{
8282
let config = ClientConfig::builder().with_safe_defaults();
8383

84+
// authentication using user's key and its associated certificate
85+
let user_auth = match (tls_config.client_cert_path, tls_config.client_key_path) {
86+
(Some(cert_path), Some(key_path)) => {
87+
let cert_chain = certs_from_pem(cert_path.data().await?)?;
88+
let key_der = private_key_from_pem(key_path.data().await?)?;
89+
Some((cert_chain, key_der))
90+
}
91+
(None, None) => None,
92+
(_, _) => {
93+
return Err(Error::Configuration(
94+
"user auth key and certs must be given together".into(),
95+
))
96+
}
97+
};
98+
8499
let config = if tls_config.accept_invalid_certs {
85-
config
86-
.with_custom_certificate_verifier(Arc::new(DummyTlsVerifier))
87-
.with_no_client_auth()
100+
if let Some(user_auth) = user_auth {
101+
config
102+
.with_custom_certificate_verifier(Arc::new(DummyTlsVerifier))
103+
.with_single_cert(user_auth.0, user_auth.1)
104+
.map_err(Error::tls)?
105+
} else {
106+
config
107+
.with_custom_certificate_verifier(Arc::new(DummyTlsVerifier))
108+
.with_no_client_auth()
109+
}
88110
} else {
89111
let mut cert_store = RootCertStore::empty();
90112
cert_store.add_server_trust_anchors(webpki_roots::TLS_SERVER_ROOTS.0.iter().map(|ta| {
@@ -100,7 +122,7 @@ where
100122
let mut cursor = Cursor::new(data);
101123

102124
for cert in rustls_pemfile::certs(&mut cursor)
103-
.map_err(|_| Error::Tls(format!("Invalid certificate {}", ca).into()))?
125+
.map_err(|_| Error::Tls(format!("Invalid certificate {ca}").into()))?
104126
{
105127
cert_store
106128
.add(&rustls::Certificate(cert))
@@ -111,9 +133,21 @@ where
111133
if tls_config.accept_invalid_hostnames {
112134
let verifier = WebPkiVerifier::new(cert_store, None);
113135

136+
if let Some(user_auth) = user_auth {
137+
config
138+
.with_custom_certificate_verifier(Arc::new(NoHostnameTlsVerifier { verifier }))
139+
.with_single_cert(user_auth.0, user_auth.1)
140+
.map_err(Error::tls)?
141+
} else {
142+
config
143+
.with_custom_certificate_verifier(Arc::new(NoHostnameTlsVerifier { verifier }))
144+
.with_no_client_auth()
145+
}
146+
} else if let Some(user_auth) = user_auth {
114147
config
115-
.with_custom_certificate_verifier(Arc::new(NoHostnameTlsVerifier { verifier }))
116-
.with_no_client_auth()
148+
.with_root_certificates(cert_store)
149+
.with_single_cert(user_auth.0, user_auth.1)
150+
.map_err(Error::tls)?
117151
} else {
118152
config
119153
.with_root_certificates(cert_store)
@@ -135,6 +169,34 @@ where
135169
Ok(socket)
136170
}
137171

172+
fn certs_from_pem(pem: Vec<u8>) -> Result<Vec<rustls::Certificate>, Error> {
173+
let cur = Cursor::new(pem);
174+
let mut reader = BufReader::new(cur);
175+
rustls_pemfile::certs(&mut reader)?
176+
.into_iter()
177+
.map(|v| Ok(rustls::Certificate(v)))
178+
.collect()
179+
}
180+
181+
fn private_key_from_pem(pem: Vec<u8>) -> Result<rustls::PrivateKey, Error> {
182+
let cur = Cursor::new(pem);
183+
let mut reader = BufReader::new(cur);
184+
185+
loop {
186+
match rustls_pemfile::read_one(&mut reader)? {
187+
Some(
188+
rustls_pemfile::Item::RSAKey(key)
189+
| rustls_pemfile::Item::PKCS8Key(key)
190+
| rustls_pemfile::Item::ECKey(key),
191+
) => return Ok(rustls::PrivateKey(key)),
192+
None => break,
193+
_ => {}
194+
}
195+
}
196+
197+
Err(Error::Configuration("no keys found pem file".into()))
198+
}
199+
138200
struct DummyTlsVerifier;
139201

140202
impl ServerCertVerifier for DummyTlsVerifier {

sqlx-mysql/src/connection/tls.rs

+2
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ pub(super) async fn maybe_upgrade<S: Socket>(
6464
accept_invalid_hostnames: !matches!(options.ssl_mode, MySqlSslMode::VerifyIdentity),
6565
hostname: &options.host,
6666
root_cert_path: options.ssl_ca.as_ref(),
67+
client_cert_path: options.ssl_client_cert.as_ref(),
68+
client_key_path: options.ssl_client_key.as_ref(),
6769
};
6870

6971
// Request TLS upgrade

sqlx-mysql/src/options/mod.rs

+34
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ pub struct MySqlConnectOptions {
6161
pub(crate) database: Option<String>,
6262
pub(crate) ssl_mode: MySqlSslMode,
6363
pub(crate) ssl_ca: Option<CertificateInput>,
64+
pub(crate) ssl_client_cert: Option<CertificateInput>,
65+
pub(crate) ssl_client_key: Option<CertificateInput>,
6466
pub(crate) statement_cache_capacity: usize,
6567
pub(crate) charset: String,
6668
pub(crate) collation: Option<String>,
@@ -88,6 +90,8 @@ impl MySqlConnectOptions {
8890
collation: None,
8991
ssl_mode: MySqlSslMode::Preferred,
9092
ssl_ca: None,
93+
ssl_client_cert: None,
94+
ssl_client_key: None,
9195
statement_cache_capacity: 100,
9296
log_settings: Default::default(),
9397
pipes_as_concat: true,
@@ -186,6 +190,36 @@ impl MySqlConnectOptions {
186190
self
187191
}
188192

193+
/// Sets the name of a file containing SSL client certificate.
194+
///
195+
/// # Example
196+
///
197+
/// ```rust
198+
/// # use sqlx_core::mysql::{MySqlSslMode, MySqlConnectOptions};
199+
/// let options = MySqlConnectOptions::new()
200+
/// .ssl_mode(MySqlSslMode::VerifyCa)
201+
/// .ssl_client_cert("path/to/client.crt");
202+
/// ```
203+
pub fn ssl_client_cert(mut self, cert: impl AsRef<Path>) -> Self {
204+
self.ssl_client_cert = Some(CertificateInput::File(cert.as_ref().to_path_buf()));
205+
self
206+
}
207+
208+
/// Sets the name of a file containing SSL client key.
209+
///
210+
/// # Example
211+
///
212+
/// ```rust
213+
/// # use sqlx_core::mysql::{MySqlSslMode, MySqlConnectOptions};
214+
/// let options = MySqlConnectOptions::new()
215+
/// .ssl_mode(MySqlSslMode::VerifyCa)
216+
/// .ssl_client_key("path/to/client.key");
217+
/// ```
218+
pub fn ssl_client_key(mut self, key: impl AsRef<Path>) -> Self {
219+
self.ssl_client_key = Some(CertificateInput::File(key.as_ref().to_path_buf()));
220+
self
221+
}
222+
189223
/// Sets the capacity of the connection's statement cache in a number of stored
190224
/// distinct statements. Caching is handled using LRU, meaning when the
191225
/// amount of queries hits the defined limit, the oldest statement will get

sqlx-mysql/src/options/parse.rs

+6-2
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ impl MySqlConnectOptions {
4343

4444
for (key, value) in url.query_pairs().into_iter() {
4545
match &*key {
46-
"ssl-mode" => {
46+
"sslmode" | "ssl-mode" => {
4747
options = options.ssl_mode(value.parse().map_err(Error::config)?);
4848
}
4949

50-
"ssl-ca" => {
50+
"sslca" | "ssl-ca" => {
5151
options = options.ssl_ca(&*value);
5252
}
5353

@@ -59,6 +59,10 @@ impl MySqlConnectOptions {
5959
options = options.collation(&*value);
6060
}
6161

62+
"sslcert" | "ssl-cert" => options = options.ssl_client_cert(&*value),
63+
64+
"sslkey" | "ssl-key" => options = options.ssl_client_key(&*value),
65+
6266
"statement-cache-capacity" => {
6367
options =
6468
options.statement_cache_capacity(value.parse().map_err(Error::config)?);

sqlx-postgres/src/connection/tls.rs

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ async fn maybe_upgrade<S: Socket>(
5858
accept_invalid_hostnames,
5959
hostname: &options.host,
6060
root_cert_path: options.ssl_root_cert.as_ref(),
61+
client_cert_path: options.ssl_client_cert.as_ref(),
62+
client_key_path: options.ssl_client_key.as_ref(),
6163
};
6264

6365
tls::handshake(socket, config, SocketIntoBox).await

0 commit comments

Comments
 (0)