Skip to content

Commit 550df81

Browse files
authored
feat(gax-httpjson): Add Post Quantum Cryptography (PQC) Support by default via Conscrypt (#13853)
1 parent 2aef964 commit 550df81

13 files changed

Lines changed: 659 additions & 24 deletions

File tree

.github/workflows/sdk-platform-java-dependency_compatibility_test.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,22 @@ jobs:
9090
curl --location https://github.com/googleapis/gapic-showcase/releases/download/v${SHOWCASE_VERSION}/gapic-showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz --output /usr/src/showcase/showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz
9191
cd /usr/src/showcase/
9292
tar -xf showcase-*
93+
# Start standard insecure showcase server on default port 7469 for standard integration tests
9394
./gapic-showcase run &
95+
# Start secure TLS showcase server on port 7470 for PQC TLS integration tests
96+
./gapic-showcase run --port 7470 --tls --ca-cert-output-file /tmp/showcase-ca.pem &
97+
# Wait deterministically for both background showcase servers to finish binding ports 7469/7470
98+
# and writing /tmp/showcase-ca.pem. Starting TLS requires RSA key generation and disk I/O,
99+
# which can cause race conditions if tests start before /tmp/showcase-ca.pem is created.
100+
for i in $(seq 1 30); do
101+
if (echo > /dev/tcp/127.0.0.1/7469) 2>/dev/null && \
102+
(echo > /dev/tcp/127.0.0.1/7470) 2>/dev/null && \
103+
[ -f /tmp/showcase-ca.pem ]; then
104+
echo "Showcase servers (ports 7469, 7470) and CA cert ready in attempt $i."
105+
break
106+
fi
107+
sleep 0.2
108+
done
94109
cd -
95110
working-directory: sdk-platform-java
96111

.github/workflows/showcase.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,22 @@ jobs:
6161
curl --location https://github.com/googleapis/gapic-showcase/releases/download/v${SHOWCASE_VERSION}/gapic-showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz --output /usr/src/showcase/showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz
6262
cd /usr/src/showcase/
6363
tar -xf showcase-*
64+
# Start standard insecure showcase server on default port 7469 for standard integration tests
6465
./gapic-showcase run &
66+
# Start secure TLS showcase server on port 7470 for PQC TLS integration tests
67+
./gapic-showcase run --port 7470 --tls --ca-cert-output-file /tmp/showcase-ca.pem &
68+
# Wait deterministically for both background showcase servers to finish binding ports 7469/7470
69+
# and writing /tmp/showcase-ca.pem. Starting TLS requires RSA key generation and disk I/O,
70+
# which can cause race conditions if tests start before /tmp/showcase-ca.pem is created.
71+
for i in $(seq 1 30); do
72+
if (echo > /dev/tcp/127.0.0.1/7469) 2>/dev/null && \
73+
(echo > /dev/tcp/127.0.0.1/7470) 2>/dev/null && \
74+
[ -f /tmp/showcase-ca.pem ]; then
75+
echo "Showcase servers (ports 7469, 7470) and CA cert ready in attempt $i."
76+
break
77+
fi
78+
sleep 0.2
79+
done
6580
cd -
6681
- name: Showcase integration tests
6782
working-directory: java-showcase
@@ -160,7 +175,22 @@ jobs:
160175
curl --location https://github.com/googleapis/gapic-showcase/releases/download/v${SHOWCASE_VERSION}/gapic-showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz --output /usr/src/showcase/showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz
161176
cd /usr/src/showcase/
162177
tar -xf showcase-*
178+
# Start standard insecure showcase server on default port 7469 for standard integration tests
163179
./gapic-showcase run &
180+
# Start secure TLS showcase server on port 7470 for PQC TLS integration tests
181+
./gapic-showcase run --port 7470 --tls --ca-cert-output-file /tmp/showcase-ca.pem &
182+
# Wait deterministically for both background showcase servers to finish binding ports 7469/7470
183+
# and writing /tmp/showcase-ca.pem. Starting TLS requires RSA key generation and disk I/O,
184+
# which can cause race conditions if tests start before /tmp/showcase-ca.pem is created.
185+
for i in $(seq 1 30); do
186+
if (echo > /dev/tcp/127.0.0.1/7469) 2>/dev/null && \
187+
(echo > /dev/tcp/127.0.0.1/7470) 2>/dev/null && \
188+
[ -f /tmp/showcase-ca.pem ]; then
189+
echo "Showcase servers (ports 7469, 7470) and CA cert ready in attempt $i."
190+
break
191+
fi
192+
sleep 0.2
193+
done
164194
cd -
165195
- name: Showcase integration tests
166196
working-directory: java-showcase
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.showcase.v1beta1.it;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static com.google.common.truth.Truth.assertWithMessage;
21+
22+
import com.google.api.client.http.javanet.NetHttpTransport;
23+
import com.google.api.gax.core.NoCredentialsProvider;
24+
import com.google.api.gax.httpjson.HttpJsonConscryptUtils;
25+
import com.google.api.gax.httpjson.HttpJsonMetadata;
26+
import com.google.api.gax.httpjson.InstantiatingHttpJsonChannelProvider;
27+
import com.google.showcase.v1beta1.EchoClient;
28+
import com.google.showcase.v1beta1.EchoRequest;
29+
import com.google.showcase.v1beta1.EchoResponse;
30+
import com.google.showcase.v1beta1.EchoSettings;
31+
import com.google.showcase.v1beta1.it.util.HttpJsonCapturingClientInterceptor;
32+
import java.io.File;
33+
import java.io.InputStream;
34+
import java.nio.file.Files;
35+
import java.nio.file.Paths;
36+
import java.security.KeyStore;
37+
import java.security.cert.Certificate;
38+
import java.security.cert.CertificateFactory;
39+
import java.util.Arrays;
40+
import java.util.Collections;
41+
import java.util.List;
42+
import org.conscrypt.Conscrypt;
43+
import org.junit.jupiter.api.BeforeAll;
44+
import org.junit.jupiter.api.Test;
45+
46+
/**
47+
* Integration tests to verify Post-Quantum Cryptography (PQC) TLS negotiation for HTTP/JSON (REST)
48+
* clients.
49+
*
50+
* <p>These tests execute calls against a local secure (TLS-enabled) Showcase server. During the TLS
51+
* handshake, the client and server negotiate cipher suites and key exchange groups. Showcase
52+
* injects information about the negotiated TLS connection parameters into custom headers:
53+
*
54+
* <ul>
55+
* <li>{@code x-showcase-tls-group}: The negotiated key exchange named group (e.g.
56+
* X25519MLKEM768).
57+
* <li>{@code x-showcase-tls-version}: The TLS version negotiated (e.g. TLS 1.3).
58+
* <li>{@code x-showcase-tls-cipher}: The negotiated cipher suite (e.g. TLS_AES_128_GCM_SHA256).
59+
* <li>{@code x-showcase-tls-client-supported-groups}: The list of groups offered by the client.
60+
* </ul>
61+
*
62+
* <p>Verification cases:
63+
*
64+
* <ol>
65+
* <li>{@code testHttpJsonPqc}: Verifies that HTTP/JSON transport defaults to Conscrypt and
66+
* negotiates the hybrid post-quantum group {@code X25519MLKEM768}.
67+
* <li>{@code testHttpJsonPqc_withExplicitNonPqcGroup}: Verifies that explicitly configuring
68+
* classical non-PQC key exchange groups (e.g. {@code X25519}) forces classical key exchange.
69+
* Explicitly setting the group ensures test compatibility across all JDK versions, including
70+
* future JDK releases (such as JDK 27+) where PQC will be enabled by default.
71+
* </ol>
72+
*/
73+
class ITPostQuantumCryptography {
74+
75+
// TLS response header names from Showcase server
76+
private static final String TLS_GROUP_HEADER = "x-showcase-tls-group";
77+
private static final String TLS_SUPPORTED_GROUPS_HEADER =
78+
"x-showcase-tls-client-supported-groups";
79+
80+
// Expected TLS parameters
81+
private static final String EXPECTED_PQC_GROUP = "X25519MLKEM768";
82+
private static final String CLASSICAL_X25519_GROUP = "X25519";
83+
private static final String[] EXPLICIT_NON_PQC_GROUPS = new String[] {CLASSICAL_X25519_GROUP};
84+
85+
private static final String DEFAULT_CA_CERT_PATH = getCaCertPath();
86+
87+
/**
88+
* Resolves the absolute path to the Showcase server's CA certificate PEM file.
89+
*
90+
* @return absolute path to the CA certificate file
91+
*/
92+
private static String getCaCertPath() {
93+
String prop = System.getProperty("showcase.ca.cert.path");
94+
if (prop != null) {
95+
return prop;
96+
}
97+
if (new File("/tmp/showcase-ca.pem").isFile()) {
98+
return "/tmp/showcase-ca.pem";
99+
}
100+
return "target/showcase-ca.pem";
101+
}
102+
103+
private static final String SECURE_ENDPOINT =
104+
System.getProperty("showcase.secure.endpoint", "localhost:7470");
105+
106+
@BeforeAll
107+
static void setUp() throws Exception {
108+
File certFile = new File(DEFAULT_CA_CERT_PATH);
109+
assertWithMessage("CA certificate file not found at " + DEFAULT_CA_CERT_PATH)
110+
.that(certFile.isFile())
111+
.isTrue();
112+
}
113+
114+
@Test
115+
void testHttpJsonPqc() throws Exception {
116+
HttpJsonCapturingClientInterceptor interceptor = new HttpJsonCapturingClientInterceptor();
117+
118+
// Construct a dedicated NetHttpTransport configured with Conscrypt security provider
119+
// and explicitly trusted Showcase CA certificate. This avoids modifying the global JVM
120+
// SSLContext (via SSLContext.setDefault) and ensures Conscrypt's TLS engine is used.
121+
NetHttpTransport transport =
122+
HttpJsonConscryptUtils.configureConscryptSecurityProvider(new NetHttpTransport.Builder())
123+
.trustCertificates(loadCaCert(DEFAULT_CA_CERT_PATH))
124+
.build();
125+
126+
InstantiatingHttpJsonChannelProvider transportChannelProvider =
127+
EchoSettings.defaultHttpJsonTransportProviderBuilder()
128+
.setHttpTransport(transport)
129+
.setEndpoint("https://" + SECURE_ENDPOINT)
130+
.setInterceptorProvider(() -> Collections.singletonList(interceptor))
131+
.build();
132+
133+
EchoSettings settings =
134+
EchoSettings.newHttpJsonBuilder()
135+
.setCredentialsProvider(NoCredentialsProvider.create())
136+
.setTransportChannelProvider(transportChannelProvider)
137+
.build();
138+
139+
try (EchoClient client = EchoClient.create(settings)) {
140+
EchoResponse response =
141+
client.echo(EchoRequest.newBuilder().setContent("pqc-httpjson-test").build());
142+
assertThat(response.getContent()).isEqualTo("pqc-httpjson-test");
143+
144+
HttpJsonMetadata capturedHeaders = interceptor.metadata;
145+
assertThat(capturedHeaders).isNotNull();
146+
147+
String negotiatedGroup = getSingleHeaderString(capturedHeaders, TLS_GROUP_HEADER);
148+
assertThat(negotiatedGroup).isEqualTo(EXPECTED_PQC_GROUP);
149+
150+
// Assert that supported groups offered by Conscrypt include the primary PQC group and
151+
// classical fallback group. We use containsAtLeast instead of exact list equality because
152+
// Go's crypto/tls library only recognizes standard Curve IDs (e.g. X25519MLKEM768 and
153+
// X25519), which is not a 1:1 mapping with the full list of named groups that Conscrypt
154+
// supports (see
155+
// https://github.com/google/conscrypt/blob/2.6.0/CAPABILITIES.md#supported-named-groups).
156+
// Draft/standalone groups like X25519Kyber768Draft00 and MLKEM1024 are formatted as
157+
// "Unknown-Curve-25497" and "Unknown-Curve-514" by the Showcase server.
158+
List<String> supportedGroups =
159+
getHeaderStringList(capturedHeaders, TLS_SUPPORTED_GROUPS_HEADER);
160+
assertThat(supportedGroups).containsAtLeast(EXPECTED_PQC_GROUP, CLASSICAL_X25519_GROUP);
161+
}
162+
}
163+
164+
@Test
165+
void testHttpJsonPqc_withExplicitNonPqcGroup() throws Exception {
166+
HttpJsonCapturingClientInterceptor interceptor = new HttpJsonCapturingClientInterceptor();
167+
168+
// Explicitly configure Conscrypt socket with classical X25519 group. This verifies that
169+
// custom socket configurators can override default PQC groups with non-PQC groups.
170+
// Explicitly passing a non-PQC curve ensures that the test deterministically verifies
171+
// classical key exchange fallback across all JDK versions, including future JDK 27+ releases
172+
// where PQC algorithms will be enabled by default in standard JDK security providers.
173+
NetHttpTransport transport =
174+
HttpJsonConscryptUtils.configureConscryptSecurityProvider(new NetHttpTransport.Builder())
175+
.setSslSocketConfigurator(
176+
socket -> {
177+
if (Conscrypt.isConscrypt(socket)) {
178+
try {
179+
Conscrypt.setNamedGroups(socket, EXPLICIT_NON_PQC_GROUPS);
180+
} catch (Exception ignored) {
181+
}
182+
}
183+
})
184+
.trustCertificates(loadCaCert(DEFAULT_CA_CERT_PATH))
185+
.build();
186+
187+
InstantiatingHttpJsonChannelProvider transportChannelProvider =
188+
EchoSettings.defaultHttpJsonTransportProviderBuilder()
189+
.setHttpTransport(transport)
190+
.setEndpoint("https://" + SECURE_ENDPOINT)
191+
.setInterceptorProvider(() -> Collections.singletonList(interceptor))
192+
.build();
193+
194+
EchoSettings settings =
195+
EchoSettings.newHttpJsonBuilder()
196+
.setCredentialsProvider(NoCredentialsProvider.create())
197+
.setTransportChannelProvider(transportChannelProvider)
198+
.build();
199+
200+
try (EchoClient client = EchoClient.create(settings)) {
201+
EchoResponse response =
202+
client.echo(EchoRequest.newBuilder().setContent("pqc-httpjson-jsse-test").build());
203+
assertThat(response.getContent()).isEqualTo("pqc-httpjson-jsse-test");
204+
205+
HttpJsonMetadata capturedHeaders = interceptor.metadata;
206+
assertThat(capturedHeaders).isNotNull();
207+
208+
String negotiatedGroup = getSingleHeaderString(capturedHeaders, TLS_GROUP_HEADER);
209+
assertThat(negotiatedGroup).isEqualTo(CLASSICAL_X25519_GROUP);
210+
211+
List<String> supportedGroups =
212+
getHeaderStringList(capturedHeaders, TLS_SUPPORTED_GROUPS_HEADER);
213+
assertThat(supportedGroups).containsExactlyElementsIn(Arrays.asList(EXPLICIT_NON_PQC_GROUPS));
214+
}
215+
}
216+
217+
/**
218+
* Extracts the first string value of a specified HTTP response header from metadata.
219+
*
220+
* @param metadata the HTTP metadata containing response headers
221+
* @param name the case-insensitive header key name
222+
* @return header value string, or {@code null} if not found
223+
*/
224+
private static String getSingleHeaderString(HttpJsonMetadata metadata, String name) {
225+
Object valueObj = metadata.getHeaders().get(name);
226+
if (valueObj instanceof List) {
227+
List<?> list = (List<?>) valueObj;
228+
if (!list.isEmpty()) {
229+
return String.valueOf(list.get(0));
230+
}
231+
} else if (valueObj != null) {
232+
return String.valueOf(valueObj);
233+
}
234+
return null;
235+
}
236+
237+
/**
238+
* Extracts a list of string values from a comma-separated HTTP response header.
239+
*
240+
* @param metadata the HTTP metadata containing response headers
241+
* @param name the case-insensitive header key name
242+
* @return list of header string tokens, or empty list if not found
243+
*/
244+
private static List<String> getHeaderStringList(HttpJsonMetadata metadata, String name) {
245+
String value = getSingleHeaderString(metadata, name);
246+
if (value == null || value.trim().isEmpty()) {
247+
return Collections.emptyList();
248+
}
249+
return Arrays.asList(value.split(","));
250+
}
251+
252+
/**
253+
* Loads an X.509 CA certificate file from disk into a new KeyStore instance.
254+
*
255+
* @param certPath path to the X.509 certificate file
256+
* @return initialized KeyStore containing the certificate entry
257+
* @throws Exception if reading or parsing the certificate fails
258+
*/
259+
private static KeyStore loadCaCert(String certPath) throws Exception {
260+
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
261+
trustStore.load(null, null);
262+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
263+
try (InputStream is = Files.newInputStream(Paths.get(certPath))) {
264+
Certificate cert = cf.generateCertificate(is);
265+
trustStore.setCertificateEntry("showcase-ca", cert);
266+
}
267+
return trustStore;
268+
}
269+
}

sdk-platform-java/gax-java/dependencies.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ maven.javax_annotation_javax_annotation_api=javax.annotation:javax.annotation-ap
7979
maven.org_graalvm_sdk=org.graalvm.sdk:nativeimage:24.1.2
8080
maven.org_slf4j_slf4j_api=org.slf4j:slf4j-api:2.0.16
8181
maven.com_google_protobuf_protobuf_java_util=com.google.protobuf:protobuf-java-util:3.25.5
82+
maven.org_conscrypt_conscrypt_openjdk_uber=org.conscrypt:conscrypt-openjdk-uber:2.6.0
8283

8384
# Testing maven artifacts
8485
maven.org_jspecify_jspecify=org.jspecify:jspecify:1.0.0

sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ _COMPILE_DEPS = [
2525
"@com_google_api_grpc_proto_google_common_protos//jar",
2626
"@com_google_protobuf//:protobuf_java",
2727
"@com_google_protobuf_java_util//jar",
28+
"@org_conscrypt_conscrypt_openjdk_uber//jar",
2829
"//gax:gax",
2930
]
3031

sdk-platform-java/gax-java/gax-httpjson/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@
108108
<artifactId>error_prone_annotations</artifactId>
109109
<version>${errorprone.version}</version>
110110
</dependency>
111+
<dependency>
112+
<groupId>org.conscrypt</groupId>
113+
<artifactId>conscrypt-openjdk-uber</artifactId>
114+
<version>${conscrypt.version}</version>
115+
</dependency>
111116
</dependencies>
112117

113118
<build>

0 commit comments

Comments
 (0)