diff --git a/.github/actions/setup-boringssl/action.yml b/.github/actions/setup-boringssl/action.yml new file mode 100644 index 000000000..cfb0aefcc --- /dev/null +++ b/.github/actions/setup-boringssl/action.yml @@ -0,0 +1,45 @@ +name: "Setup BoringSSL" +description: "Clone, build, and cache BoringSSL with Rust bindings" +runs: + using: "composite" + steps: + - name: Cache BoringSSL build + uses: actions/cache@v4 + id: bssl-cache + with: + path: ${{ runner.temp }}/boringssl/build + key: boringssl-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }} + + - name: Install bindgen-cli + if: steps.bssl-cache.outputs.cache-hit != 'true' + uses: taiki-e/cache-cargo-install-action@v2 + with: + tool: bindgen-cli + + - name: Build BoringSSL + if: steps.bssl-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + BUILD_DIR="${{ runner.temp }}/boringssl/build" + SRC_DIR="${{ runner.temp }}/boringssl/src" + mkdir -p "${{ runner.temp }}/boringssl" + if [ ! -d "$SRC_DIR/.git" ]; then + git clone https://boringssl.googlesource.com/boringssl "$SRC_DIR" + fi + REV=$(grep -A 2 'name = "bssl-sys"' Cargo.lock | grep -o 'boringssl#[a-f0-9]*' | cut -d'#' -f2 | head -n1 | tr -d '\r' || true) + TARGET=$(rustc -vV | sed -n 's|host: ||p' | tr -d '\r') + cd "$SRC_DIR" + if [ -n "$REV" ]; then + git checkout "$REV" + fi + cmake -GNinja -B "$BUILD_DIR" -S "$SRC_DIR" -DBUILD_TESTING=OFF -DRUST_BINDINGS="$TARGET" + ninja -C "$BUILD_DIR" crypto ssl bssl_sys + + - name: Export BORINGSSL_BUILD_DIR and C++ runtime library + shell: bash + run: | + echo "BORINGSSL_BUILD_DIR=${{ runner.temp }}/boringssl/build" >> "$GITHUB_ENV" + if [ "${{ runner.os }}" = "Windows" ]; then + echo "BORINGSSL_RUST_CPPLIB=stdc++" >> "$GITHUB_ENV" + fi diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f3554dc36..3bae79590 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -75,6 +75,7 @@ jobs: - uses: hecrj/setup-rust-action@v2 with: components: clippy + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -116,6 +117,7 @@ jobs: toolchain: nightly-2026-02-22 - uses: taiki-e/install-action@cargo-hack - uses: taiki-e/install-action@cargo-udeps + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -147,6 +149,7 @@ jobs: - uses: actions/checkout@v6 - uses: hecrj/setup-rust-action@v2 - uses: taiki-e/install-action@cargo-hack + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -184,6 +187,7 @@ jobs: with: rust-version: ${{ steps.msrv.outputs.version }} - uses: taiki-e/install-action@cargo-no-dev-deps + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -203,6 +207,7 @@ jobs: - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/install@cargo-docs-rs - uses: taiki-e/install-action@cargo-hack + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -228,6 +233,7 @@ jobs: - uses: hecrj/setup-rust-action@v2 - uses: taiki-e/install-action@cargo-hack - uses: taiki-e/install-action@cargo-nextest + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -249,6 +255,7 @@ jobs: - uses: actions/checkout@v6 - uses: hecrj/setup-rust-action@v2 - uses: taiki-e/install-action@cargo-hack + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache @@ -293,6 +300,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: hecrj/setup-rust-action@v2 + - uses: ./.github/actions/setup-boringssl - uses: obi1kenobi/cargo-semver-checks-action@v2 with: feature-group: all-features @@ -309,6 +318,7 @@ jobs: with: tool: cargo-check-external-types@0.4.0 - uses: taiki-e/install-action@cargo-hack + - uses: ./.github/actions/setup-boringssl - uses: actions/cache@v4 with: path: protoc-cache diff --git a/Cargo.toml b/Cargo.toml index 23f4b37af..0116ccb53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "codegen", "grpc", "grpc-benchmark", + "grpc-bssl", "grpc-google", "grpc-protobuf", "grpc-protobuf-build", diff --git a/grpc-bssl/Cargo.toml b/grpc-bssl/Cargo.toml new file mode 100644 index 000000000..d5f808e0f --- /dev/null +++ b/grpc-bssl/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "grpc-bssl" +version = "0.10.0" +authors = ["gRPC authors"] +license = "MIT" +edition = "2024" +rust-version = { workspace = true } +publish = false +description = "BoringSSL TLS credentials provider for gRPC in Rust" +homepage = "https://grpc.io" +repository = "https://github.com/grpc/grpc-rust" +readme = "README.md" +keywords = ["rpc", "grpc", "async", "tls", "boringssl"] +categories = ["web-programming", "network-programming", "asynchronous"] + +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "bssl_tls::*", + "bssl_tls_tokio::*", + "grpc::*", +] + +[dependencies] +bssl-tls = { git = "https://boringssl.googlesource.com/boringssl", rev = "281fb6d844f8ef70c95ce9d3d599652cc2448292", package = "bssl-tls" } +bssl-tls-tokio = { git = "https://boringssl.googlesource.com/boringssl", rev = "281fb6d844f8ef70c95ce9d3d599652cc2448292", package = "bssl-tls-tokio" } +grpc = { path = "../grpc", default-features = false, features = ["__unstable"] } +tokio = { version = "1", features = ["io-util", "net"] } +tonic = { version = "0.14.6", path = "../tonic", default-features = false } + +[dev-dependencies] +bssl-x509 = { git = "https://boringssl.googlesource.com/boringssl", rev = "281fb6d844f8ef70c95ce9d3d599652cc2448292", package = "bssl-x509" } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net"] } diff --git a/grpc-bssl/README.md b/grpc-bssl/README.md new file mode 100644 index 000000000..b8d4b3b8f --- /dev/null +++ b/grpc-bssl/README.md @@ -0,0 +1,5 @@ +# grpc-bssl + +BoringSSL TLS transport credentials provider for [`grpc`](../grpc). + +This crate provides `BsslChannelCredentials` and `BsslServerCredentials`, which integrate BoringSSL (`bssl-tls` and `bssl-tls-tokio`) transport security with the `grpc` crate. diff --git a/grpc-bssl/src/client.rs b/grpc-bssl/src/client.rs new file mode 100644 index 000000000..8f5168686 --- /dev/null +++ b/grpc-bssl/src/client.rs @@ -0,0 +1,158 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +use std::fmt; +use std::sync::Arc; + +use bssl_tls::alpn::H2; +use bssl_tls::context::TlsContextBuilder; +use bssl_tls_tokio::{TlsConnector, TokioTlsExt}; +use grpc::__unstable::Internal; +use grpc::__unstable::credentials::client::{ + ClientHandshakeInfo, HandshakeOutput, ValidateAuthority, +}; +use grpc::__unstable::credentials::common::Authority; +use grpc::__unstable::rt::{BoxEndpoint, EndpointIoStream, GrpcRuntime, StreamEndpoint}; +use grpc::credentials::call::CallCredentials; +use grpc::credentials::{ChannelCredentials, ProtocolInfo, SecurityInfo, SecurityLevel}; +use tonic::async_trait; + +/// Authority validator for BoringSSL-based channel credentials. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BsslAuthorityValidator { + expected_host: String, +} + +impl BsslAuthorityValidator { + /// Creates a new validator with the expected server authority host. + pub fn new(expected_host: impl Into) -> Self { + Self { + expected_host: expected_host.into(), + } + } +} + +impl ValidateAuthority for BsslAuthorityValidator { + fn validate_authority(&self, authority: &Authority) -> bool { + self.expected_host == authority.host() + } +} + +/// Client-side transport security credentials powered by BoringSSL (`bssl-tls`). +pub struct BsslChannelCredentials { + connector: TlsConnector, + protocol_info: ProtocolInfo, +} + +impl fmt::Debug for BsslChannelCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BsslChannelCredentials") + .field("protocol_info", &self.protocol_info.security_protocol()) + .finish() + } +} + +impl BsslChannelCredentials { + /// Creates credentials with an existing [`TlsConnector`]. + pub fn new(connector: TlsConnector) -> Self { + Self { + connector, + protocol_info: ProtocolInfo::new("tls"), + } + } + + /// Creates credentials from a [`TlsContextBuilder`], automatically configuring ALPN for HTTP/2 (`h2`). + pub fn from_context_builder( + mut builder: TlsContextBuilder, + ) -> Result { + builder.set_alpn_protocols([H2])?; + Ok(Self::new(builder.build_tokio_connector())) + } +} + +#[async_trait] +impl ChannelCredentials for BsslChannelCredentials { + fn info(&self) -> &ProtocolInfo { + &self.protocol_info + } + + fn get_call_credentials(&self, _token: Internal) -> Option<&Arc> { + None + } + + async fn connect( + &self, + authority: &Authority, + source: BoxEndpoint, + _info: &ClientHandshakeInfo, + _runtime: &GrpcRuntime, + _token: Internal, + ) -> Result { + let local_addr: Box = source.get_local_address().into(); + let peer_addr: Box = source.get_peer_address().into(); + let network_type = source.get_network_type(); + + let host = authority.host(); + let input_io = EndpointIoStream::new(source); + + let mut tls_stream = self + .connector + .connect(host, input_io) + .await + .map_err(|e| format!("BoringSSL TLS client handshake failed: {e}"))?; + + let alpn = if let Some(est) = tls_stream.get_mut().established() { + est.get_selected_alpn().map(|s| s.to_vec()) + } else { + None + }; + + if let Some(selected) = alpn { + if selected != H2 { + return Err(format!( + "Server negotiated unexpected ALPN protocol: {:?}", + String::from_utf8_lossy(&selected) + )); + } + } else { + return Err("Server did not negotiate ALPN (h2 required)".into()); + } + + let endpoint: BoxEndpoint = Box::new(StreamEndpoint::new( + tls_stream, + local_addr, + peer_addr, + network_type, + )); + + let security_info = + SecurityInfo::new("tls").with_security_level(SecurityLevel::PrivacyAndIntegrity); + + Ok(HandshakeOutput { + endpoint, + security_info, + authority_validator: Box::new(BsslAuthorityValidator::new(host)), + }) + } +} diff --git a/grpc-bssl/src/lib.rs b/grpc-bssl/src/lib.rs new file mode 100644 index 000000000..19a204e86 --- /dev/null +++ b/grpc-bssl/src/lib.rs @@ -0,0 +1,35 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +//! BoringSSL TLS transport credentials for gRPC in Rust. +//! +//! This crate provides [`BsslChannelCredentials`] and [`BsslServerCredentials`], +//! which integrate Google's `bssl-tls` / `bssl-tls-tokio` stack with the `grpc` crate. + +pub mod client; +pub mod server; + +pub use client::BsslAuthorityValidator; +pub use client::BsslChannelCredentials; +pub use server::BsslServerCredentials; diff --git a/grpc-bssl/src/server.rs b/grpc-bssl/src/server.rs new file mode 100644 index 000000000..00f22c06b --- /dev/null +++ b/grpc-bssl/src/server.rs @@ -0,0 +1,121 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +use std::fmt; + +use bssl_tls::alpn::H2; +use bssl_tls::context::TlsContextBuilder; +use bssl_tls_tokio::{TlsAcceptor, TokioTlsExt}; +use grpc::__unstable::Internal; +use grpc::__unstable::credentials::server::HandshakeOutput; +use grpc::__unstable::rt::{BoxEndpoint, EndpointIoStream, GrpcRuntime, StreamEndpoint}; +use grpc::credentials::{ProtocolInfo, SecurityInfo, SecurityLevel, ServerCredentials}; +use tonic::async_trait; + +/// Server-side transport security credentials powered by BoringSSL (`bssl-tls`). +pub struct BsslServerCredentials { + acceptor: TlsAcceptor, + protocol_info: ProtocolInfo, +} + +impl fmt::Debug for BsslServerCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BsslServerCredentials") + .field("protocol_info", &self.protocol_info.security_protocol()) + .finish() + } +} + +impl BsslServerCredentials { + /// Creates credentials with an existing [`TlsAcceptor`]. + pub fn new(acceptor: TlsAcceptor) -> Self { + Self { + acceptor, + protocol_info: ProtocolInfo::new("tls"), + } + } + + /// Creates credentials from a [`TlsContextBuilder`], automatically configuring ALPN for HTTP/2 (`h2`). + pub fn from_context_builder( + mut builder: TlsContextBuilder, + ) -> Result { + builder.set_alpn_protocols([H2])?; + Ok(Self::new(builder.build_tokio_acceptor())) + } +} + +#[async_trait] +impl ServerCredentials for BsslServerCredentials { + fn info(&self) -> &ProtocolInfo { + &self.protocol_info + } + + async fn accept( + &self, + source: BoxEndpoint, + _runtime: GrpcRuntime, + _token: Internal, + ) -> Result { + let local_addr: Box = source.get_local_address().into(); + let peer_addr: Box = source.get_peer_address().into(); + let network_type = source.get_network_type(); + + let input_io = EndpointIoStream::new(source); + + let mut tls_stream = self + .acceptor + .accept(input_io) + .await + .map_err(|e| format!("BoringSSL TLS server accept failed: {e}"))?; + + let alpn = if let Some(est) = tls_stream.get_mut().established() { + est.get_selected_alpn().map(|s| s.to_vec()) + } else { + None + }; + + if let Some(selected) = alpn { + if selected != H2 { + return Err(format!( + "Client negotiated unexpected ALPN protocol: {:?}", + String::from_utf8_lossy(&selected) + )); + } + } else { + return Err("Client did not negotiate ALPN (h2 required)".into()); + } + + let endpoint: BoxEndpoint = Box::new(StreamEndpoint::new( + tls_stream, + local_addr, + peer_addr, + network_type, + )); + + let security = + SecurityInfo::new("tls").with_security_level(SecurityLevel::PrivacyAndIntegrity); + + Ok(HandshakeOutput { endpoint, security }) + } +} diff --git a/grpc-bssl/tests/tls_test.rs b/grpc-bssl/tests/tls_test.rs new file mode 100644 index 000000000..88dcf0a15 --- /dev/null +++ b/grpc-bssl/tests/tls_test.rs @@ -0,0 +1,432 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +use std::sync::Arc; + +use bssl_tls::alpn::H2; +use bssl_tls::context::TlsContextBuilder; +use bssl_tls::credentials::{Certificate, CertificateVerificationMode, TlsCredentialBuilder}; +use bssl_tls_tokio::TokioTlsExt; +use bssl_x509::certificates::X509Certificate; +use bssl_x509::keys::PrivateKey; +use bssl_x509::params::Trust; +use bssl_x509::store::X509StoreBuilder; +use grpc::__unstable::Internal; +use grpc::__unstable::credentials::client::{ClientHandshakeInfo, ValidateAuthority}; +use grpc::__unstable::credentials::common::Authority; +use grpc::__unstable::rt::{BoxEndpoint, EndpointIoStream, GrpcRuntime, StreamEndpoint}; +use grpc::credentials::{ChannelCredentials, SecurityLevel, ServerCredentials}; +use grpc_bssl::{BsslAuthorityValidator, BsslChannelCredentials, BsslServerCredentials}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const CA_PEM: &[u8] = include_bytes!("../../examples/data/tls/ca.pem"); +const SERVER_CERT_PEM: &[u8] = include_bytes!("../../examples/data/tls/server.pem"); +const SERVER_KEY_PEM: &[u8] = include_bytes!("../../examples/data/tls/server.key"); +const CLIENT_CA_PEM: &[u8] = include_bytes!("../../examples/data/tls/client_ca.pem"); +const CLIENT1_CERT_PEM: &[u8] = include_bytes!("../../examples/data/tls/client1.pem"); +const CLIENT1_KEY_PEM: &[u8] = include_bytes!("../../examples/data/tls/client1.key"); + +fn server_context_builder() -> TlsContextBuilder { + let ca = Certificate::parse_one_from_pem(CA_PEM, None).unwrap(); + let server_cert = Certificate::parse_one_from_pem(SERVER_CERT_PEM, None).unwrap(); + let server_key = PrivateKey::from_pem(SERVER_KEY_PEM, || unreachable!()).unwrap(); + + let mut cred_builder = TlsCredentialBuilder::new(); + cred_builder + .with_certificate_chain(&[server_cert, ca]) + .unwrap() + .with_private_key(server_key) + .unwrap(); + + let mut ctx_builder = TlsContextBuilder::new_tls(); + ctx_builder + .with_credential(cred_builder.build().unwrap()) + .unwrap(); + ctx_builder +} + +fn client_context_builder() -> TlsContextBuilder { + let mut cert_store = X509StoreBuilder::new(); + cert_store + .set_trust(Trust::SslServer) + .unwrap() + .add_cert(X509Certificate::parse_one_from_pem(CA_PEM).unwrap()) + .unwrap(); + + let cert_store = cert_store.build(); + let mut ctx_builder = TlsContextBuilder::new_tls(); + ctx_builder.with_certificate_store(&cert_store); + ctx_builder +} + +fn server_mtls_context_builder() -> TlsContextBuilder { + let mut ctx_builder = server_context_builder(); + let mut client_ca_store = X509StoreBuilder::new(); + client_ca_store + .set_trust(Trust::SslClient) + .unwrap() + .add_cert(X509Certificate::parse_one_from_pem(CLIENT_CA_PEM).unwrap()) + .unwrap(); + + let client_ca_store = client_ca_store.build(); + ctx_builder + .with_certificate_store(&client_ca_store) + .with_certificate_verification_mode(CertificateVerificationMode::PeerCertMandatory); + ctx_builder +} + +fn client_mtls_context_builder() -> TlsContextBuilder { + let mut ctx_builder = client_context_builder(); + let client_ca = Certificate::parse_one_from_pem(CLIENT_CA_PEM, None).unwrap(); + let client_cert = Certificate::parse_one_from_pem(CLIENT1_CERT_PEM, None).unwrap(); + let client_key = PrivateKey::from_pem(CLIENT1_KEY_PEM, || unreachable!()).unwrap(); + + let mut cred_builder = TlsCredentialBuilder::new(); + cred_builder + .with_certificate_chain(&[client_cert, client_ca]) + .unwrap() + .with_private_key(client_key) + .unwrap(); + + ctx_builder + .with_credential(cred_builder.build().unwrap()) + .unwrap(); + ctx_builder +} + +async fn run_handshake_pair( + server_creds: Arc, + client_creds: Arc, + authority_host: &str, +) -> ( + Result, + Result, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_task = tokio::spawn(async move { + let (tcp_stream, peer_addr) = listener.accept().await.unwrap(); + let local_addr = tcp_stream.local_addr().unwrap(); + let raw_endpoint: BoxEndpoint = Box::new(StreamEndpoint::new( + tcp_stream, + local_addr.to_string().into_boxed_str(), + peer_addr.to_string().into_boxed_str(), + "tcp", + )); + server_creds + .accept(raw_endpoint, GrpcRuntime::default(), Internal) + .await + }); + + let authority = Authority::new(authority_host, Some(addr.port())); + let client_task = tokio::spawn(async move { + let tcp_stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let local_addr = tcp_stream.local_addr().unwrap(); + let peer_addr = tcp_stream.peer_addr().unwrap(); + let raw_endpoint: BoxEndpoint = Box::new(StreamEndpoint::new( + tcp_stream, + local_addr.to_string().into_boxed_str(), + peer_addr.to_string().into_boxed_str(), + "tcp", + )); + let handshake_info = ClientHandshakeInfo::default(); + client_creds + .connect( + &authority, + raw_endpoint, + &handshake_info, + &GrpcRuntime::default(), + Internal, + ) + .await + }); + + let (s_res, c_res) = tokio::join!(server_task, client_task); + (s_res.unwrap(), c_res.unwrap()) +} + +#[tokio::test] +async fn test_tls_handshake_roundtrip() { + let server_creds = + Arc::new(BsslServerCredentials::from_context_builder(server_context_builder()).unwrap()); + let client_creds = + Arc::new(BsslChannelCredentials::from_context_builder(client_context_builder()).unwrap()); + + assert_eq!(server_creds.info().security_protocol(), "tls"); + assert_eq!(client_creds.info().security_protocol(), "tls"); + + let (server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + let server_handshake = server_result.expect("server handshake should succeed"); + let client_handshake = client_result.expect("client handshake should succeed"); + + assert_eq!(server_handshake.security.security_protocol(), "tls"); + assert_eq!( + server_handshake.security.security_level(), + SecurityLevel::PrivacyAndIntegrity + ); + + assert_eq!(client_handshake.security_info.security_protocol(), "tls"); + assert_eq!( + client_handshake.security_info.security_level(), + SecurityLevel::PrivacyAndIntegrity + ); + assert!( + client_handshake + .authority_validator + .validate_authority(&Authority::new("localhost", None)) + ); + + // Verify mutual I/O over encrypted streams + let mut server_io = EndpointIoStream::new(server_handshake.endpoint); + let mut client_io = EndpointIoStream::new(client_handshake.endpoint); + + client_io.write_all(b"ping!").await.unwrap(); + client_io.flush().await.unwrap(); + + let mut buf = [0u8; 5]; + server_io.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"ping!"); + + server_io.write_all(b"pong!").await.unwrap(); + server_io.flush().await.unwrap(); + + let mut resp_buf = [0u8; 5]; + client_io.read_exact(&mut resp_buf).await.unwrap(); + assert_eq!(&resp_buf, b"pong!"); +} + +#[tokio::test] +async fn test_tls_client_handshake_no_alpn() { + // Server provides NO ALPN + let server_ctx = server_context_builder(); + let server_creds = Arc::new(BsslServerCredentials::new( + server_ctx.build_tokio_acceptor(), + )); + + let client_creds = + Arc::new(BsslChannelCredentials::from_context_builder(client_context_builder()).unwrap()); + + let (_server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + let err = match client_result { + Ok(_) => panic!("client handshake should fail when server offers no ALPN"), + Err(e) => e, + }; + assert!( + err.contains("Server did not negotiate ALPN") + || err.contains("NoApplicationProtocol") + || err.contains("Tlsv1AlertNoApplicationProtocol"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_tls_client_handshake_bad_alpn() { + // Server provides only http/1.1 + let mut server_ctx = server_context_builder(); + server_ctx.set_alpn_protocols([&b"http/1.1"[..]]).unwrap(); + let server_creds = Arc::new(BsslServerCredentials::new( + server_ctx.build_tokio_acceptor(), + )); + + let client_creds = + Arc::new(BsslChannelCredentials::from_context_builder(client_context_builder()).unwrap()); + + let (_server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + let err = match client_result { + Ok(_) => panic!("client handshake should fail when server offers bad ALPN"), + Err(e) => e, + }; + assert!( + err.contains("unexpected ALPN protocol") + || err.contains("NoApplicationProtocol") + || err.contains("Tlsv1AlertNoApplicationProtocol"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_tls_client_handshake_alpn_h1_and_h2() { + // Server offers http/1.1 and h2; client negotiates h2 + let mut server_ctx = server_context_builder(); + server_ctx + .set_alpn_protocols([&b"http/1.1"[..], H2]) + .unwrap(); + let server_creds = Arc::new(BsslServerCredentials::new( + server_ctx.build_tokio_acceptor(), + )); + + let client_creds = + Arc::new(BsslChannelCredentials::from_context_builder(client_context_builder()).unwrap()); + + let (server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + assert!(server_result.is_ok(), "server handshake should succeed"); + assert!( + client_result.is_ok(), + "client handshake should succeed with h2" + ); +} + +#[tokio::test] +async fn test_tls_server_handshake_no_alpn() { + // Server requires h2; client offers NO ALPN + let server_creds = + Arc::new(BsslServerCredentials::from_context_builder(server_context_builder()).unwrap()); + + let client_ctx = client_context_builder(); + let client_creds = Arc::new(BsslChannelCredentials::new( + client_ctx.build_tokio_connector(), + )); + + let (server_result, _client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + let err = match server_result { + Ok(_) => panic!("server handshake should fail when client offers no ALPN"), + Err(e) => e, + }; + assert!( + err.contains("Client did not negotiate ALPN") + || err.contains("NoApplicationProtocol") + || err.contains("Tlsv1AlertNoApplicationProtocol"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_tls_server_handshake_bad_alpn() { + // Server requires h2; client offers only http/1.1 + let server_creds = + Arc::new(BsslServerCredentials::from_context_builder(server_context_builder()).unwrap()); + + let mut client_ctx = client_context_builder(); + client_ctx.set_alpn_protocols([&b"http/1.1"[..]]).unwrap(); + let client_creds = Arc::new(BsslChannelCredentials::new( + client_ctx.build_tokio_connector(), + )); + + let (server_result, _client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + let err = match server_result { + Ok(_) => panic!("server handshake should fail when client offers bad ALPN"), + Err(e) => e, + }; + assert!( + err.contains("unexpected ALPN protocol") + || err.contains("NoApplicationProtocol") + || err.contains("Tlsv1AlertNoApplicationProtocol"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_tls_handshake_wrong_server_name() { + // Certificate is valid for localhost and example.com, but NOT for wrong.host.invalid + let server_creds = + Arc::new(BsslServerCredentials::from_context_builder(server_context_builder()).unwrap()); + let client_creds = + Arc::new(BsslChannelCredentials::from_context_builder(client_context_builder()).unwrap()); + + let (_server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "wrong.host.invalid").await; + + let err = match client_result { + Ok(_) => panic!("client should fail hostname verification"), + Err(e) => e, + }; + assert!( + err.contains("handshake failed") || err.contains("BoringSSL TLS client handshake failed"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_tls_validate_authority() { + let validator = BsslAuthorityValidator::new("localhost"); + assert!(validator.validate_authority(&Authority::new("localhost", None))); + assert!(validator.validate_authority(&Authority::new("localhost", Some(50051)))); + assert!(!validator.validate_authority(&Authority::new("other.host", None))); +} + +#[tokio::test] +async fn test_tls_server_mtls_require_fail() { + // Server requires client certificate (PeerCertMandatory), but client sends none + let server_creds = Arc::new( + BsslServerCredentials::from_context_builder(server_mtls_context_builder()).unwrap(), + ); + let client_creds = + Arc::new(BsslChannelCredentials::from_context_builder(client_context_builder()).unwrap()); + + let (server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + assert!( + server_result.is_err() || client_result.is_err(), + "handshake should fail when required client certificate is omitted" + ); +} + +#[tokio::test] +async fn test_tls_server_mtls_success() { + // Server requires client certificate, and client provides trusted client1 cert + let server_creds = Arc::new( + BsslServerCredentials::from_context_builder(server_mtls_context_builder()).unwrap(), + ); + let client_creds = Arc::new( + BsslChannelCredentials::from_context_builder(client_mtls_context_builder()).unwrap(), + ); + + let (server_result, client_result) = + run_handshake_pair(server_creds, client_creds, "localhost").await; + + let server_handshake = server_result.expect("server mTLS handshake should succeed"); + let client_handshake = client_result.expect("client mTLS handshake should succeed"); + + // Verify mutual I/O over mTLS + let mut server_io = EndpointIoStream::new(server_handshake.endpoint); + let mut client_io = EndpointIoStream::new(client_handshake.endpoint); + + client_io.write_all(b"mtls!").await.unwrap(); + client_io.flush().await.unwrap(); + + let mut buf = [0u8; 5]; + server_io.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"mtls!"); + + server_io.write_all(b"done!").await.unwrap(); + server_io.flush().await.unwrap(); + + let mut resp_buf = [0u8; 5]; + client_io.read_exact(&mut resp_buf).await.unwrap(); + assert_eq!(&resp_buf, b"done!"); +} diff --git a/grpc/src/credentials/mod.rs b/grpc/src/credentials/mod.rs index f3d5a48aa..1655301c9 100644 --- a/grpc/src/credentials/mod.rs +++ b/grpc/src/credentials/mod.rs @@ -248,11 +248,18 @@ pub(crate) mod common { /// Contains information about a [`ChannelCredentials`] or /// [`ServerCredentials`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ProtocolInfo { security_protocol: &'static str, } impl ProtocolInfo { + #[cfg(feature = "__unstable")] + pub const fn new(security_protocol: &'static str) -> Self { + Self { security_protocol } + } + + #[cfg(not(feature = "__unstable"))] pub(crate) const fn new(security_protocol: &'static str) -> Self { Self { security_protocol } } diff --git a/grpc/src/lib.rs b/grpc/src/lib.rs index e5002f999..7579b3082 100644 --- a/grpc/src/lib.rs +++ b/grpc/src/lib.rs @@ -71,6 +71,8 @@ pub use status::StatusError; #[cfg(feature = "__unstable")] #[doc(hidden)] pub mod __unstable { + pub use crate::private::Internal; + pub mod rt { pub use crate::rt::*; } @@ -85,6 +87,17 @@ pub mod __unstable { pub use crate::client::service_config::*; } } + pub mod credentials { + pub mod client { + pub use crate::credentials::client::*; + } + pub mod server { + pub use crate::credentials::server::*; + } + pub mod common { + pub use crate::credentials::common::*; + } + } } mod private { diff --git a/grpc/src/rt/mod.rs b/grpc/src/rt/mod.rs index fcf87588e..75f005cba 100644 --- a/grpc/src/rt/mod.rs +++ b/grpc/src/rt/mod.rs @@ -223,16 +223,16 @@ pub trait GrpcEndpoint: Send + Unpin + 'static { /// An adapter that exposes `AsyncRead` and `AsyncWrite` functionality for /// interfacing with `hyper` and `rustls`. This type is kept private to avoid /// exposing its read and write methods to external crates. -pub(crate) struct EndpointIoStream { +pub struct EndpointIoStream { inner: T, } impl EndpointIoStream { - pub(crate) fn new(inner: T) -> Self { + pub fn new(inner: T) -> Self { Self { inner } } - pub(crate) fn get_ref(&self) -> &T { + pub fn get_ref(&self) -> &T { &self.inner } } @@ -278,7 +278,7 @@ impl AsyncWrite for EndpointIoStream { } /// A wrapper that implements [GrpcEndpoint] for an asynchronous I/O stream. -pub(crate) struct StreamEndpoint { +pub struct StreamEndpoint { inner: T, peer_addr: Box, local_addr: Box, @@ -286,7 +286,7 @@ pub(crate) struct StreamEndpoint { } impl StreamEndpoint { - pub(crate) fn new( + pub fn new( inner: T, local_addr: Box, peer_addr: Box, @@ -455,7 +455,7 @@ impl Runtime for NoOpRuntime { } } -pub(crate) fn default_runtime() -> GrpcRuntime { +pub fn default_runtime() -> GrpcRuntime { #[cfg(feature = "_runtime-tokio")] { return GrpcRuntime::new(tokio::TokioRuntime::default()); @@ -469,6 +469,12 @@ pub struct GrpcRuntime { inner: Arc, } +impl Default for GrpcRuntime { + fn default() -> Self { + default_runtime() + } +} + impl GrpcRuntime { pub fn new(runtime: T) -> Self { GrpcRuntime { diff --git a/release-plz.toml b/release-plz.toml index 7baecc653..9fe40d576 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -59,6 +59,10 @@ release = false name = "grpc" release = false +[[package]] +name = "grpc-bssl" +release = false + [[package]] name = "grpc-protobuf" release = false