This is an automated email from the ASF dual-hosted git repository. HTHou pushed a commit to branch codex/use-rustls in repository https://gitbox.apache.org/repos/asf/iotdb-client-rust.git
commit 5c9a4d80f95365f420563b50cbf2e9b5a50b97c5 Author: HTHou <[email protected]> AuthorDate: Wed Aug 12 17:31:54 2026 +0800 Use rustls for TLS --- .github/workflows/ci.yml | 33 ++++++ Cargo.toml | 19 +++- README.md | 11 +- README_ZH.md | 10 +- src/connection/mod.rs | 265 ++++++++++++++++++++++++++++++++++--------- src/error.rs | 5 +- tests/fixtures/tls/README.md | 14 +-- 7 files changed, 287 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bec0ae..23b6d6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,39 @@ jobs: - name: Unit tests (tls) run: cargo test --features tls + tls-platforms: + name: tls (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # Exercises each OS verifier and native root-store integration. + - name: Unit tests (tls) + run: cargo test --features tls + + msrv: + name: msrv (rust 1.75) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: 1.75.0 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Check (tls) + run: cargo check --features tls + integration: runs-on: ubuntu-latest strategy: diff --git a/Cargo.toml b/Cargo.toml index 8d9b168..c025128 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,13 +36,24 @@ thrift = "0.23" byteorder = "1.5" chrono = "0.4" log = "0.4" -native-tls = { version = "0.2", optional = true } +# thrift 0.23 allows any uuid 1.x; 1.21+ raises MSRV to Rust 1.85. +uuid = "=1.20.0" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true } +rustls-pemfile = { version = "2.2", optional = true } +# 0.7 raises MSRV to 1.85; the crate currently supports Rust 1.75. +rustls-platform-verifier = { version = "0.6.2", optional = true } +# Keep Cargo 1.75 from resolving zeroize 1.9, whose manifest uses edition 2024. +zeroize = { version = "=1.8.2", optional = true } + +[target.'cfg(any(target_vendor = "apple"))'.dependencies] +# rustls-platform-verifier 0.6 allows 3.x; 3.7 raises MSRV to Rust 1.85. +security-framework = { version = "=3.5.1", optional = true } [dev-dependencies] env_logger = "0.11" [features] default = [] -# TLS support via the platform-native TLS stack (SecureTransport / -# SChannel / OpenSSL). Adds `use_ssl` & friends to SessionConfig. -tls = ["dep:native-tls"] +# TLS 1.2/1.3 via rustls, using the ring crypto provider and platform +# certificate verification. Adds `use_ssl` & friends to SessionConfig. +tls = ["dep:rustls", "dep:rustls-pemfile", "dep:rustls-platform-verifier", "dep:zeroize", "dep:security-framework"] diff --git a/README.md b/README.md index ef06b58..5691ab7 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,12 @@ let config = SessionConfig { enable_rpc_compression: true, ..Default::default() It must match the **server** setting `dn_rpc_thrift_compression_enable` (default `false`). The server speaks exactly one protocol — there is no per-connection negotiation, so a mismatch in either direction fails at the first RPC with a transport error. -**TLS** is behind the `tls` cargo feature (platform-native TLS via [`native-tls`](https://crates.io/crates/native-tls)): +**TLS** is behind the `tls` cargo feature. It uses [`rustls`](https://crates.io/crates/rustls) +with the `ring` crypto provider and supports TLS 1.2/1.3. Server certificates are +verified with [`rustls-platform-verifier`](https://crates.io/crates/rustls-platform-verifier), +so the platform trust store and verification policy are used where available; +on Linux/BSD, platform roots are loaded and verified with WebPKI. A CA supplied +through `ca_cert_path` is added to those platform roots. ```toml iotdb-client-rust = { version = "0.1", features = ["tls"] } @@ -200,6 +205,10 @@ let config = SessionConfig { // or: TableSession::builder().use_ssl(true).ca_cert_path("ca.pem")... ``` +`accept_invalid_certs` disables certificate-chain and hostname verification, +but TLS handshake signatures are still cryptographically verified. It should +only be used with controlled test servers. + For **mutual TLS** (server has `thrift_ssl_client_auth=true`), add a PEM client certificate and its PKCS#8 key — the analogue of the Node.js `sslOptions.cert`/`sslOptions.key`: ```rust diff --git a/README_ZH.md b/README_ZH.md index 8161bf1..f3e454a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -183,7 +183,12 @@ let config = SessionConfig { enable_rpc_compression: true, ..Default::default() 必须与**服务端**配置 `dn_rpc_thrift_compression_enable`(默认 `false`)一致。服务端只讲一种协议——没有按连接协商的机制,任意方向的不匹配都会在第一个 RPC 上以传输错误失败。 -**TLS** 位于 `tls` cargo feature 之后(基于 [`native-tls`](https://crates.io/crates/native-tls) 的平台原生 TLS): +**TLS** 位于 `tls` cargo feature 之后。其底层使用 +[`rustls`](https://crates.io/crates/rustls) 和 `ring` 密码学 provider,支持 +TLS 1.2/1.3。服务端证书通过 +[`rustls-platform-verifier`](https://crates.io/crates/rustls-platform-verifier) +校验:在支持的平台上使用系统信任库及校验策略;在 Linux/BSD 上加载系统根证书并通过 +WebPKI 校验。`ca_cert_path` 指定的 CA 会追加到系统根证书中。 ```toml iotdb-client-rust = { version = "0.1", features = ["tls"] } @@ -200,6 +205,9 @@ let config = SessionConfig { // 或:TableSession::builder().use_ssl(true).ca_cert_path("ca.pem")... ``` +`accept_invalid_certs` 会关闭证书链和主机名校验,但 TLS 握手签名仍会经过密码学校验。 +该选项只应对受控的测试服务使用。 + **双向 TLS**(服务端 `thrift_ssl_client_auth=true`)需额外提供 PEM 客户端证书及其 PKCS#8 私钥 —— 对应 Node.js 的 `sslOptions.cert`/`sslOptions.key`: ```rust diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 69dc3c7..f861d37 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -24,8 +24,19 @@ use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; +#[cfg(feature = "tls")] +use std::sync::Arc; use std::time::Duration; +#[cfg(feature = "tls")] +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +#[cfg(feature = "tls")] +use rustls::crypto::WebPkiSupportedAlgorithms; +#[cfg(feature = "tls")] +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +#[cfg(feature = "tls")] +use rustls::{ClientConfig, ClientConnection, DigitallySignedStruct, SignatureScheme, StreamOwned}; + use thrift::protocol::{ TBinaryInputProtocol, TBinaryOutputProtocol, TCompactInputProtocol, TCompactOutputProtocol, TInputProtocol, TOutputProtocol, @@ -63,9 +74,10 @@ pub enum RpcProtocol { #[derive(Debug, Clone, Default)] pub struct TlsOptions { /// PEM certificate added as a trusted root (e.g. a private CA or the - /// server's self-signed certificate). + /// server's self-signed certificate), in addition to the platform roots. pub ca_cert_path: Option<std::path::PathBuf>, - /// Skip certificate verification entirely (self-signed test certs). + /// Skip certificate-chain and hostname verification (self-signed test + /// certs). TLS handshake signatures are still verified. /// **Dangerous** outside tests. Default `false`. pub accept_invalid_certs: bool, /// Hostname used for SNI + certificate validation instead of the @@ -264,56 +276,155 @@ fn connect_stream(endpoint: &Endpoint, connect_timeout: Duration) -> Result<TcpS #[cfg(feature = "tls")] fn tls_handshake( endpoint: &Endpoint, - stream: TcpStream, + mut stream: TcpStream, tls: &TlsOptions, -) -> Result<native_tls::TlsStream<TcpStream>> { - let mut builder = native_tls::TlsConnector::builder(); - if let Some(path) = &tls.ca_cert_path { - let pem = std::fs::read(path).map_err(|e| { - Error::Client(format!( - "cannot read CA certificate {}: {e}", - path.display() - )) - })?; - let cert = native_tls::Certificate::from_pem(&pem).map_err(Error::Tls)?; - builder.add_root_certificate(cert); - } - if tls.accept_invalid_certs { - builder.danger_accept_invalid_certs(true); +) -> Result<StreamOwned<ClientConnection, TcpStream>> { + let config = tls_client_config(tls)?; + let domain = tls.domain_override.as_deref().unwrap_or(&endpoint.host); + let server_name = ServerName::try_from(domain.to_owned()) + .map_err(|e| Error::Client(format!("invalid TLS server name '{domain}': {e}")))?; + let mut connection = + ClientConnection::new(config, server_name).map_err(|e| Error::Tls(e.to_string()))?; + + connection + .complete_io(&mut stream) + .map_err(|e| Error::Tls(e.to_string()))?; + if connection.is_handshaking() { + return Err(Error::Tls("TLS handshake did not complete".into())); } - match (&tls.client_cert_path, &tls.client_key_path) { + + Ok(StreamOwned::new(connection, stream)) +} + +#[cfg(feature = "tls")] +fn tls_client_config(tls: &TlsOptions) -> Result<Arc<ClientConfig>> { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let extra_roots = match &tls.ca_cert_path { + Some(path) => load_certificates(path, "CA certificate")?, + None => Vec::new(), + }; + + let verifier: Arc<dyn ServerCertVerifier> = if tls.accept_invalid_certs { + Arc::new(NoCertificateVerification::new( + provider.signature_verification_algorithms, + )) + } else { + Arc::new( + rustls_platform_verifier::Verifier::new_with_extra_roots( + extra_roots, + Arc::clone(&provider), + ) + .map_err(|e| Error::Tls(e.to_string()))?, + ) + }; + + let builder = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| Error::Tls(e.to_string()))? + .dangerous() + .with_custom_certificate_verifier(verifier); + + let config = match (&tls.client_cert_path, &tls.client_key_path) { (Some(cert_path), Some(key_path)) => { - let cert = std::fs::read(cert_path).map_err(|e| { - Error::Client(format!( - "cannot read client certificate {}: {e}", - cert_path.display() - )) - })?; - let key = std::fs::read(key_path).map_err(|e| { + let certificates = load_certificates(cert_path, "client certificate")?; + let key_file = std::fs::File::open(key_path).map_err(|e| { Error::Client(format!( "cannot read client key {}: {e}", key_path.display() )) })?; - let identity = native_tls::Identity::from_pkcs8(&cert, &key).map_err(Error::Tls)?; - builder.identity(identity); + let mut key_reader = std::io::BufReader::new(key_file); + let key = rustls_pemfile::pkcs8_private_keys(&mut key_reader) + .next() + .transpose() + .map_err(|e| Error::Tls(format!("cannot parse client key: {e}")))? + .ok_or_else(|| Error::Tls("client key is not a PEM PKCS#8 private key".into()))?; + builder + .with_client_auth_cert(certificates, key.into()) + .map_err(|e| Error::Tls(e.to_string()))? } - (None, None) => {} + (None, None) => builder.with_no_client_auth(), _ => { return Err(Error::Client( "mutual TLS requires both client_cert_path and client_key_path".into(), )) } - } - let connector = builder.build().map_err(Error::Tls)?; - let domain = tls.domain_override.as_deref().unwrap_or(&endpoint.host); - connector.connect(domain, stream).map_err(|e| match e { - native_tls::HandshakeError::Failure(e) => Error::Tls(e), - // Blocking sockets never yield the mid-handshake variant. - native_tls::HandshakeError::WouldBlock(_) => { - Error::Client("TLS handshake interrupted".into()) + }; + + Ok(Arc::new(config)) +} + +#[cfg(feature = "tls")] +fn load_certificates( + path: &std::path::Path, + description: &str, +) -> Result<Vec<CertificateDer<'static>>> { + let file = std::fs::File::open(path) + .map_err(|e| Error::Client(format!("cannot read {description} {}: {e}", path.display())))?; + let mut reader = std::io::BufReader::new(file); + let certificates = rustls_pemfile::certs(&mut reader) + .collect::<std::io::Result<Vec<_>>>() + .map_err(|e| Error::Tls(format!("cannot parse {description}: {e}")))?; + if certificates.is_empty() { + return Err(Error::Tls(format!( + "{description} {} contains no PEM certificates", + path.display() + ))); + } + Ok(certificates) +} + +/// Disables certificate-chain and hostname validation while retaining +/// cryptographic verification of the TLS handshake signatures. +#[cfg(feature = "tls")] +#[derive(Debug)] +struct NoCertificateVerification { + supported_algorithms: WebPkiSupportedAlgorithms, +} + +#[cfg(feature = "tls")] +impl NoCertificateVerification { + fn new(supported_algorithms: WebPkiSupportedAlgorithms) -> Self { + Self { + supported_algorithms, } - }) + } +} + +#[cfg(feature = "tls")] +impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result<ServerCertVerified, rustls::Error> { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported_algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec<SignatureScheme> { + self.supported_algorithms.supported_schemes() + } } /// A `TlsStream` shared between the read and write transports. @@ -325,15 +436,15 @@ fn tls_handshake( /// read and write never contend. #[cfg(feature = "tls")] #[derive(Clone)] -struct SharedTlsStream(std::sync::Arc<std::sync::Mutex<native_tls::TlsStream<TcpStream>>>); +struct SharedTlsStream(std::sync::Arc<std::sync::Mutex<StreamOwned<ClientConnection, TcpStream>>>); #[cfg(feature = "tls")] impl SharedTlsStream { - fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { + fn new(stream: StreamOwned<ClientConnection, TcpStream>) -> Self { Self(std::sync::Arc::new(std::sync::Mutex::new(stream))) } - fn lock(&self) -> std::sync::MutexGuard<'_, native_tls::TlsStream<TcpStream>> { + fn lock(&self) -> std::sync::MutexGuard<'_, StreamOwned<ClientConnection, TcpStream>> { self.0.lock().unwrap_or_else(|p| p.into_inner()) } } @@ -515,6 +626,7 @@ mod tls_tests { use super::*; use crate::protocol::client::TIClientRPCServiceSyncClient; use std::path::PathBuf; + use std::sync::Arc; fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -522,14 +634,56 @@ mod tls_tests { .join(name) } - /// Spawn a TLS acceptor on a loopback port that completes one handshake - /// and then drops the connection. Uses the checked-in self-signed cert - /// (CN=localhost, SAN DNS:localhost + IP:127.0.0.1, 100-year validity). + fn pkcs8_key(name: &str) -> rustls::pki_types::PrivateKeyDer<'static> { + let file = std::fs::File::open(fixture(name)).expect("read key fixture"); + let mut reader = std::io::BufReader::new(file); + let key = rustls_pemfile::pkcs8_private_keys(&mut reader) + .next() + .expect("PKCS#8 key item") + .expect("parse PKCS#8 key") + .into(); + key + } + + fn server_config(require_client_auth: bool) -> Arc<rustls::ServerConfig> { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = rustls::ServerConfig::builder_with_provider(Arc::clone(&provider)) + .with_safe_default_protocol_versions() + .expect("protocol versions"); + let builder = if require_client_auth { + let mut roots = rustls::RootCertStore::empty(); + for certificate in + load_certificates(&fixture("client-cert.pem"), "client root").expect("client root") + { + roots.add(certificate).expect("add client root"); + } + let verifier = + rustls::server::WebPkiClientVerifier::builder_with_provider(roots.into(), provider) + .build() + .expect("client verifier"); + builder.with_client_cert_verifier(verifier) + } else { + builder.with_no_client_auth() + }; + let config = builder + .with_single_cert( + load_certificates(&fixture("cert.pem"), "server certificate") + .expect("server certificate"), + pkcs8_key("key.pem"), + ) + .expect("server config"); + Arc::new(config) + } + + /// Spawn a rustls acceptor on a loopback port that completes handshakes + /// and then drops each connection. Uses the checked-in self-signed cert + /// (CN=localhost, SAN DNS:localhost + IP:127.0.0.1). fn tls_acceptor_once() -> Endpoint { - let cert = std::fs::read(fixture("cert.pem")).expect("read cert fixture"); - let key = std::fs::read(fixture("key.pem")).expect("read key fixture"); - let identity = native_tls::Identity::from_pkcs8(&cert, &key).expect("identity"); - let acceptor = native_tls::TlsAcceptor::new(identity).expect("acceptor"); + tls_acceptor(false) + } + + fn tls_acceptor(require_client_auth: bool) -> Endpoint { + let config = server_config(require_client_auth); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("local_addr").port(); std::thread::spawn(move || { @@ -537,7 +691,11 @@ mod tls_tests { match stream { // Handshake (which may itself fail when the client // rejects our cert — fine, just move on), then drop. - Ok(s) => drop(acceptor.accept(s)), + Ok(mut stream) => { + let mut connection = rustls::ServerConnection::new(Arc::clone(&config)) + .expect("server connection"); + let _ = connection.complete_io(&mut stream); + } Err(_) => break, } } @@ -674,15 +832,12 @@ mod tls_tests { assert!(matches!(err, Error::Tls(_)), "got {err:?}"); } - /// Mutual TLS: a PEM client certificate + PKCS#8 key load into an - /// identity and the handshake completes with the identity configured. - /// `native-tls`'s `TlsAcceptor` has no API to *request* a client - /// certificate, so the loopback server cannot verify it — acceptor-side - /// client-auth is exercised only by the live `IOTDB_TLS_URL` test - /// against a real server. + /// Mutual TLS: a PEM client certificate + PKCS#8 key load into the + /// client config, and a rustls server that requires the fixture client + /// certificate completes the handshake. #[test] fn tls_client_identity_handshake_succeeds() { - let endpoint = tls_acceptor_once(); + let endpoint = tls_acceptor(true); let options = ConnectionOptions { connect_timeout: Duration::from_millis(500), protocol: RpcProtocol::Binary, diff --git a/src/error.rs b/src/error.rs index 52b126c..5cb372f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -31,9 +31,10 @@ pub enum Error { Client(String), /// Malformed binary payload received from the server (e.g. truncated TsBlock). Decode(String), - /// TLS setup or handshake failure (cargo feature `tls`). + /// TLS configuration, certificate parsing, or handshake failure (cargo + /// feature `tls`). #[cfg(feature = "tls")] - Tls(native_tls::Error), + Tls(String), } impl fmt::Display for Error { diff --git a/tests/fixtures/tls/README.md b/tests/fixtures/tls/README.md index 7b5a9e5..fb7eb26 100644 --- a/tests/fixtures/tls/README.md +++ b/tests/fixtures/tls/README.md @@ -25,11 +25,12 @@ Not secrets — the keys never protect anything. | File | Role | |---|---| -| `cert.pem` + `key.pem` | server identity (loopback `TlsAcceptor`, live-server keystore) | +| `cert.pem` + `key.pem` | server identity (loopback rustls server, live-server keystore) | | `client-cert.pem` + `client-key.pem` | client identity for the mutual-TLS tests | -macOS SecureTransport imposes extra requirements even on explicitly -trusted roots: validity ≤ 825 days (error −67901) and an +The macOS verifier used by `rustls-platform-verifier` imposes extra +requirements even on explicitly trusted roots: validity ≤ 825 days +(error −67901) and an `extendedKeyUsage=serverAuth` extension (error −67609). Current cert expires **2028-10-10**; when the trusted-root test starts failing with a validity/expiry error, regenerate: @@ -43,10 +44,9 @@ openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \ -addext "keyUsage=digitalSignature,keyEncipherment" ``` -The client certificate (expires **2028-10-10** as well) is standalone -self-signed — `cert.pem` cannot act as its issuer because its `keyUsage` -lacks `keyCertSign`; nothing in the tests validates the client chain -anyway (`native-tls`'s `TlsAcceptor` cannot request client certs): +The client certificate (expires **2028-10-10** as well) is a standalone +self-signed test CA and client identity. The rustls loopback server trusts it +as a root and requires it during the mutual-TLS test: ```sh cd tests/fixtures/tls
