Skip to content

Commit f466e44

Browse files
ohmayrhebaalazzeh
authored andcommitted
chore(generator): add post-quantum cryptography (PQC) integration tests (#17586)
Adds integration tests against showcase to verify that our libraries support PQC TLS key exchange. To verify, you can run `nox -s showcase_pqc`.
1 parent 81b9ae4 commit f466e44

3 files changed

Lines changed: 150 additions & 18 deletions

File tree

packages/gapic-generator/noxfile.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,24 @@ def showcase_mtls(
474474
)
475475

476476

477+
# TODO(https://github.com/googleapis/google-cloud-python/issues/17752):
478+
# Remove showcase_pqc once grpcio >= 1.83.0 is enforced by default.
479+
@nox.session(python=NEWEST_PYTHON)
480+
def showcase_pqc(
481+
session,
482+
templates="DEFAULT",
483+
other_opts: typing.Iterable[str] = (),
484+
env: typing.Optional[typing.Dict[str, str]] = {},
485+
):
486+
"""Run the Showcase PQC verification test suite against grpcio 1.83+ over standard TLS."""
487+
with showcase_library(session, templates=templates, other_opts=other_opts):
488+
session.install("pytest", "pytest-asyncio")
489+
# TODO(https://github.com/googleapis/google-cloud-python/issues/17751):
490+
# Update the version below to `1.83.0` once released, and remove `--pre`.
491+
session.install("--pre", "--upgrade", "grpcio>=1.83.0rc0", "grpcio-status>=1.83.0rc0")
492+
session.run("py.test", "--quiet", "--tls", *(session.posargs or ["tests/system/test_pqc.py"]), env=env)
493+
494+
477495
def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False):
478496
session.install(
479497
"coverage",

packages/gapic-generator/tests/system/conftest.py

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import os
1919
import pytest
2020
import pytest_asyncio
21+
from requests.adapters import HTTPAdapter
2122

2223
from typing import Sequence, Tuple
2324

@@ -113,15 +114,18 @@ def async_identity(use_mtls, request, event_loop):
113114
)
114115

115116

116-
dir = os.path.dirname(__file__)
117-
with open(os.path.join(dir, "../cert/mtls.crt"), "rb") as fh:
117+
base_dir = os.path.dirname(__file__)
118+
CERT_PATH = os.path.join(base_dir, "../cert/mtls.crt")
119+
KEY_PATH = os.path.join(base_dir, "../cert/mtls.key")
120+
with open(CERT_PATH, "rb") as fh:
118121
cert = fh.read()
119-
with open(os.path.join(dir, "../cert/mtls.key"), "rb") as fh:
122+
with open(KEY_PATH, "rb") as fh:
120123
key = fh.read()
121124

122125
ssl_credentials = grpc.ssl_channel_credentials(
123126
root_certificates=cert, certificate_chain=cert, private_key=key
124127
)
128+
tls_credentials = grpc.ssl_channel_credentials(root_certificates=cert)
125129

126130

127131
def callback():
@@ -136,6 +140,9 @@ def pytest_addoption(parser):
136140
parser.addoption(
137141
"--mtls", action="store_true", help="Run system test with mutual TLS channel"
138142
)
143+
parser.addoption(
144+
"--tls", action="store_true", help="Run system test with standard one-way TLS channel"
145+
)
139146

140147

141148
# TODO: Need to test without passing in a transport class
@@ -189,6 +196,11 @@ def use_mtls(request):
189196
return request.config.getoption("--mtls")
190197

191198

199+
@pytest.fixture
200+
def use_tls(request):
201+
return request.config.getoption("--tls") or request.config.getoption("--mtls")
202+
203+
192204
@pytest.fixture
193205
def parametrized_echo(
194206
use_mtls,
@@ -328,7 +340,7 @@ def _read_response_metadata_stream(self):
328340
def intercept_unary_unary(self, continuation, client_call_details, request):
329341
self._add_request_metadata(client_call_details)
330342
response = continuation(client_call_details, request)
331-
metadata = [(k, str(v)) for k, v in response.trailing_metadata()]
343+
metadata = [(k, str(v)) for k, v in response.initial_metadata()] + [(k, str(v)) for k, v in response.trailing_metadata()]
332344
self.response_metadata = metadata
333345
return response
334346

@@ -387,7 +399,7 @@ async def _add_request_metadata(self, client_call_details):
387399
async def intercept_unary_unary(self, continuation, client_call_details, request):
388400
await self._add_request_metadata(client_call_details)
389401
response = await continuation(client_call_details, request)
390-
metadata = [(k, str(v)) for k, v in await response.trailing_metadata()]
402+
metadata = [(k, str(v)) for k, v in await response.initial_metadata()] + [(k, str(v)) for k, v in await response.trailing_metadata()]
391403
self.response_metadata = metadata
392404
return response
393405

@@ -412,7 +424,7 @@ async def intercept_stream_stream(
412424

413425

414426
@pytest.fixture
415-
def intercepted_echo_grpc(use_mtls):
427+
def intercepted_echo_grpc(use_mtls, use_tls):
416428
# The interceptor adds 'showcase-trailer' client metadata. Showcase server
417429
# echoes any metadata with key 'showcase-trailer', so the same metadata
418430
# should appear as trailing metadata in the response.
@@ -421,11 +433,12 @@ def intercepted_echo_grpc(use_mtls):
421433
"intercepted",
422434
)
423435
host = "localhost:7469"
424-
channel = (
425-
grpc.secure_channel(host, ssl_credentials)
426-
if use_mtls
427-
else grpc.insecure_channel(host)
428-
)
436+
if use_mtls:
437+
channel = grpc.secure_channel(host, ssl_credentials)
438+
elif use_tls:
439+
channel = grpc.secure_channel(host, tls_credentials)
440+
else:
441+
channel = grpc.insecure_channel(host)
429442
intercept_channel = grpc.intercept_channel(channel, interceptor)
430443
transport = EchoClient.get_transport_class("grpc")(
431444
credentials=ga_credentials.AnonymousCredentials(),
@@ -435,7 +448,7 @@ def intercepted_echo_grpc(use_mtls):
435448

436449

437450
@pytest_asyncio.fixture
438-
async def intercepted_echo_grpc_async():
451+
async def intercepted_echo_grpc_async(use_mtls, use_tls):
439452
# The interceptor adds 'showcase-trailer' client metadata. Showcase server
440453
# echoes any metadata with key 'showcase-trailer', so the same metadata
441454
# should appear as trailing metadata in the response.
@@ -444,28 +457,45 @@ async def intercepted_echo_grpc_async():
444457
"intercepted",
445458
)
446459
host = "localhost:7469"
447-
channel = grpc.aio.insecure_channel(host, interceptors=[interceptor])
448-
# intercept_channel = grpc.aio.intercept_channel(channel, interceptor)
460+
if use_mtls:
461+
channel = grpc.aio.secure_channel(host, ssl_credentials, interceptors=[interceptor])
462+
elif use_tls:
463+
channel = grpc.aio.secure_channel(host, tls_credentials, interceptors=[interceptor])
464+
else:
465+
channel = grpc.aio.insecure_channel(host, interceptors=[interceptor])
449466
transport = EchoAsyncClient.get_transport_class("grpc_asyncio")(
450467
credentials=ga_credentials.AnonymousCredentials(),
451468
channel=channel,
452469
)
453470
return EchoAsyncClient(transport=transport), interceptor
454471

455472

473+
class HostNameIgnoringAdapter(HTTPAdapter):
474+
"""Custom HTTPAdapter that disables hostname verification for local self-signed certs."""
475+
def cert_verify(self, conn, url, verify, cert):
476+
super().cert_verify(conn, url, verify, cert)
477+
conn.assert_hostname = False
478+
479+
456480
@pytest.fixture
457-
def intercepted_echo_rest():
481+
def intercepted_echo_rest(use_mtls, use_tls):
458482
transport_name = "rest"
459483
transport_cls = EchoClient.get_transport_class(transport_name)
460484
interceptor = EchoMetadataClientRestInterceptor()
461485

462-
# The custom host explicitly bypasses https.
486+
url_scheme = "https" if (use_mtls or use_tls) else "http"
463487
transport = transport_cls(
464488
credentials=ga_credentials.AnonymousCredentials(),
465489
host="localhost:7469",
466-
url_scheme="http",
490+
url_scheme=url_scheme,
467491
interceptor=interceptor,
468492
)
493+
if use_mtls or use_tls:
494+
transport._session.verify = CERT_PATH
495+
transport._session.mount("https://", HostNameIgnoringAdapter())
496+
if use_mtls:
497+
transport._session.cert = (CERT_PATH, KEY_PATH)
498+
469499
return EchoClient(transport=transport), interceptor
470500

471501

@@ -478,11 +508,11 @@ def intercepted_echo_rest_async():
478508
transport_cls = EchoAsyncClient.get_transport_class(transport_name)
479509
interceptor = EchoMetadataClientRestAsyncInterceptor()
480510

481-
# The custom host explicitly bypasses https.
482511
transport = transport_cls(
483512
credentials=async_anonymous_credentials(),
484513
host="localhost:7469",
485514
url_scheme="http",
486515
interceptor=interceptor,
487516
)
517+
488518
return EchoAsyncClient(transport=transport), interceptor
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import grpc
16+
from packaging.version import Version
17+
import pytest
18+
from google import showcase
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def require_tls(use_tls):
23+
if not use_tls:
24+
pytest.skip("PQC integration test requires standard TLS (--tls flag) to be enabled.")
25+
26+
27+
def _verify_pqc_metadata(interceptor, transport_name):
28+
"""Extracts and verifies negotiated PQC group and supported groups from interceptor metadata."""
29+
response_metadata = getattr(interceptor, "response_metadata", []) or []
30+
headers = {key.lower(): value for key, value in response_metadata}
31+
negotiated_group = headers.get("x-showcase-tls-group")
32+
supported_groups = headers.get("x-showcase-tls-client-supported-groups")
33+
34+
assert negotiated_group is not None, "Failed: Showcase server did not return negotiated TLS group header."
35+
assert supported_groups is not None, "Failed: Showcase server did not return client advertised supported groups."
36+
37+
# Enforce PQC compliance by verifying a post-quantum MLKEM group was negotiated.
38+
# Substring check ("MLKEM" in ...) ensures compatibility across different transport and library group strings.
39+
assert (
40+
"MLKEM" in negotiated_group
41+
), f"Failed: {transport_name} Connection did not negotiate a post-quantum MLKEM group! Negotiated: {negotiated_group}"
42+
43+
44+
def test_pqc_grpc(intercepted_echo_grpc):
45+
"""Verifies that the gRPC client library negotiates post-quantum MLKEM with Showcase server."""
46+
# TODO(https://github.com/googleapis/google-cloud-python/issues/17752):
47+
# Remove this check once grpcio >= 1.83.0 is enforced across all client libraries.
48+
if Version(grpc.__version__) < Version("1.83.0rc0"):
49+
# TODO(https://github.com/googleapis/google-cloud-python/issues/17751):
50+
# Update the version in the check above to `1.83.0` once released.
51+
pytest.skip(f"gRPC PQC negotiation requires grpcio >= 1.83.0 (current: {grpc.__version__})")
52+
53+
client, interceptor = intercepted_echo_grpc
54+
response = client.echo(request=showcase.EchoRequest(content="Verify PQC connection."))
55+
assert response.content == "Verify PQC connection."
56+
_verify_pqc_metadata(interceptor, "grpc")
57+
58+
59+
def test_pqc_rest(intercepted_echo_rest):
60+
"""Verifies that the REST client library negotiates post-quantum MLKEM with Showcase server."""
61+
client, interceptor = intercepted_echo_rest
62+
response = client.echo(request=showcase.EchoRequest(content="Verify PQC connection."))
63+
assert response.content == "Verify PQC connection."
64+
_verify_pqc_metadata(interceptor, "rest")
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_pqc_grpc_async(intercepted_echo_grpc_async):
69+
"""Verifies that the async gRPC client library negotiates post-quantum MLKEM with Showcase server."""
70+
# TODO(https://github.com/googleapis/google-cloud-python/issues/17752):
71+
# Remove this check once grpcio >= 1.83.0 is enforced across all client libraries.
72+
if Version(grpc.__version__) < Version("1.83.0rc0"):
73+
# TODO(https://github.com/googleapis/google-cloud-python/issues/17751):
74+
# Update the version in the check above to `1.83.0` once released.
75+
pytest.skip(
76+
f"gRPC PQC negotiation requires grpcio >= 1.83.0 (current: {grpc.__version__})"
77+
)
78+
79+
client, interceptor = intercepted_echo_grpc_async
80+
response = await client.echo(
81+
request=showcase.EchoRequest(content="Verify PQC connection.")
82+
)
83+
assert response.content == "Verify PQC connection."
84+
_verify_pqc_metadata(interceptor, "grpc_asyncio")

0 commit comments

Comments
 (0)