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..ad26f50b83d4 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,9 @@ public ManagedChannelBuilder createDecoratedChannelBuilder() throws IOExcepti if (interceptorProvider != null) { builder.intercept(interceptorProvider.getInterceptors()); } + // Apply PQC configuration by default as a standard feature of GAX. + builder = applyPqcConfiguration(builder); + if (channelConfigurator != null) { builder = channelConfigurator.apply(builder); } @@ -819,6 +822,148 @@ public ManagedChannelBuilder createDecoratedChannelBuilder() throws IOExcepti return builder; } + private static final class OpenSslReflectionHolder { + private static final Class SHADED_GRPC_SSL_CONTEXTS; + private static final Class SHADED_SSL_CONTEXT_BUILDER; + private static final java.lang.reflect.Method SHADED_FOR_CLIENT; + private static final Object SHADED_GROUPS_OPTION; + private static final java.lang.reflect.Method SHADED_OPTION_METHOD; + private static final java.lang.reflect.Method SHADED_BUILD_METHOD; + private static final java.lang.reflect.Method SHADED_SSL_CONTEXT_METHOD; + private static final Class SHADED_SSL_CONTEXT; + private static final boolean SHADED_AVAILABLE; + + private static final Class UNSHADED_GRPC_SSL_CONTEXTS; + private static final Class UNSHADED_SSL_CONTEXT_BUILDER; + private static final java.lang.reflect.Method UNSHADED_FOR_CLIENT; + private static final Object UNSHADED_GROUPS_OPTION; + private static final java.lang.reflect.Method UNSHADED_OPTION_METHOD; + private static final java.lang.reflect.Method UNSHADED_BUILD_METHOD; + private static final java.lang.reflect.Method UNSHADED_SSL_CONTEXT_METHOD; + private static final Class UNSHADED_SSL_CONTEXT; + private static final boolean UNSHADED_AVAILABLE; + + static { + // 1. Shaded Netty Lookups + Class shadedGrpcSslCtx = null; + Class shadedSslCtxBuilder = null; + java.lang.reflect.Method shadedForClient = null; + Object shadedGroupsOpt = null; + java.lang.reflect.Method shadedOption = null; + java.lang.reflect.Method shadedBuild = null; + java.lang.reflect.Method shadedSslCtxMethod = null; + Class shadedSslCtx = null; + boolean shadedAvailable = false; + try { + String p = "io.grpc.netty.shaded."; + shadedGrpcSslCtx = Class.forName(p + "io.grpc.netty.GrpcSslContexts"); + shadedSslCtxBuilder = Class.forName(p + "io.netty.handler.ssl.SslContextBuilder"); + Class openSslCtxOpt = Class.forName(p + "io.netty.handler.ssl.OpenSslContextOption"); + Class sslCtxOpt = Class.forName(p + "io.netty.handler.ssl.SslContextOption"); + shadedSslCtx = Class.forName(p + "io.netty.handler.ssl.SslContext"); + + shadedForClient = shadedGrpcSslCtx.getMethod("forClient"); + java.lang.reflect.Field groupsField = openSslCtxOpt.getDeclaredField("GROUPS"); + shadedGroupsOpt = groupsField.get(null); + shadedOption = shadedSslCtxBuilder.getMethod("option", sslCtxOpt, Object.class); + shadedBuild = shadedSslCtxBuilder.getMethod("build"); + + Class nettyBuilderClass = Class.forName(p + "io.grpc.netty.NettyChannelBuilder"); + shadedSslCtxMethod = nettyBuilderClass.getMethod("sslContext", shadedSslCtx); + + shadedAvailable = true; + } catch (Throwable t) { + // Ignore: Shaded Netty is not available + } + SHADED_GRPC_SSL_CONTEXTS = shadedGrpcSslCtx; + SHADED_SSL_CONTEXT_BUILDER = shadedSslCtxBuilder; + SHADED_FOR_CLIENT = shadedForClient; + SHADED_GROUPS_OPTION = shadedGroupsOpt; + SHADED_OPTION_METHOD = shadedOption; + SHADED_BUILD_METHOD = shadedBuild; + SHADED_SSL_CONTEXT_METHOD = shadedSslCtxMethod; + SHADED_SSL_CONTEXT = shadedSslCtx; + SHADED_AVAILABLE = shadedAvailable; + + // 2. Unshaded Netty Lookups + Class unshadedGrpcSslCtx = null; + Class unshadedSslCtxBuilder = null; + java.lang.reflect.Method unshadedForClient = null; + Object unshadedGroupsOpt = null; + java.lang.reflect.Method unshadedOption = null; + java.lang.reflect.Method unshadedBuild = null; + java.lang.reflect.Method unshadedSslCtxMethod = null; + Class unshadedSslCtx = null; + boolean unshadedAvailable = false; + try { + unshadedGrpcSslCtx = Class.forName("io.grpc.netty.GrpcSslContexts"); + unshadedSslCtxBuilder = Class.forName("io.netty.handler.ssl.SslContextBuilder"); + Class openSslCtxOpt = Class.forName("io.netty.handler.ssl.OpenSslContextOption"); + Class sslCtxOpt = Class.forName("io.netty.handler.ssl.SslContextOption"); + unshadedSslCtx = Class.forName("io.netty.handler.ssl.SslContext"); + + unshadedForClient = unshadedGrpcSslCtx.getMethod("forClient"); + java.lang.reflect.Field groupsField = openSslCtxOpt.getDeclaredField("GROUPS"); + unshadedGroupsOpt = groupsField.get(null); + unshadedOption = unshadedSslCtxBuilder.getMethod("option", sslCtxOpt, Object.class); + unshadedBuild = unshadedSslCtxBuilder.getMethod("build"); + + Class nettyBuilderClass = Class.forName("io.grpc.netty.NettyChannelBuilder"); + unshadedSslCtxMethod = nettyBuilderClass.getMethod("sslContext", unshadedSslCtx); + + unshadedAvailable = true; + } catch (Throwable t) { + // Ignore: Unshaded Netty is not available + } + UNSHADED_GRPC_SSL_CONTEXTS = unshadedGrpcSslCtx; + UNSHADED_SSL_CONTEXT_BUILDER = unshadedSslCtxBuilder; + UNSHADED_FOR_CLIENT = unshadedForClient; + UNSHADED_GROUPS_OPTION = unshadedGroupsOpt; + UNSHADED_OPTION_METHOD = unshadedOption; + UNSHADED_BUILD_METHOD = unshadedBuild; + UNSHADED_SSL_CONTEXT_METHOD = unshadedSslCtxMethod; + UNSHADED_SSL_CONTEXT = unshadedSslCtx; + UNSHADED_AVAILABLE = unshadedAvailable; + } + } + + private ManagedChannelBuilder applyPqcConfiguration(ManagedChannelBuilder builder) { + // Configure the PQ and classical hybrid named groups: + // 1. X25519MLKEM768 (codepoint 4588): Hybrid classical (X25519) + post-quantum (ML-KEM-768) key exchange. + // Provides defense-in-depth: if ML-KEM is compromised, security reverts to classical strength of X25519. + // 2. MLKEM768 (codepoint 1896): Pure post-quantum key exchange using ML-KEM-768. + // 3. X25519 (codepoint 29): Classical elliptic curve Diffie-Hellman key exchange, used as a fallback. + String[] hybridGroups = new String[] {"X25519MLKEM768", "MLKEM768", "X25519"}; + String builderClassName = builder.getClass().getName(); + boolean isShaded = "io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder".equals(builderClassName); + boolean isUnshaded = "io.grpc.netty.NettyChannelBuilder".equals(builderClassName); + + if (isShaded && OpenSslReflectionHolder.SHADED_AVAILABLE) { + try { + Object sslContextBuilder = OpenSslReflectionHolder.SHADED_FOR_CLIENT.invoke(null); + OpenSslReflectionHolder.SHADED_OPTION_METHOD.invoke( + sslContextBuilder, OpenSslReflectionHolder.SHADED_GROUPS_OPTION, (Object) hybridGroups); + Object sslContext = OpenSslReflectionHolder.SHADED_BUILD_METHOD.invoke(sslContextBuilder); + OpenSslReflectionHolder.SHADED_SSL_CONTEXT_METHOD.invoke(builder, sslContext); + return builder; + } catch (java.lang.reflect.InvocationTargetException | IllegalAccessException | RuntimeException e) { + LOG.log(Level.WARNING, "Failed to configure shaded PQC transport fallback", e); + } + } else if (isUnshaded && OpenSslReflectionHolder.UNSHADED_AVAILABLE) { + try { + Object sslContextBuilder = OpenSslReflectionHolder.UNSHADED_FOR_CLIENT.invoke(null); + OpenSslReflectionHolder.UNSHADED_OPTION_METHOD.invoke( + sslContextBuilder, OpenSslReflectionHolder.UNSHADED_GROUPS_OPTION, (Object) hybridGroups); + Object sslContext = OpenSslReflectionHolder.UNSHADED_BUILD_METHOD.invoke(sslContextBuilder); + OpenSslReflectionHolder.UNSHADED_SSL_CONTEXT_METHOD.invoke(builder, sslContext); + return builder; + } catch (java.lang.reflect.InvocationTargetException | IllegalAccessException | RuntimeException e) { + LOG.log(Level.WARNING, "Failed to configure unshaded PQC transport fallback", e); + } + } + 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..1afc1f20f2e4 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; @@ -186,6 +188,8 @@ public TransportChannelProvider withCredentials(Credentials credentials) { HttpTransport createHttpTransport() throws IOException, GeneralSecurityException { if (mtlsProvider == null) { + // Returning null allows ManagedHttpJsonChannel to instantiate a default NetHttpTransport, + // which is automatically PQC-hardened if Bouncy Castle JSSE is available on the classpath. return null; } if (certificateBasedAccess.useMtlsClientCertificate()) { 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..30534091a559 --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/httpjson/PqcConnectivityTest.java @@ -0,0 +1,238 @@ +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 class for validating Post-Quantum Cryptography (PQC) + * connectivity in the Google Cloud Java SDK. + */ +public class PqcConnectivityTest { + + private static PqcTestServer server; + + @BeforeAll + public static void setup() throws Exception { + System.setProperty("javax.net.debug", "all"); + + // NOTE: Enforcing MLKEM768 globally via system property is strictly isolated to this test JVM execution. + // This ensures that the SunJSSE engine (used by old released libraries when pqc.enable is false) + // attempts to negotiate MLKEM768. Since SunJSSE does not implement MLKEM768, it immediately + // aborts the handshake with a handshake_failure, allowing us to confirm that older client libraries + // cleanly fail-fast as expected, validating the integration test negative assertions. + System.setProperty("jdk.tls.namedGroups", "MLKEM768"); + + Security.addProvider(new BouncyCastleProvider()); + if (Boolean.getBoolean("pqc.enable")) { + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + } else { + Security.addProvider(new BouncyCastleJsseProvider()); + } + + server = new PqcTestServer(); + server.start(); + } + + @AfterAll + public static void teardown() { + if (server != null) { + server.stop(); + } + Security.removeProvider("BCJSSE"); + Security.removeProvider("BC"); + } + + public void runTests() throws Exception { + testHttpPqc(); + testGrpcPqc(); + testBigQueryPqc(); + } + + @Test + public void testHttpPqc() throws Exception { + java.security.KeyStore ks = java.security.KeyStore.getInstance("PKCS12"); + ks.load(PqcTestServer.class.getResourceAsStream("/pqctest.p12"), "password".toCharArray()); + + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + // Build a custom HttpTransport explicitly trusting the self-signed certificate keystore. + com.google.api.client.http.HttpTransport httpTransport = new com.google.api.client.http.javanet.NetHttpTransport.Builder() + .trustCertificates(ks) + .build(); + + // Pass the pre-configured httpTransport to the InstantiatingHttpJsonChannelProvider. + InstantiatingHttpJsonChannelProvider provider = InstantiatingHttpJsonChannelProvider.newBuilder() + .setEndpoint("localhost:" + server.getHttpPort()) + .setHeaderProvider(() -> java.util.Collections.emptyMap()) + .setHttpTransport(httpTransport) + .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); + com.google.api.client.http.HttpRequest request = transportFromChannel.createRequestFactory().buildGetRequest( + new com.google.api.client.http.GenericUrl("https://localhost:" + server.getHttpPort() + "/test")); + + HttpResponse response = request.execute(); + assertEquals(200, response.getStatusCode()); + String content = response.parseAsString(); + assertEquals("PQC HTTP OK", content.trim()); + } + + @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()); + + if (Boolean.getBoolean("pqc.enable")) { + 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(); + + byte[] response = io.grpc.stub.ClientCalls.blockingUnaryCall( + channel, method, io.grpc.CallOptions.DEFAULT, "Hello".getBytes()); + + assertEquals("PQC gRPC OK", new String(response).trim()); + ((io.grpc.ManagedChannel) channel).shutdown(); + } + + @Test + public void testBigQueryPqc() throws Exception { + java.security.KeyStore ks = java.security.KeyStore.getInstance("PKCS12"); + ks.load(PqcTestServer.class.getResourceAsStream("/pqctest.p12"), "password".toCharArray()); + + // Build a custom HttpTransport explicitly trusting the self-signed certificate keystore. + final com.google.api.client.http.HttpTransport httpTransport = new com.google.api.client.http.javanet.NetHttpTransport.Builder() + .trustCertificates(ks) + .build(); + + TransportOptions transportOptions = HttpTransportOptions.newBuilder() + .setHttpTransportFactory(new HttpTransportFactory() { + @Override + public com.google.api.client.http.HttpTransport create() { + return httpTransport; + } + }) + .build(); + + BigQueryOptions bigqueryOptions = BigQueryOptions.newBuilder() + .setProjectId("test-project") + .setHost("https://localhost:" + server.getHttpPort()) + .setCredentials(NoCredentials.getInstance()) + .setTransportOptions(transportOptions) + .build(); + + BigQuery bigquery = bigqueryOptions.getService(); + + // This will trigger a request to https://localhost:httpPort/bigquery/v2/projects/test-project/datasets + // Handshake must succeed. If it fails, it throws SSLHandshakeException. + bigquery.listDatasets(); + } + + 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..9cd7a14521cb --- /dev/null +++ b/sdk-platform-java/pqc-test/pqc-test-common/src/main/java/com/google/api/gax/pqc/PqcTestServer.java @@ -0,0 +1,150 @@ +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. + 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. + Security.addProvider(new BouncyCastleJsseProvider()); + + // Set system property to strictly enforce ML-KEM hybrid named group on the server. + // NOTE: This system property is set strictly inside test harness setup. + // Since this server class is only compiled and executed inside integration test contexts, + // it has zero impact on production runtimes (which never load or execute this class). + System.setProperty("jdk.tls.namedGroups", "MLKEM768"); + + // PKCS12 is the key store format to bundle the private key + the certificate. + 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 key with a dummy password + ks.load(is, "password".toCharArray()); + } + + // Key manager factory used to choose credentials for the TLS handshake. + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "password".toCharArray()); + + // Trust manager factory used to decide whether a client should be trusted. + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + // 1. Start HTTP Server utilizing Bouncy Castle JJSSE + BouncyCastleJsseProvider bcProvider = new BouncyCastleJsseProvider(); + SSLContext sslContext = SSLContext.getInstance("TLSv1.3", bcProvider); + sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + httpServer = HttpsServer.create(new InetSocketAddress(0), 0); + httpServer.setHttpsConfigurator(new HttpsConfigurator(sslContext) { + @Override + public void configure(HttpsParameters params) { + SSLParameters sslparams = getSSLContext().getDefaultSSLParameters(); + // Enforce TLSv1.3 protocol + sslparams.setProtocols(new String[]{"TLSv1.3"}); + params.setSSLParameters(sslparams); + } + }); + httpServer.createContext("/test", exchange -> { + String response = "PQC HTTP OK"; + exchange.sendResponseHeaders(200, response.length()); + exchange.getResponseBody().write(response.getBytes()); + exchange.getResponseBody().close(); + }); + 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(); + }); + httpServer.start(); + httpPort = httpServer.getAddress().getPort(); + + // 2. Start gRPC Server using JDK SSL Provider bound specifically to Bouncy Castle JJSSE + io.netty.handler.ssl.SslContext nettySslContext = io.grpc.netty.GrpcSslContexts.configure( + io.netty.handler.ssl.SslContextBuilder.forServer(kmf) + .sslContextProvider(bcProvider), // Bind Netty statically to BC JJSSE! + io.netty.handler.ssl.SslProvider.JDK + ) + .protocols("TLSv1.3") // Enforce TLSv1.3 + .build(); + + io.grpc.MethodDescriptor method = io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName("Greeter/SayHello") + .setRequestMarshaller(new ByteMarshaller()) + .setResponseMarshaller(new ByteMarshaller()) + .build(); + + 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(); + + 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. +}