diff --git a/Cargo.lock b/Cargo.lock index dfa3dd099d..fdd4a0a0b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7328,6 +7328,7 @@ dependencies = [ "google-cloud-test-utils", "google-cloud-wkt", "http", + "serial_test", "tokio", "tracing", "uuid", diff --git a/tests/showcase/Cargo.toml b/tests/showcase/Cargo.toml index 52b707f718..6440bc66b2 100644 --- a/tests/showcase/Cargo.toml +++ b/tests/showcase/Cargo.toml @@ -36,6 +36,7 @@ google-cloud-rpc.workspace = true google-cloud-showcase-v1beta1 = { workspace = true, features = ["default"] } google-cloud-wkt.workspace = true http.workspace = true +serial_test.workspace = true tokio.workspace = true tracing.workspace = true uuid = { workspace = true, features = ["v4"] } diff --git a/tests/showcase/src/lib.rs b/tests/showcase/src/lib.rs index 6c871b5f8a..9a264b9277 100644 --- a/tests/showcase/src/lib.rs +++ b/tests/showcase/src/lib.rs @@ -25,6 +25,7 @@ use tokio::process::Command; mod compliance; mod echo; mod identity; +pub mod pqc; #[cfg(google_cloud_unstable_gapic_streaming)] mod streaming; @@ -36,7 +37,7 @@ mod streaming; /// go list -m -f '{{.Version}}' github.com/googleapis/gapic-showcase@main /// ``` const SHOWCASE_NAME: &str = - "github.com/googleapis/gapic-showcase/cmd/gapic-showcase@v0.37.1-0.20260210150911-3fd9cb2f682d"; + "github.com/googleapis/gapic-showcase/cmd/gapic-showcase@v0.43.1-0.20260817230810-0c88ce83d259"; pub async fn run() -> Result<()> { let _guard = google_cloud_test_utils::tracing::enable_tracing(); @@ -73,7 +74,7 @@ pub async fn run() -> Result<()> { Ok(()) } -async fn install() -> Result { +pub(crate) async fn install() -> Result { for backoff in [Some(5), Some(10), Some(20), None] { let error = match install_attempt().await { Ok(path) => return Ok(path), diff --git a/tests/showcase/src/pqc.rs b/tests/showcase/src/pqc.rs new file mode 100644 index 0000000000..a5623d7629 --- /dev/null +++ b/tests/showcase/src/pqc.rs @@ -0,0 +1,216 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Post-Quantum Cryptography (PQC) Transport Verification Tests. +//! +//! This module verifies that `google-cloud-rust` transports support and negotiate +//! post-quantum hybrid key exchange algorithms (specifically `X25519MLKEM768`, +//! IANA group ID `0x11ec` / 4588) when connecting over TLS 1.3. +//! +//! # Verification Mechanism +//! +//! An isolated instance of `gapic-showcase` is spawned with: +//! * `--tls`: Enables Auto-TLS, generating in-memory CA and server certificates. +//! * `--ca-cert-output-file `: Exports the self-signed CA certificate PEM. +//! * `--tls-groups 0x11ec`: Strictly restricts server-accepted key exchange groups +//! to `X25519MLKEM768`. +//! +//! If the client's TLS stack (`aws-lc-rs` via `rustls`) does not offer and negotiate +//! `X25519MLKEM768` during the TLS 1.3 `ClientHello`, the server rejects the handshake +//! with a TLS `HandshakeFailure` alert. +//! +//! # Scope of Tests +//! +//! 1. **HTTP/REST Unary (`reqwest`)**: Verifies HTTPS unary RPC execution. +//! 2. **gRPC Streaming (`tonic`)**: Verifies bidirectional streaming RPC execution +//! over HTTP/2 TLS. + +use super::{Anonymous, NeverRetry}; +use crate::Result; +use anyhow::Error; +use google_cloud_gax::options::RequestOptionsBuilder; +use google_cloud_gax::retry_policy::{AlwaysRetry, RetryPolicyExt}; +use google_cloud_showcase_v1beta1::client::{Echo, Testing}; +#[cfg(google_cloud_unstable_gapic_streaming)] +use google_cloud_showcase_v1beta1::model::EchoRequest; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +/// Dedicated port for the PQC Showcase server to prevent collisions with the standard +/// showcase test suite (running concurrently on port `:7469`). +const PQC_PORT: &str = ":7471"; +/// Dedicated fallback port to prevent port collisions with the default `:1337`. +const PQC_FALLBACK_PORT: &str = ":1339"; +const PQC_ENDPOINT: &str = "https://localhost:7471"; + +/// Main entry point for the PQC integration test suite. +/// +/// Spawns an isolated `gapic-showcase` server configured with Auto-TLS and pinned to +/// `0x11ec` (`X25519MLKEM768`), configures CA trust, and runs transport verifications. +pub async fn run() -> Result<()> { + let _guard = google_cloud_test_utils::tracing::enable_tracing(); + + let path = super::install().await?; + let showcase: PathBuf = [path.as_str(), "bin", "gapic-showcase"].iter().collect(); + + let ca_cert_path = + std::env::temp_dir().join(format!("showcase_pqc_ca_{}.pem", std::process::id())); + if ca_cert_path.exists() { + let _ = std::fs::remove_file(&ca_cert_path); + } + + tracing::info!("starting {showcase:?} with Auto-TLS (PQC enabled and pinned to 0x11ec)"); + let child = Command::new(&showcase) + .args([ + "run", + "--port", + PQC_PORT, + "--fallback-port", + PQC_FALLBACK_PORT, + "--tls", + "--ca-cert-output-file", + ca_cert_path + .to_str() + .ok_or_else(|| Error::msg("temp dir path is not valid UTF-8"))?, + "--tls-groups", + "0x11ec", // X25519MLKEM768 + ]) + .stdin(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(anyhow::Error::from)?; + tracing::info!("started showcase PQC server: {child:?}"); + + // Wait for the Showcase server to write its autogenerated CA certificate and become ready. + let mut ready = false; + for _ in 0..50 { + if let Ok(pem) = std::fs::read(&ca_cert_path) + && !pem.is_empty() + { + // SAFETY: `std::env::set_var` is unsafe in Rust because concurrent reads or writes + // to the environment in a multi-threaded process cause a data race in POSIX (`setenv`/`getenv`). + // This is safe here because this integration test (`tests/pqc.rs`) runs in its own isolated + // OS process with a single test thread, so no concurrent environment access can occur. + // We set `SSL_CERT_FILE` globally because the client does not yet expose a builder option + // to configure custom root CA certificates + unsafe { + std::env::set_var("SSL_CERT_FILE", &ca_cert_path); + } + if wait_until_ready(PQC_ENDPOINT).await.is_ok() { + ready = true; + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + if !ready { + return Err(Error::msg(format!( + "showcase PQC server is not ready: {child:?}" + ))); + } + + tracing::info!("testing PQC transport (HTTP unary)"); + test_pqc_http().await?; + + #[cfg(google_cloud_unstable_gapic_streaming)] + { + tracing::info!("testing PQC transport (gRPC streaming)"); + test_pqc_grpc().await?; + } + + Ok(()) +} + +async fn wait_until_ready(endpoint: &str) -> Result<()> { + for _ in 0..50 { + if let Ok(client) = Testing::builder() + .with_endpoint(endpoint) + .with_credentials(Anonymous::new().build()) + .build() + .await + && client + .list_sessions() + .with_retry_policy(AlwaysRetry.with_attempt_limit(1)) + .with_attempt_timeout(Duration::from_millis(500)) + .send() + .await + .is_ok() + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(Error::msg(format!( + "showcase server at {endpoint} is not ready" + ))) +} + +/// Verifies that HTTP unary RPCs (`reqwest`) succeed over a TLS channel requiring `X25519MLKEM768`. +async fn test_pqc_http() -> Result<()> { + let client = Echo::builder() + .with_endpoint(PQC_ENDPOINT) + .with_credentials(Anonymous::new().build()) + .with_retry_policy(NeverRetry) + .with_tracing() + .build() + .await?; + + const TEXT: &str = "testing PQC transport compliance (HTTP unary)"; + let response = client.echo().set_content(TEXT).send().await?; + assert_eq!(response.content, TEXT); + + tracing::info!("Verified HTTP unary RPC over PQC TLS channel"); + Ok(()) +} + +/// Verifies that gRPC bidirectional streaming RPCs (`tonic`) succeed over a TLS channel requiring `X25519MLKEM768`. +#[cfg(google_cloud_unstable_gapic_streaming)] +async fn test_pqc_grpc() -> Result<()> { + let client = Echo::builder() + .with_endpoint(PQC_ENDPOINT) + .with_credentials(Anonymous::new().build()) + .with_retry_policy(NeverRetry) + .with_tracing() + .build() + .await?; + + const TOTAL_MESSAGES: usize = 5; + let (sender, mut receiver) = client.chat().build(); + + for i in 0..TOTAL_MESSAGES { + sender + .send(EchoRequest::new().set_content(format!("pqc-grpc-msg-{i}"))) + .await?; + } + drop(sender); + + let mut received = Vec::new(); + while let Some(res) = receiver.recv().await { + received.push(res?.content); + } + + let expected: Vec = (0..TOTAL_MESSAGES) + .map(|i| format!("pqc-grpc-msg-{i}")) + .collect(); + assert_eq!( + received, expected, + "gRPC streaming message exchange over PQC TLS channel must match" + ); + + tracing::info!("Verified gRPC streaming over PQC TLS channel (0x11ec)"); + Ok(()) +} diff --git a/tests/showcase/tests/pqc.rs b/tests/showcase/tests/pqc.rs new file mode 100644 index 0000000000..da4b23214c --- /dev/null +++ b/tests/showcase/tests/pqc.rs @@ -0,0 +1,26 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[cfg(all(test, feature = "run-showcase-tests"))] +mod pqc { + use google_cloud_test_utils::errors::anydump; + + #[tokio::test] + #[serial_test::serial] + async fn run() -> anyhow::Result<()> { + integration_tests_showcase::pqc::run() + .await + .inspect_err(anydump) + } +}