diff --git a/core/packages/gax/test/showcase-server/src/index.ts b/core/packages/gax/test/showcase-server/src/index.ts index a7f904cc6bff..8f3a5110fff3 100644 --- a/core/packages/gax/test/showcase-server/src/index.ts +++ b/core/packages/gax/test/showcase-server/src/index.ts @@ -32,8 +32,16 @@ function sleep(timeoutMs: number) { export class ShowcaseServer { server: execa.ExecaChildProcess | undefined; - async start() { - const testDir = path.join(process.cwd(), '.showcase-server-dir'); + /** + * Starts the gapic-showcase server. + * @param opts Optional configuration for the server: + * - tls: If true, starts the server with TLS enabled. + * - port: The port to bind the server to (e.g. ":7443"). + * - caCertOutputFile: Path where the server should write its CA cert (when TLS is enabled). + */ + async start(opts?: { tls?: boolean; port?: string; caCertOutputFile?: string }) { + const cwd = process.cwd(); + const testDir = path.join(cwd, '.showcase-server-dir'); const platform = process.platform; const arch = process.arch === 'x64' ? 'amd64' : process.arch; const showcaseVersion = process.env['SHOWCASE_VERSION'] || '0.36.2'; @@ -41,14 +49,31 @@ export class ShowcaseServer { const fallbackServerUrl = `https://github.com/googleapis/gapic-showcase/releases/download/v${showcaseVersion}/${tarballFilename}`; const binaryName = './gapic-showcase'; + let resolvedCaCertPath = ''; + if (opts?.caCertOutputFile) { + resolvedCaCertPath = path.resolve(cwd, opts.caCertOutputFile); + } + await fsp.rm(testDir, {recursive: true, force: true}); await mkdir(testDir); - process.chdir(testDir); console.log(`Server will be run from ${testDir}.`); await download(fallbackServerUrl, testDir); - await execa('tar', ['xzf', tarballFilename]); - const childProcess = execa(binaryName, ['run'], { + await execa('tar', ['xzf', tarballFilename], {cwd: testDir}); + + const args = ['run']; + // Pass additional options to gapic-showcase based on opts + if (opts?.tls) { + args.push('--tls'); + } + if (opts?.port) { + args.push('--port', opts.port); + } + if (resolvedCaCertPath) { + args.push('--ca-cert-output-file', resolvedCaCertPath); + } + + const childProcess = execa(binaryName, args, { cwd: testDir, stdio: 'inherit', }); @@ -68,6 +93,40 @@ export class ShowcaseServer { ); this.server = childProcess; + + if (resolvedCaCertPath) { + const dir = path.dirname(resolvedCaCertPath); + const filename = path.basename(resolvedCaCertPath); + + const pemExists = await new Promise((resolve) => { + if (fs.existsSync(resolvedCaCertPath)) { + return resolve(true); + } + + const timeoutId = setTimeout(() => { + watcher.close(); + resolve(false); + }, 15000); + + const watcher = fs.watch(dir, (eventType, triggeredFilename) => { + if (triggeredFilename === filename || fs.existsSync(resolvedCaCertPath)) { + clearTimeout(timeoutId); + watcher.close(); + // Wait a few milliseconds to ensure showcase has finished writing the file. + setTimeout(() => { + resolve(true); + }, 500); + } + }); + }); + + if (!pemExists) { + throw new Error(`CA Certificate file not found at ${resolvedCaCertPath}`); + } + + return fs.readFileSync(resolvedCaCertPath); + } + return undefined; } stop() { diff --git a/core/packages/gax/test/test-application/src/index.ts b/core/packages/gax/test/test-application/src/index.ts index d6ab4154e17b..c0035e1de447 100644 --- a/core/packages/gax/test/test-application/src/index.ts +++ b/core/packages/gax/test/test-application/src/index.ts @@ -19,6 +19,7 @@ import {EchoClient, SequenceServiceClient, protos} from 'showcase-echo-client'; import {ShowcaseServer} from 'showcase-server'; import * as assert from 'assert'; import {promises as fsp} from 'fs'; +import {runPqcComplianceTests} from './pqc-test'; import * as path from 'path'; import { protobuf, @@ -2910,6 +2911,8 @@ async function main() { } finally { showcaseServer.stop(); } + // Run PQC tests with a different showcase server because setup with a certificate is required. + await runPqcComplianceTests() } main(); diff --git a/core/packages/gax/test/test-application/src/pqc-test.ts b/core/packages/gax/test/test-application/src/pqc-test.ts new file mode 100644 index 000000000000..e506b0b8767c --- /dev/null +++ b/core/packages/gax/test/test-application/src/pqc-test.ts @@ -0,0 +1,211 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as https from 'https'; +import * as path from 'path'; +import * as assert from 'assert'; +import {grpc, GoogleAuth, googleAuthLibrary} from 'google-gax'; +import {EchoClient} from 'showcase-echo-client'; +import {ShowcaseServer} from 'showcase-server'; + +import * as os from 'os'; +import * as tls from 'tls'; + +async function grpcPqcTest(pemBuffer: Buffer, port: number) { + let negotiatedGroupGrpc: string | undefined; + let clientSupportedGroupsGrpc: string | undefined; + + const interceptor = (options: any, nextCall: any) => { + return new grpc.InterceptingCall(nextCall(options), { + start: (metadata: any, listener: any, next: any) => { + next(metadata, { + onReceiveMetadata: (receivedMetadata: any, nextListener: any) => { + const group = receivedMetadata.get('x-showcase-tls-group'); + if (group && group.length > 0) { + negotiatedGroupGrpc = group[0].toString(); + } + const supportedGroups = receivedMetadata.get( + 'x-showcase-tls-client-supported-groups' + ); + if (supportedGroups && supportedGroups.length > 0) { + clientSupportedGroupsGrpc = supportedGroups[0].toString(); + } + nextListener(receivedMetadata); + }, + }); + }, + }); + }; + + const grpcClientOpts = { + grpc, + sslCreds: grpc.credentials.createSsl(pemBuffer), + servicePath: 'localhost', + port: port, + }; + + const grpcClient = new EchoClient(grpcClientOpts); + + const [responseGrpc] = await grpcClient.echo( + { content: 'grpc-pqc-test' }, + { + otherArgs: { + options: { + interceptors: [interceptor], + }, + }, + } + ); + + assert.strictEqual(responseGrpc.content, 'grpc-pqc-test'); + assert.ok( + negotiatedGroupGrpc, + 'Expected negotiated TLS group in gRPC response metadata' + ); + assert.strictEqual(negotiatedGroupGrpc, 'X25519MLKEM768'); + assert.ok( + clientSupportedGroupsGrpc, + 'Expected client supported groups in gRPC response metadata' + ); + assert.ok( + clientSupportedGroupsGrpc.includes('X25519MLKEM768'), + 'Expected client to include X25519MLKEM768 in supported groups' + ); +} + +async function httpRestFallbackPqcTest(pemBuffer: Buffer, port: number) { + let negotiatedGroupRest: string | undefined; + let clientSupportedGroupsRest: string | undefined; + + const auth = new GoogleAuth({ + authClient: new googleAuthLibrary.PassThroughClient(), + }); + + const originalFetch = auth.fetch.bind(auth); + (auth as any).fetch = async (url: string, opts: any) => { + if (url.startsWith('https:')) { + opts.agent = new https.Agent({ + ca: pemBuffer, + keepAlive: true, + }); + } + const res = await originalFetch(url, opts); + // res.headers is a Headers instance (with .get()) in standard Fetch API and a plain object in other HTTP clients. + const group = + typeof res.headers.get === 'function' + ? res.headers.get('x-showcase-tls-group') + : (res.headers as any)['x-showcase-tls-group']; + if (group) { + negotiatedGroupRest = group; + } + const supportedGroups = + typeof res.headers.get === 'function' + ? res.headers.get('x-showcase-tls-client-supported-groups') + : (res.headers as any)['x-showcase-tls-client-supported-groups']; + if (supportedGroups) { + clientSupportedGroupsRest = supportedGroups; + } + return res; + }; + + const restClientOpts = { + fallback: true, + protocol: 'https', + servicePath: 'localhost', + port: port, + auth: auth, + }; + + const restClient = new EchoClient(restClientOpts); + const [responseRest] = await restClient.echo({ content: 'rest-pqc-test' }); + + assert.strictEqual(responseRest.content, 'rest-pqc-test'); + assert.ok( + negotiatedGroupRest, + 'Expected negotiated TLS group in REST response headers' + ); + assert.strictEqual(negotiatedGroupRest, 'X25519MLKEM768'); + assert.ok( + clientSupportedGroupsRest, + 'Expected client supported groups in REST response headers' + ); + assert.ok( + clientSupportedGroupsRest.includes('X25519MLKEM768'), + 'Expected client to include X25519MLKEM768 in supported groups' + ); +} + +/** + * Tests Post Quantum Cryptography (PQC) using the specified CA cert and port. + * It verifies both gRPC and HTTP/REST clients by inspecting the negotiated TLS group. + * @param pemBuffer The CA certificate buffer. + * @param port The port the TLS showcase server is listening on. + */ +async function testPqc(pemBuffer: Buffer, port: number) { + await grpcPqcTest(pemBuffer, port); + await httpRestFallbackPqcTest(pemBuffer, port); +} + +/** + * Spins up a TLS-enabled showcase server and runs the PQC compliance tests. + * Cleans up the generated certificate file after the tests complete. + */ +export async function runPqcComplianceTests() { + const [major, minor] = process.version.replace('v', '').split('.').map(Number); + if (major < 22 || (major === 22 && minor < 20)) { + console.log( + `skipping PQC compliance tests because node version is ${process.version}` + ); + return; + } + + const originalShowcaseVersion = process.env['SHOWCASE_VERSION']; + process.env['SHOWCASE_VERSION'] = '0.41.1'; + const showcaseServerTls = new ShowcaseServer(); + const tlsPort = 7443; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pqc-test-')); + const caCertOutputFile = path.join(tempDir, 'showcase.pem'); + const pemPath = caCertOutputFile; + + try { + if (fs.existsSync(pemPath)) { + fs.unlinkSync(pemPath); + } + + const pemBuffer = await showcaseServerTls.start({ + tls: true, + port: `:${tlsPort}`, + caCertOutputFile: caCertOutputFile, + }); + + if (!pemBuffer) { + throw new Error('Expected showcase server to return CA certificate buffer'); + } + + await testPqc(pemBuffer, tlsPort); + } finally { + showcaseServerTls.stop(); + if (originalShowcaseVersion === undefined) { + delete process.env['SHOWCASE_VERSION']; + } else { + process.env['SHOWCASE_VERSION'] = originalShowcaseVersion; + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + } +}