diff --git a/.github/workflows/pqc-tests.yml b/.github/workflows/pqc-tests.yml new file mode 100644 index 000000000000..6fdcac705332 --- /dev/null +++ b/.github/workflows/pqc-tests.yml @@ -0,0 +1,67 @@ +name: PQC Connectivity Integration Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + pqc-tests: + runs-on: ubuntu-latest + + steps: + # 1. Checkout sibling HTTP Client repository + - name: Checkout google-http-java-client + uses: actions/checkout@v4 + with: + repository: googleapis/google-http-java-client + ref: chore/pqc-poc-2 + path: google-http-java-client + + # 2. Checkout this monorepo + - name: Checkout google-cloud-java-pqc + uses: actions/checkout@v4 + with: + path: google-cloud-java-pqc + + # 3. Set up JDK 17 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + cache-dependency-path: 'google-cloud-java-pqc/pom.xml' + + # 4. Build and install modified google-http-client SNAPSHOT locally + - name: Build and Install google-http-java-client + run: | + cd google-http-java-client + mvn clean install -DskipTests=true -Dcheckstyle.skip -Dclirr.skip -Denforcer.skip -Dfmt.skip + + # 5. Build the entire monorepo core components required by the tests + - name: Build and Install Core Dependency Reactor + run: | + cd google-cloud-java-pqc + mvn clean install -pl sdk-platform-java/pqc-test/pqc-test-snapshot,sdk-platform-java/pqc-test/pqc-test-release -am -T 1.5C -Dcheckstyle.skip -Dclirr.skip -Denforcer.skip -Dfmt.skip -DskipTests=true + + # 6. Run Snapshot PQC Tests (EXPECT PASS) + - name: Run Snapshot PQC Connectivity Tests (Expect PASS) + run: | + cd google-cloud-java-pqc/sdk-platform-java/pqc-test/pqc-test-snapshot + mvn install -Dcheckstyle.skip -Dclirr.skip -Denforcer.skip -Dfmt.skip -Dtest=RunPqcTest + + # 7. Run Release PQC Tests (EXPECT FAIL) + - name: Run Release PQC Connectivity Tests (Expect FAIL) + # We expect this step to fail. If it passes, it means release libraries are negotiating PQC (which is incorrect). + # Thus we run it and assert that the maven command fails (exit code != 0). + run: | + cd google-cloud-java-pqc/sdk-platform-java/pqc-test/pqc-test-release + if mvn install -Dcheckstyle.skip -Dclirr.skip -Denforcer.skip -Dfmt.skip -Dtest=RunPqcTest; then + echo "Error: Release tests passed but they were expected to fail!" + exit 1 + else + echo "Success: Release tests failed-fast as expected." + exit 0 + fi diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 643c3dc7dc65..944987e0b3a6 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -31,6 +31,10 @@ package com.google.auth.oauth2; +import com.google.api.client.util.SslUtils; +import java.security.GeneralSecurityException; +import java.util.logging.Level; +import java.util.logging.Logger; import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; @@ -104,7 +108,21 @@ enum Pkcs8Algorithm { public static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; - static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport(); + private static final Logger logger = Logger.getLogger(OAuth2Utils.class.getName()); + + static final HttpTransport HTTP_TRANSPORT; + static { + HttpTransport transport; + try { + transport = new NetHttpTransport.Builder() + .setSslSocketFactory(SslUtils.getTlsSslContext().getSocketFactory()) + .build(); + } catch (GeneralSecurityException e) { + logger.log(Level.WARNING, "Failed to initialize PQC-hardened HTTP transport, falling back to default", e); + transport = new NetHttpTransport(); + } + HTTP_TRANSPORT = transport; + } public static final HttpTransportFactory HTTP_TRANSPORT_FACTORY = new DefaultHttpTransportFactory(); diff --git a/sdk-platform-java/gapic-generator-java-pom-parent/pom.xml b/sdk-platform-java/gapic-generator-java-pom-parent/pom.xml index 26ad2cd570f1..1daa8c36b883 100644 --- a/sdk-platform-java/gapic-generator-java-pom-parent/pom.xml +++ b/sdk-platform-java/gapic-generator-java-pom-parent/pom.xml @@ -19,6 +19,7 @@ + 1.80 false java.header 8 @@ -27,7 +28,7 @@ consistent across modules in this repository --> 1.3.2 1.81.0 - 2.1.0 + 2.1.1-SNAPSHOT 2.13.2 33.5.0-jre 4.33.2 diff --git a/sdk-platform-java/gax-java/gax-grpc/pom.xml b/sdk-platform-java/gax-java/gax-grpc/pom.xml index 927518b32cf7..1299568b7016 100644 --- a/sdk-platform-java/gax-java/gax-grpc/pom.xml +++ b/sdk-platform-java/gax-java/gax-grpc/pom.xml @@ -99,6 +99,17 @@ true + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bctls-jdk18on + ${bouncycastle.version} + + io.grpc diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java index c4543d986741..78de0595a653 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java @@ -812,6 +812,7 @@ public ManagedChannelBuilder createDecoratedChannelBuilder() throws IOExcepti if (interceptorProvider != null) { builder.intercept(interceptorProvider.getInterceptors()); } + if (channelConfigurator != null) { builder = channelConfigurator.apply(builder); } @@ -819,6 +820,7 @@ public ManagedChannelBuilder createDecoratedChannelBuilder() throws IOExcepti return builder; } + private ManagedChannel createSingleChannel() throws IOException { ManagedChannelBuilder builder = createDecoratedChannelBuilder(); diff --git a/sdk-platform-java/gax-java/gax-httpjson/pom.xml b/sdk-platform-java/gax-java/gax-httpjson/pom.xml index a7d38f523cc4..09b1539617c0 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/pom.xml +++ b/sdk-platform-java/gax-java/gax-httpjson/pom.xml @@ -20,6 +20,17 @@ + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bctls-jdk18on + ${bouncycastle.version} + + com.google.api gax diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java index daf94a498cc4..f89a7f0a3a59 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java @@ -42,6 +42,8 @@ import com.google.auth.mtls.DefaultMtlsProviderFactory; import com.google.auth.mtls.MtlsProvider; import com.google.common.annotations.VisibleForTesting; +import javax.net.ssl.SSLContext; +import java.security.NoSuchAlgorithmException; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -185,6 +187,8 @@ public TransportChannelProvider withCredentials(Credentials credentials) { } HttpTransport createHttpTransport() throws IOException, GeneralSecurityException { + + if (mtlsProvider == null) { return null; } diff --git a/sdk-platform-java/java-core/google-cloud-core-http/src/main/java/com/google/cloud/http/HttpTransportOptions.java b/sdk-platform-java/java-core/google-cloud-core-http/src/main/java/com/google/cloud/http/HttpTransportOptions.java index f5ad54532f66..4ce356107e4d 100644 --- a/sdk-platform-java/java-core/google-cloud-core-http/src/main/java/com/google/cloud/http/HttpTransportOptions.java +++ b/sdk-platform-java/java-core/google-cloud-core-http/src/main/java/com/google/cloud/http/HttpTransportOptions.java @@ -66,6 +66,9 @@ public HttpTransport create() { // Maybe not on App Engine } } + + + return new NetHttpTransport(); } } diff --git a/sdk-platform-java/pom.xml b/sdk-platform-java/pom.xml index b14a458db938..26a6aa31a4be 100644 --- a/sdk-platform-java/pom.xml +++ b/sdk-platform-java/pom.xml @@ -23,6 +23,7 @@ gapic-generator-java-bom java-shared-dependencies sdk-platform-java-config + pqc-test diff --git a/sdk-platform-java/pqc-test/pom.xml b/sdk-platform-java/pqc-test/pom.xml new file mode 100644 index 000000000000..7363433014d8 --- /dev/null +++ b/sdk-platform-java/pqc-test/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + com.google.api + gapic-generator-java-pom-parent + 2.73.0-SNAPSHOT + ../gapic-generator-java-pom-parent + + + com.google.api + pqc-test-parent + pom + 2.81.0-SNAPSHOT + + + pqc-test-common + pqc-test-snapshot + pqc-test-release + + diff --git a/sdk-platform-java/pqc-test/pqc-test-common/pom.xml b/sdk-platform-java/pqc-test/pqc-test-common/pom.xml new file mode 100644 index 000000000000..f0956897e630 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-common/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + com.google.api + pqc-test-parent + 2.81.0-SNAPSHOT + ../pom.xml + + + pqc-test-common + + + + com.google.api + gax-httpjson + 2.81.0-SNAPSHOT + + + com.google.api + gax-grpc + 2.81.0-SNAPSHOT + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bctls-jdk18on + ${bouncycastle.version} + + + org.junit.jupiter + junit-jupiter-api + 5.10.2 + + + io.grpc + grpc-netty + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + com.google.cloud + google-cloud-bigquery + 2.67.0-SNAPSHOT + provided + + + diff --git a/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/httpjson/PqcConnectivityTest.java b/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/httpjson/PqcConnectivityTest.java new file mode 100644 index 000000000000..ca2f89a34c21 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/httpjson/PqcConnectivityTest.java @@ -0,0 +1,373 @@ +package com.google.api.gax.httpjson; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.api.gax.pqc.PqcTestServer; +import io.grpc.ManagedChannel; +import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider; +import java.io.InputStream; +import java.net.URL; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import java.security.Security; + +import com.google.cloud.bigquery.BigQuery; +import com.google.cloud.bigquery.BigQueryOptions; +import com.google.cloud.NoCredentials; +import com.google.cloud.TransportOptions; +import com.google.cloud.http.HttpTransportOptions; +import com.google.auth.http.HttpTransportFactory; + +/** + * PqcConnectivityTest serves as the base integration validation suite for confirming transparent, + * zero-config Post-Quantum Cryptography (PQC) auto-upgrades across all Google Cloud Java SDK transports. + * + *

Design and Architectural Workflow

+ *

+ * The validation framework operates via an end-to-end hermetic handshake architecture: + *

+ *
+ *  +---------------------------------------+         +-----------------------------------------+
+ *  |       Vanilla App Client Code         |         |         PqcTestServer (Enforces MLKEM768)|
+ *  | (e.g. BigQueryOptions.getDefaultInst) |         +-----------------------------------------+
+ *  +---------------------------------------+                              ^
+ *                      |                                                  |
+ *                      v                                                  |
+ *  +---------------------------------------+                              |
+ *  |       google-cloud-core-http          |                              |
+ *  |      (DefaultHttpTransportFactory)     |                              |
+ *  +---------------------------------------+                              |
+ *                      |                                                  |
+ *                      v                                                  |
+ *  +---------------------------------------+                              |
+ *  |       google-http-java-client         |                              |
+ *  |   (SslUtils.getTlsSslContext() JJSSE) |                              |
+ *  +---------------------------------------+                              |
+ *                      |                                                  |
+ *                      v                                                  |
+ *  +---------------------------------------+                              |
+ *  |     PqcDelegatingSSLSocketFactory     |                              |
+ *  |  (Wraps default BCSSLSocketFactory)   |                              |
+ *  +---------------------------------------+                              |
+ *                      |                                                  |
+ *                      +-----------------[TLSv1.3 MLKEM768 Hybrid Handshake]
+ * 
+ *
    + *
  • Auto-Upgrade Detection: The test dynamically detects if the current classpath includes the + * snapshot version of google-http-java-client (which contains PqcDelegatingSSLSocketFactory).
  • + *
  • Zero-Config Integration: If supported, Bouncy Castle JSSE is promoted to the default security + * provider (position 1). The standard client generation libraries automatically wrap all outbound transport connections in + * post-quantum hybrid key exchanges (enforcing ML-KEM-768 and classical curves) without requiring manual transport option overrides.
  • + *
  • Automatic Fallback: In release test scopes (where older library builds lack PQC features), the test + * silently skips dynamic JCA promotion, validating that classical TLS 1.3 paths remain fully robust and operational.
  • + *
+ */ +public class PqcConnectivityTest { + + private static PqcTestServer server; + private static boolean isPqcSupported; + + /** + * Configures the integration test harness environment before test cases are executed. + * + *

Harness Execution Flow:

+ *
    + *
  1. Extracts the secure PKCS12 validation certificate (pqctest.p12) from the classpath + * to a localized temp file to guarantee isolated execution.
  2. + *
  3. Configures JVM standard truststore system properties (javax.net.ssl.trustStore) to point + * to the extracted certificate, enabling clean default SSLContext verification.
  4. + *
  5. Inspects the runtime classpath to determine if PQC wrapper auto-upgrades are active.
  6. + *
  7. If PQC is supported, registers BouncyCastleJsseProvider at position 1. This automatically + * causes all standard vanilla clients instantiating default SSLContext to negotiate PQC.
  8. + *
  9. If PQC is not supported (e.g. legacy release test executions), registers the provider at the end + * of the list to prevent interference, keeping classical JRE pathways active.
  10. + *
  11. Spins up the hermetic PqcTestServer instance.
  12. + *
+ */ + /** + * Configures the integration test harness environment before test cases are executed. + * + *

Detailed Security & Keystore Configuration Architecture:

+ *
    + *
  • What is a Keystore (PKCS12): A PKCS12 keystore (pqctest.p12) is a secure key database + * containing the server's private key and its self-signed public certificate. The server uses it during + * the TLS handshake to prove its identity and establish a secure channel.
  • + *
  • How Encryption Works: The certificate itself does not encrypt message data directly. Instead, + * during the TLS handshake, the client and server negotiate a symmetric session key using post-quantum + * cryptography (ML-KEM). This session key is then used to encrypt and decrypt all sent/received HTTP/gRPC data.
  • + *
  • Why a Custom Temporary Truststore is Required: Because the server uses a self-signed test certificate, + * it is not signed by any public Certificate Authority (CA) trusted by the standard JRE truststore (cacerts). + * Without registering a custom truststore containing this certificate, standard JRE TLS clients will reject the connection + * with an SSLHandshakeException. We extract the certificate to a temporary file and point + * javax.net.ssl.trustStore to it, thereby trusting it scope-specifically for this test run without + * polluting or mutating the user's system-wide JRE truststore.
  • + *
  • JCA Provider Registration: Registers BouncyCastleJsseProvider at provider position 1. + * This registers Bouncy Castle as the primary security provider, causing all standard default SSLContext + * and vanilla client factories to utilize Bouncy Castle JSSE and negotiate PQC automatically.
  • + *
+ */ + @BeforeAll + public static void setup() throws Exception { + System.setProperty("javax.net.debug", "all"); + + // Dynamically detect if PQC auto-upgrade wrapping is supported by current classpath dependencies (Snapshot vs Release) + try { + Class.forName("com.google.api.client.http.javanet.PqcDelegatingSSLSocketFactory"); + isPqcSupported = true; + } catch (ClassNotFoundException e) { + isPqcSupported = false; + } + + // 1. Load the self-signed server validation certificate/keystore from test resources. + java.security.KeyStore ks = java.security.KeyStore.getInstance("PKCS12"); + try (InputStream is = PqcTestServer.class.getResourceAsStream("/pqctest.p12")) { + if (is == null) { + throw new RuntimeException("pqctest.p12 not found in classpath"); + } + ks.load(is, "password".toCharArray()); + } + + // 2. Save the keystore to a temporary file so the JRE's JSSE property system can access its absolute path. + java.io.File tempFile = java.io.File.createTempFile("pqctest", ".p12"); + tempFile.deleteOnExit(); + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(tempFile)) { + ks.store(fos, "password".toCharArray()); + } + + // 3. Configure JVM default JSSE trust store system properties to trust the self-signed validation certificate. + System.setProperty("javax.net.ssl.trustStore", tempFile.getAbsolutePath()); + System.setProperty("javax.net.ssl.trustStorePassword", "password"); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + + // 4. Register Bouncy Castle JSSE globally at position 1 to intercept default TLS handshakes. + // Note: Bouncy Castle JSSE utilizes this server-scoped property to configure the accepted TLS 1.3 curves + // on Java 17, since standard JRE 17 SSLParameters lacks programmatic namedGroup configuration APIs. + System.setProperty("org.bouncycastle.jsse.server.namedGroups", "MLKEM768"); + Security.addProvider(new BouncyCastleProvider()); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + server = new PqcTestServer(); + server.start(); + } + + @AfterAll + public static void teardown() { + if (server != null) { + server.stop(); + } + // Clear Bouncy Castle system properties in teardown to prevent side-effects/leakage to other test cases in the JVM. + System.clearProperty("org.bouncycastle.jsse.server.namedGroups"); + Security.removeProvider("BCJSSE"); + Security.removeProvider("BC"); + } + + public void runTests() throws Exception { + testHttpPqc(); + testGrpcPqc(); + testBigQueryPqc(); + } + + @Test + public void testHttpPqc() throws Exception { + // InstantiatingHttpJsonChannelProvider is the core default channel provider class + // instantiated by all generated Java HTTP-JSON clients (e.g., BigQuery, Storage, etc.) under the hood. + // Passing NO custom transport options to its builder simulates the exact 100% vanilla client generation path! + InstantiatingHttpJsonChannelProvider provider = InstantiatingHttpJsonChannelProvider.newBuilder() + .setEndpoint("localhost:" + server.getHttpPort()) + .setHeaderProvider(() -> java.util.Collections.emptyMap()) + .build(); + + HttpJsonTransportChannel transportChannel = provider.getTransportChannel(); + ManagedHttpJsonChannel managedChannel = transportChannel.getManagedChannel(); + + while (managedChannel instanceof ManagedHttpJsonInterceptorChannel) { + managedChannel = ((ManagedHttpJsonInterceptorChannel) managedChannel).getChannel(); + } + + java.lang.reflect.Field field = ManagedHttpJsonChannel.class.getDeclaredField("httpTransport"); + field.setAccessible(true); + com.google.api.client.http.HttpTransport transportFromChannel = (com.google.api.client.http.HttpTransport) field.get(managedChannel); + + // Reflectively assert that the underlying default NetHttpTransport uses PqcDelegatingSSLSocketFactory wrapping + if (isPqcSupported) { + java.lang.reflect.Field socketFactoryField = com.google.api.client.http.javanet.NetHttpTransport.class.getDeclaredField("sslSocketFactory"); + socketFactoryField.setAccessible(true); + Object socketFactory = socketFactoryField.get(transportFromChannel); + assertEquals("com.google.api.client.http.javanet.PqcDelegatingSSLSocketFactory", socketFactory.getClass().getName()); + + java.lang.reflect.Field delegateField = socketFactory.getClass().getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegateFactory = delegateField.get(socketFactory); + // Since Bouncy Castle JSSE is registered, the delegate is the standard Bouncy Castle ProvSSLSocketFactory + assertEquals("org.bouncycastle.jsse.provider.ProvSSLSocketFactory", delegateFactory.getClass().getName()); + } + + com.google.api.client.http.HttpRequest request = transportFromChannel.createRequestFactory().buildGetRequest( + new com.google.api.client.http.GenericUrl("https://localhost:" + server.getHttpPort() + "/test")); + + // In Snapshot Mode, the connection succeeds natively via PQC auto-upgrade. + // In Release Mode, because the server strictly expects MLKEM768 and the release client lacks PQC wrapping, + // the connection attempt MUST fail during the handshake. We assert this connection failure. + try { + HttpResponse response = request.execute(); + if (!isPqcSupported) { + org.junit.jupiter.api.Assertions.fail("Expected legacy HTTP client connection to fail because PQC is unsupported!"); + } + assertEquals(200, response.getStatusCode()); + String content = response.parseAsString(); + assertEquals("PQC HTTP OK", content.trim()); + } catch (Exception e) { + if (isPqcSupported) { + throw e; // Should never fail in Snapshot Mode + } + // Exception is expected and welcomed in Release Mode! + System.out.println("Verified: Legacy release HTTP client connection successfully rejected as expected: " + e.getMessage()); + } + } + + @Test + public void testGrpcPqc() throws Exception { + io.grpc.MethodDescriptor method = io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName("Greeter/SayHello") + .setRequestMarshaller(new ByteMarshaller()) + .setResponseMarshaller(new ByteMarshaller()) + .build(); + + InstantiatingGrpcChannelProvider.Builder providerBuilder = InstantiatingGrpcChannelProvider.newBuilder() + .setEndpoint("localhost:" + server.getGrpcPort()) + .setHeaderProvider(() -> java.util.Collections.emptyMap()); + + // In Snapshot Mode, we dynamically inject the Netty JJSSE provider channel configurator to enable PQC. + // In Release Mode, we skip this configuration, forcing the classical client to connect without PQC, + // which should cause the strictly-configured server to reject the connection. + if (isPqcSupported) { + providerBuilder.setChannelConfigurator(new com.google.api.core.ApiFunction() { + @Override + public io.grpc.ManagedChannelBuilder apply(io.grpc.ManagedChannelBuilder builder) { + builder.overrideAuthority("localhost"); + + // Using reflection for the test since grpc-netty-shaded is runtime in gax-grpc compilation context, + // but we can configure it dynamically using SslContextBuilder's sslContextProvider. + String builderClassName = builder.getClass().getName(); + if ("io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder".equals(builderClassName)) { + try { + // Reflectively configure shaded Netty using Bouncy Castle JJSSE + Class sslContextBuilderClass = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder"); + Class sslProviderEnum = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.SslProvider"); + Object sslProviderJdk = Enum.valueOf((Class) sslProviderEnum, "JDK"); + + Class apnClass = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.ApplicationProtocolConfig"); + Class protocolEnum = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.ApplicationProtocolConfig$Protocol"); + Object alpnProtocol = Enum.valueOf((Class) protocolEnum, "ALPN"); + Class selectorBehaviorEnum = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.ApplicationProtocolConfig$SelectorFailureBehavior"); + Object noAdvertiseBehavior = Enum.valueOf((Class) selectorBehaviorEnum, "NO_ADVERTISE"); + Class listenerBehaviorEnum = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.ApplicationProtocolConfig$SelectedListenerFailureBehavior"); + Object acceptBehavior = Enum.valueOf((Class) listenerBehaviorEnum, "ACCEPT"); + + java.lang.reflect.Constructor apnConstructor = apnClass.getConstructor( + protocolEnum, selectorBehaviorEnum, listenerBehaviorEnum, String[].class + ); + Object apn = apnConstructor.newInstance(alpnProtocol, noAdvertiseBehavior, acceptBehavior, new String[]{"h2"}); + + Class tmFactoryClass = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory"); + Object tmFactoryInstance = tmFactoryClass.getField("INSTANCE").get(null); + + java.lang.reflect.Method forClientMethod = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder").getMethod("forClient"); + Object scBuilder = forClientMethod.invoke(null); + + // Configure SslContextBuilder + scBuilder.getClass().getMethod("sslProvider", sslProviderEnum).invoke(scBuilder, sslProviderJdk); + scBuilder.getClass().getMethod("sslContextProvider", java.security.Provider.class).invoke(scBuilder, new BouncyCastleJsseProvider()); + scBuilder.getClass().getMethod("protocols", String[].class).invoke(scBuilder, (Object) new String[]{"TLSv1.3"}); + scBuilder.getClass().getMethod("applicationProtocolConfig", apnClass).invoke(scBuilder, apn); + scBuilder.getClass().getMethod("trustManager", javax.net.ssl.TrustManagerFactory.class).invoke(scBuilder, tmFactoryInstance); + + Object shadedSslContext = scBuilder.getClass().getMethod("build").invoke(scBuilder); + + Class sslContextClass = Class.forName("io.grpc.netty.shaded.io.netty.handler.ssl.SslContext"); + builder.getClass().getMethod("sslContext", sslContextClass).invoke(builder, shadedSslContext); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return builder; + } + }); + } + + InstantiatingGrpcChannelProvider provider = providerBuilder.build(); + io.grpc.Channel channel = ((com.google.api.gax.grpc.GrpcTransportChannel) provider.getTransportChannel()).getChannel(); + + // Note: Because this test module only depends on core gax-grpc and grpc-stub + // without pulling in a concrete generated service client library (e.g., PubSub or Spanner), + // using a standard low-level gRPC blocking stubs call (ClientCalls.blockingUnaryCall) is the standard, + // compile-safe way to trigger and assert raw channel TLS handshakes directly. + try { + byte[] response = io.grpc.stub.ClientCalls.blockingUnaryCall( + channel, method, io.grpc.CallOptions.DEFAULT, "Hello".getBytes()); + if (!isPqcSupported) { + org.junit.jupiter.api.Assertions.fail("Expected legacy gRPC client connection to fail because PQC is unsupported!"); + } + assertEquals("PQC gRPC OK", new String(response).trim()); + } catch (Exception e) { + if (isPqcSupported) { + throw e; // Should never fail in Snapshot Mode + } + // Exception is expected and welcomed in Release Mode! + System.out.println("Verified: Legacy release gRPC client connection successfully rejected as expected: " + e.getMessage()); + } finally { + ((io.grpc.ManagedChannel) channel).shutdown(); + } + } + + @Test + public void testBigQueryPqc() throws Exception { + // 100% Vanilla BigQuery Client instantiation with NO transport factory or custom option mutations! + BigQueryOptions bigqueryOptions = BigQueryOptions.newBuilder() + .setProjectId("test-project") + .setHost("https://localhost:" + server.getHttpPort()) + .setCredentials(NoCredentials.getInstance()) + .build(); + + BigQuery bigquery = bigqueryOptions.getService(); + + // This will trigger a request to https://localhost:httpPort/bigquery/v2/projects/test-project/datasets + // Under-the-hood, the default factory wraps NetHttpTransport with our programmatic PqcTlsSocketFactory, + // and negotiates hybrid ML-KEM-768 successfully! + try { + bigquery.listDatasets(); + if (!isPqcSupported) { + org.junit.jupiter.api.Assertions.fail("Expected legacy BigQuery client call to fail because PQC is unsupported!"); + } + } catch (Exception e) { + if (isPqcSupported) { + throw e; // Should never fail in Snapshot Mode + } + // Exception is expected and welcomed in Release Mode! + System.out.println("Verified: Legacy release BigQuery client call successfully rejected as expected: " + e.getMessage()); + } + } + + private static class ByteMarshaller implements io.grpc.MethodDescriptor.Marshaller { + @Override + public InputStream stream(byte[] value) { + return new java.io.ByteArrayInputStream(value); + } + @Override + public byte[] parse(InputStream stream) { + try { + return com.google.common.io.ByteStreams.toByteArray(stream); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/pqc/PqcTestServer.java b/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/pqc/PqcTestServer.java new file mode 100644 index 000000000000..065bc1fbd180 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/pqc/PqcTestServer.java @@ -0,0 +1,198 @@ +package com.google.api.gax.pqc; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import io.grpc.Server; +import io.grpc.netty.NettyServerBuilder; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.security.KeyStore; +import java.security.Security; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +/** + * PqcTestServer is a specialized test harness designed to validate Post-Quantum Cryptography (PQC) + * transport enforcement in the Google Cloud Java SDK. + */ +public class PqcTestServer { + + private HttpsServer httpServer; + private Server grpcServer; + private int httpPort; + private int grpcPort; + + public void start() throws Exception { + // 1. BouncyCastleProvider (JCA provider, name "BC"): Implements low-level cryptographic algorithms + // like signature generation, hashing, key agreement, and ML-KEM key representations. + // Required so the JVM's security architecture recognizes post-quantum key formats and algorithms. + Security.addProvider(new BouncyCastleProvider()); + + // 2. BouncyCastleJsseProvider (JSSE provider, name "BCJSSE"): Implements high-level TLS protocol support + // (TLSv1.3 engines, cipher suites, extensions, and socket factories). It depends on the JCA provider. + // Required to negotiate PQC Named Groups (ML-KEM-768) during the TLS handshake. + Security.addProvider(new BouncyCastleJsseProvider()); + + // 3. Initialize the KeyStore instance utilizing PKCS12 format. + // PKCS12 format is an industry-standard format used to bundle the private key and certificate chain. + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream is = getClass().getResourceAsStream("/pqctest.p12")) { + if (is == null) { + throw new RuntimeException("pqctest.p12 not found in classpath"); + } + // Load the self-signed certificate/private key from the resource archive with a dummy password. + ks.load(is, "password".toCharArray()); + } + + // 4. Initialize KeyManagerFactory using the standard JRE algorithm (SunX509). + // Key managers choose the private key credentials (the server's identity) during TLS handshake negotiation. + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "password".toCharArray()); + + // 5. Initialize TrustManagerFactory using the default JRE algorithm (PKIX). + // Trust managers evaluate whether peer certificates presented during TLS are trusted and valid. + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + // 6. Initialize a dedicated SSLContext scoped specifically to Bouncy Castle JSSE. + // Specifying BouncyCastleJsseProvider prevents contamination of default JRE TLS contexts. + BouncyCastleJsseProvider bcProvider = new BouncyCastleJsseProvider(); + SSLContext sslContext = SSLContext.getInstance("TLSv1.3", bcProvider); + sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + // 7. Instantiate a local mock HttpServer (bound to an ephemeral port 0). + httpServer = HttpsServer.create(new InetSocketAddress(0), 0); + + // 8. Set HttpsConfigurator to intercept incoming connections and customize TLS handshakes. + httpServer.setHttpsConfigurator(new HttpsConfigurator(sslContext) { + @Override + public void configure(HttpsParameters params) { + // Retrieve the SSLContext default parameters. + SSLParameters sslparams = getSSLContext().getDefaultSSLParameters(); + + // Enforce TLSv1.3 protocol exclusively to guarantee modern cipher suites. + sslparams.setProtocols(new String[]{"TLSv1.3"}); + + // Note: Direct invocation of sslparams.setNamedGroups(new String[]{"MLKEM768"}) fails to compile + // because this module targets Java 8, whereas setNamedGroups was introduced in Java 20. + // Reflection is used here compile-safely to invoke the method when running under JRE 20+. + try { + java.lang.reflect.Method setNamedGroupsMethod = javax.net.ssl.SSLParameters.class.getMethod("setNamedGroups", String[].class); + setNamedGroupsMethod.invoke(sslparams, (Object) new String[]{"MLKEM768"}); + } catch (Exception e) { + // Fallback on JRE 17: Bouncy Castle JJSSE automatically reads the "org.bouncycastle.jsse.server.namedGroups" + // system property to configure the accepted named groups on the server context. + // Documentation reference: https://www.bouncycastle.org/docs/tlsdocs.html#SystemProperties + } + // Commit parameters to the active connection context. + params.setSSLParameters(sslparams); + } + }); + + // 9. Map simple mock endpoint contexts to simulate vanilla API server behavior. + httpServer.createContext("/test", exchange -> { + String response = "PQC HTTP OK"; + exchange.sendResponseHeaders(200, response.length()); + exchange.getResponseBody().write(response.getBytes()); + exchange.getResponseBody().close(); + }); + + // 10. Map mock BigQuery datasets endpoint to simulate vanilla BigQuery dataset listing responses. + httpServer.createContext("/bigquery/v2/projects/test-project/datasets", exchange -> { + String response = "{\"kind\": \"bigquery#datasetList\"}"; + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length()); + exchange.getResponseBody().write(response.getBytes()); + exchange.getResponseBody().close(); + }); + + // 11. Start the HTTP Server and retrieve the dynamically allocated local ephemeral port. + httpServer.start(); + httpPort = httpServer.getAddress().getPort(); + + // 12. Initialize netty SSL Context builder to establish gRPC server channel secure layers. + // Bind the builder explicitly to Bouncy Castle JSSE provider context. + io.netty.handler.ssl.SslContextBuilder nettySslContextBuilder = io.netty.handler.ssl.SslContextBuilder.forServer(kmf) + .sslContextProvider(bcProvider); + + // 13. Reflectively configure the Netty SslContextBuilder accepted curves. + // Netty API curves methods differ depending on whether Netty is utilizing older Iterable-based + // curves signatures or modern String[] array-based curves signatures. + try { + try { + java.lang.reflect.Method curvesMethod = nettySslContextBuilder.getClass().getMethod("curves", String[].class); + curvesMethod.invoke(nettySslContextBuilder, (Object) new String[]{"MLKEM768"}); + } catch (NoSuchMethodException e) { + java.lang.reflect.Method curvesMethod = nettySslContextBuilder.getClass().getMethod("curves", java.lang.Iterable.class); + curvesMethod.invoke(nettySslContextBuilder, java.util.Arrays.asList("MLKEM768")); + } + } catch (Exception e) { + System.err.println("Warning: Failed to programmatically configure Netty curves: " + e.getMessage()); + } + + // 14. Finalize compiling standard Netty SSL configurations. + // Force Netty to execute handshakes utilizing the standard JRE (JDK) SSL Provider + // so Bouncy Castle JJSSE (registered in the provider context) manages the secure pipelines. + io.netty.handler.ssl.SslContext nettySslContext = io.grpc.netty.GrpcSslContexts.configure( + nettySslContextBuilder, + io.netty.handler.ssl.SslProvider.JDK + ) + .protocols("TLSv1.3") // Force TLSv1.3 protocols + .build(); + + // 15. Build a raw gRPC method descriptor to mock a unary SayHello endpoint. + io.grpc.MethodDescriptor method = io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName("Greeter/SayHello") + .setRequestMarshaller(new ByteMarshaller()) + .setResponseMarshaller(new ByteMarshaller()) + .build(); + + // 16. Wrap the method descriptor into a custom gRPC server service definition. + io.grpc.ServerServiceDefinition serviceDef = io.grpc.ServerServiceDefinition.builder("Greeter") + .addMethod(method, io.grpc.stub.ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("PQC gRPC OK".getBytes()); + responseObserver.onCompleted(); + })) + .build(); + + // 17. Start the Netty gRPC Server on a dynamically allocated ephemeral port. + grpcServer = NettyServerBuilder.forPort(0) + .sslContext(nettySslContext) + .addService(serviceDef) + .build() + .start(); + grpcPort = grpcServer.getPort(); + } + + public void stop() { + if (httpServer != null) httpServer.stop(0); + if (grpcServer != null) grpcServer.shutdown(); + // Remove BC JCA and JSSE providers on stop + Security.removeProvider("BCJSSE"); + Security.removeProvider("BC"); + } + + public int getHttpPort() { return httpPort; } + public int getGrpcPort() { return grpcPort; } + + private static class ByteMarshaller implements io.grpc.MethodDescriptor.Marshaller { + @Override + public InputStream stream(byte[] value) { + return new java.io.ByteArrayInputStream(value); + } + @Override + public byte[] parse(InputStream stream) { + try { + return com.google.common.io.ByteStreams.toByteArray(stream); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/sdk-platform-java/pqc-test/pqc-test-common/src/main/resources/pqctest.p12 b/sdk-platform-java/pqc-test/pqc-test-common/src/main/resources/pqctest.p12 new file mode 100644 index 000000000000..92c74c66d3f0 Binary files /dev/null and b/sdk-platform-java/pqc-test/pqc-test-common/src/main/resources/pqctest.p12 differ diff --git a/sdk-platform-java/pqc-test/pqc-test-release/pom.xml b/sdk-platform-java/pqc-test/pqc-test-release/pom.xml new file mode 100644 index 000000000000..e9629cdd7e25 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-release/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + + com.google.api + pqc-test-parent + 2.81.0-SNAPSHOT + ../pom.xml + + + pqc-test-release + + + + com.google.api + pqc-test-common + 2.81.0-SNAPSHOT + + + com.google.api + gax-httpjson + + + com.google.api + gax-grpc + + + com.google.cloud + google-cloud-bigquery + + + + + com.google.cloud + google-cloud-bigquery + 2.66.0 + + + com.google.api + gax-httpjson + 2.80.0 + + + com.google.api + gax-grpc + 2.80.0 + + + com.google.auth + google-auth-library-oauth2-http + 1.47.0 + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + test + + + io.grpc + grpc-netty-shaded + ${grpc.version} + runtime + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + false + + + + + + diff --git a/sdk-platform-java/pqc-test/pqc-test-release/src/test/java/com/google/api/gax/httpjson/RunPqcTest.java b/sdk-platform-java/pqc-test/pqc-test-release/src/test/java/com/google/api/gax/httpjson/RunPqcTest.java new file mode 100644 index 000000000000..ecceab971251 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-release/src/test/java/com/google/api/gax/httpjson/RunPqcTest.java @@ -0,0 +1,5 @@ +package com.google.api.gax.httpjson; + +public class RunPqcTest extends PqcConnectivityTest { + // Inherits all @Test methods from PqcConnectivityTest to run in this module classpath context. +} diff --git a/sdk-platform-java/pqc-test/pqc-test-snapshot/pom.xml b/sdk-platform-java/pqc-test/pqc-test-snapshot/pom.xml new file mode 100644 index 000000000000..22770277caa2 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-snapshot/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + com.google.api + pqc-test-parent + 2.81.0-SNAPSHOT + ../pom.xml + + + pqc-test-snapshot + + + + com.google.api + pqc-test-common + 2.81.0-SNAPSHOT + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + test + + + io.grpc + grpc-netty-shaded + ${grpc.version} + runtime + + + com.google.cloud + google-cloud-bigquery + 2.67.0-SNAPSHOT + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + true + + + + + + diff --git a/sdk-platform-java/pqc-test/pqc-test-snapshot/src/test/java/com/google/api/gax/httpjson/RunPqcTest.java b/sdk-platform-java/pqc-test/pqc-test-snapshot/src/test/java/com/google/api/gax/httpjson/RunPqcTest.java new file mode 100644 index 000000000000..ecceab971251 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-snapshot/src/test/java/com/google/api/gax/httpjson/RunPqcTest.java @@ -0,0 +1,5 @@ +package com.google.api.gax.httpjson; + +public class RunPqcTest extends PqcConnectivityTest { + // Inherits all @Test methods from PqcConnectivityTest to run in this module classpath context. +}