Repository navigation
feat(gax): add ensurePqcCurvesConfigured helper for MLKEM1024 TLS negotiation - #9798
danieljbruce wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces post-quantum cryptography (PQC) ECDH curve configuration (MLKEM1024) in grpc.ts when tls.DEFAULT_ECDH_CURVE is set to 'auto', along with corresponding unit tests. The feedback highlights two key improvements: first, to avoid temporarily setting the global tls.DEFAULT_ECDH_CURVE to an invalid value, the candidate curve should be verified before mutating the global state; second, the TLS ClientHello parsing in the test should accumulate buffers and perform proper bounds checking to prevent potential out-of-bounds reads or unhandled exceptions caused by TCP fragmentation.
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | ||
| try { | ||
| tls.DEFAULT_ECDH_CURVE = candidate; | ||
| tls.createSecureContext({ecdhCurve: candidate}); | ||
| return; | ||
| } catch { | ||
| tls.DEFAULT_ECDH_CURVE = 'auto'; | ||
| } | ||
| } |
There was a problem hiding this comment.
Mutating the global tls.DEFAULT_ECDH_CURVE before verifying if the candidate curve is supported is risky. If tls.createSecureContext throws an error, the global state is temporarily set to an invalid/unsupported value before being reset to 'auto'. It is safer to test the candidate first and only update the global tls.DEFAULT_ECDH_CURVE upon success.
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | |
| try { | |
| tls.DEFAULT_ECDH_CURVE = candidate; | |
| tls.createSecureContext({ecdhCurve: candidate}); | |
| return; | |
| } catch { | |
| tls.DEFAULT_ECDH_CURVE = 'auto'; | |
| } | |
| } | |
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | |
| try { | |
| tls.createSecureContext({ecdhCurve: candidate}); | |
| tls.DEFAULT_ECDH_CURVE = candidate; | |
| return; | |
| } catch { | |
| // Continue to the next candidate | |
| } | |
| } |
| const supportedGroups = await new Promise<number[]>((resolve, reject) => { | ||
| const server = net.createServer(socket => { | ||
| socket.once('data', buf => { | ||
| socket.destroy(); | ||
| server.close(); | ||
| let pos = 5 + 4 + 2 + 32; | ||
| pos += 1 + buf[pos]; | ||
| pos += 2 + buf.readUInt16BE(pos); | ||
| pos += 1 + buf[pos]; | ||
| const extTotalLen = buf.readUInt16BE(pos); | ||
| pos += 2; | ||
| const end = pos + extTotalLen; | ||
| const groups: number[] = []; | ||
| while (pos < end) { | ||
| const extType = buf.readUInt16BE(pos); | ||
| const extLen = buf.readUInt16BE(pos + 2); | ||
| pos += 4; | ||
| if (extType === 0x000a) { | ||
| const listLen = buf.readUInt16BE(pos); | ||
| for (let i = 0; i < listLen; i += 2) { | ||
| groups.push(buf.readUInt16BE(pos + 2 + i)); | ||
| } | ||
| } | ||
| pos += extLen; | ||
| } | ||
| resolve(groups); | ||
| }); | ||
| }); | ||
| server.on('error', reject); | ||
| server.listen(0, '127.0.0.1', () => { | ||
| const addr = server.address() as net.AddressInfo; | ||
| const client = tls.connect({ | ||
| port: addr.port, | ||
| host: '127.0.0.1', | ||
| servername: 'localhost', | ||
| }); | ||
| client.on('error', () => {}); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The TLS ClientHello parsing in this test assumes that the entire ClientHello is received in a single data event and does not perform bounds checking on the buffer. In TCP, data can be fragmented, and reading from arbitrary offsets without verifying buf.length can lead to out-of-bounds reads or unhandled exceptions. It is safer to accumulate the buffer until the full TLS record length is received and perform bounds checks at each parsing step.
const supportedGroups = await new Promise<number[]>((resolve, reject) => {
let accumulated = Buffer.alloc(0);
const server = net.createServer(socket => {
socket.on('data', buf => {
accumulated = Buffer.concat([accumulated, buf]);
if (accumulated.length < 5) {
return;
}
const recordLen = accumulated.readUInt16BE(3);
if (accumulated.length < 5 + recordLen) {
return;
}
socket.destroy();
server.close();
try {
let pos = 5 + 4 + 2 + 32;
if (pos >= accumulated.length) {
resolve([]);
return;
}
pos += 1 + accumulated[pos];
if (pos + 2 > accumulated.length) {
resolve([]);
return;
}
pos += 2 + accumulated.readUInt16BE(pos);
if (pos >= accumulated.length) {
resolve([]);
return;
}
pos += 1 + accumulated[pos];
if (pos + 2 > accumulated.length) {
resolve([]);
return;
}
const extTotalLen = accumulated.readUInt16BE(pos);
pos += 2;
const end = pos + extTotalLen;
const groups: number[] = [];
while (pos + 4 <= end && pos + 4 <= accumulated.length) {
const extType = accumulated.readUInt16BE(pos);
const extLen = accumulated.readUInt16BE(pos + 2);
pos += 4;
if (extType === 0x000a && pos + 2 <= accumulated.length) {
const listLen = accumulated.readUInt16BE(pos);
for (let i = 0; i < listLen && pos + 2 + i + 2 <= accumulated.length; i += 2) {
groups.push(accumulated.readUInt16BE(pos + 2 + i));
}
}
pos += extLen;
}
resolve(groups);
} catch (err) {
reject(err);
}
});
});
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as net.AddressInfo;
const client = tls.connect({
port: addr.port,
host: '127.0.0.1',
servername: 'localhost',
});
client.on('error', () => {});
});
});| import tls from 'tls'; | ||
|
|
||
| const PQC_ECDH_CURVE_CANDIDATES = [ | ||
| 'DEFAULT:MLKEM1024', | ||
| 'X25519MLKEM768:MLKEM1024:X25519:P-256:P-384', | ||
| ]; | ||
|
|
||
| export function ensurePqcCurvesConfigured() { | ||
| if (tls.DEFAULT_ECDH_CURVE !== 'auto') { | ||
| return; | ||
| } | ||
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | ||
| try { | ||
| tls.DEFAULT_ECDH_CURVE = candidate; | ||
| tls.createSecureContext({ecdhCurve: candidate}); | ||
| return; | ||
| } catch { | ||
| tls.DEFAULT_ECDH_CURVE = 'auto'; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| ensurePqcCurvesConfigured(); |
There was a problem hiding this comment.
[Generated] Imports tls as a default binding (avoiding __importStar read-only getter wrappers) and defines ensurePqcCurvesConfigured() to append MLKEM1024 (IANA 0x0202) to tls.DEFAULT_ECDH_CURVE when set to 'auto', validating OpenSSL 3.5+ ('DEFAULT:MLKEM1024') and BoringSSL/Bun ('X25519MLKEM768:MLKEM1024:X25519:P-256:P-384') via tls.createSecureContext with fallback to 'auto'.
| /* eslint-disable @typescript-eslint/no-var-requires */ | ||
|
|
||
| import assert from 'assert'; | ||
| import * as net from 'net'; |
There was a problem hiding this comment.
[Generated] Imports Node's net module to spin up a local TCP listener that captures raw TLS 1.3 ClientHello packets for supported_groups (0x000a) inspection.
| import * as net from 'net'; | ||
| import * as path from 'path'; | ||
| import * as sinon from 'sinon'; | ||
| import tls from 'tls'; |
There was a problem hiding this comment.
[Generated] Imports Node's tls module so unit tests can inspect and reset tls.DEFAULT_ECDH_CURVE and initiate loopback TLS handshakes.
|
|
||
| import {protobuf} from '../../src/index'; | ||
| import { | ||
| ensurePqcCurvesConfigured, |
There was a problem hiding this comment.
[Generated] Imports ensurePqcCurvesConfigured from ../../src/grpc for direct unit test coverage.
|
|
||
| describe('ensurePqcCurvesConfigured', () => { | ||
| const initialDefaultCurve = tls.DEFAULT_ECDH_CURVE; | ||
|
|
||
| afterEach(() => { | ||
| sinon.restore(); | ||
| tls.DEFAULT_ECDH_CURVE = initialDefaultCurve; | ||
| }); | ||
|
|
||
| it('configures tls.DEFAULT_ECDH_CURVE to include MLKEM1024 when set to auto', () => { | ||
| tls.DEFAULT_ECDH_CURVE = 'auto'; | ||
| ensurePqcCurvesConfigured(); | ||
| assert.ok( | ||
| tls.DEFAULT_ECDH_CURVE.includes('MLKEM1024'), | ||
| `Expected tls.DEFAULT_ECDH_CURVE (${tls.DEFAULT_ECDH_CURVE}) to include MLKEM1024`, | ||
| ); | ||
| }); | ||
|
|
||
| it('preserves custom tls.DEFAULT_ECDH_CURVE values without overwriting', () => { | ||
| tls.DEFAULT_ECDH_CURVE = 'X25519:P-256'; | ||
| ensurePqcCurvesConfigured(); | ||
| assert.strictEqual(tls.DEFAULT_ECDH_CURVE, 'X25519:P-256'); | ||
| }); | ||
|
|
||
| it('falls back to auto when tls.createSecureContext rejects PQC curve candidates', () => { | ||
| tls.DEFAULT_ECDH_CURVE = 'auto'; | ||
| sinon.stub(tls, 'createSecureContext').throws(new Error('unsupported')); | ||
| ensurePqcCurvesConfigured(); | ||
| assert.strictEqual(tls.DEFAULT_ECDH_CURVE, 'auto'); | ||
| }); | ||
|
|
||
| it('advertises 0x0202 (MLKEM1024) in TLS ClientHello supported_groups', async () => { | ||
| tls.DEFAULT_ECDH_CURVE = 'auto'; | ||
| ensurePqcCurvesConfigured(); | ||
|
|
||
| const supportedGroups = await new Promise<number[]>((resolve, reject) => { | ||
| const server = net.createServer(socket => { | ||
| socket.once('data', buf => { | ||
| socket.destroy(); | ||
| server.close(); | ||
| let pos = 5 + 4 + 2 + 32; | ||
| pos += 1 + buf[pos]; | ||
| pos += 2 + buf.readUInt16BE(pos); | ||
| pos += 1 + buf[pos]; | ||
| const extTotalLen = buf.readUInt16BE(pos); | ||
| pos += 2; | ||
| const end = pos + extTotalLen; | ||
| const groups: number[] = []; | ||
| while (pos < end) { | ||
| const extType = buf.readUInt16BE(pos); | ||
| const extLen = buf.readUInt16BE(pos + 2); | ||
| pos += 4; | ||
| if (extType === 0x000a) { | ||
| const listLen = buf.readUInt16BE(pos); | ||
| for (let i = 0; i < listLen; i += 2) { | ||
| groups.push(buf.readUInt16BE(pos + 2 + i)); | ||
| } | ||
| } | ||
| pos += extLen; | ||
| } | ||
| resolve(groups); | ||
| }); | ||
| }); | ||
| server.on('error', reject); | ||
| server.listen(0, '127.0.0.1', () => { | ||
| const addr = server.address() as net.AddressInfo; | ||
| const client = tls.connect({ | ||
| port: addr.port, | ||
| host: '127.0.0.1', | ||
| servername: 'localhost', | ||
| }); | ||
| client.on('error', () => {}); | ||
| }); | ||
| }); | ||
|
|
||
| assert.ok( | ||
| supportedGroups.includes(0x0202), | ||
| `Expected ClientHello supported_groups (${supportedGroups.map(g => '0x' + g.toString(16)).join(', ')}) to include 0x0202 (MLKEM1024)`, | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
[Generated] Adds unit tests for ensurePqcCurvesConfigured() verifying MLKEM1024 configuration when tls.DEFAULT_ECDH_CURVE === 'auto', preservation of custom curves, fallback to 'auto' on unsupported runtimes, and wire-level advertisement of 0x0202 (MLKEM1024) in the TLS ClientHello supported_groups extension.
…ng curve and harden ClientHello test
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces automatic configuration of Post-Quantum Cryptography (PQC) curves (specifically MLKEM1024) for TLS connections in grpc.ts when supported by the runtime, along with comprehensive unit tests to verify the fallback logic and ClientHello advertisement. The feedback suggests a minor simplification in the curve selection loop to remove a redundant fallback assignment to 'auto' in the catch block.
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | ||
| try { | ||
| tls.createSecureContext({ecdhCurve: candidate}); | ||
| tls.DEFAULT_ECDH_CURVE = candidate; | ||
| return; | ||
| } catch { | ||
| tls.DEFAULT_ECDH_CURVE = 'auto'; | ||
| } | ||
| } |
There was a problem hiding this comment.
In the fallback loop, if a candidate fails, tls.DEFAULT_ECDH_CURVE is set to 'auto'. However, since the function already guards against tls.DEFAULT_ECDH_CURVE !== 'auto' at the beginning, it is guaranteed to be 'auto' initially. We can simplify the loop by removing the redundant assignment in the catch block, which avoids unnecessary mutations of the global configuration.
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | |
| try { | |
| tls.createSecureContext({ecdhCurve: candidate}); | |
| tls.DEFAULT_ECDH_CURVE = candidate; | |
| return; | |
| } catch { | |
| tls.DEFAULT_ECDH_CURVE = 'auto'; | |
| } | |
| } | |
| for (const candidate of PQC_ECDH_CURVE_CANDIDATES) { | |
| try { | |
| tls.createSecureContext({ecdhCurve: candidate}); | |
| tls.DEFAULT_ECDH_CURVE = candidate; | |
| return; | |
| } catch { | |
| // Fall back to the next candidate | |
| } | |
| } |
Description
This change enables post-quantum ML-KEM-1024 key exchange support for secure connections.
Impact
Google Cloud Node.js clients can negotiate pure ML-KEM-1024 TLS 1.3 connections.
Stack
feat/gax-pqc-mlkem1024-part-1mainsrc/grpc.tsensurePqcCurvesConfigured)feat/gax-pqc-mlkem1024-part-2feat/gax-pqc-mlkem1024-part-1GrpcClientconstructor PQC curve check)feat/gax-pqc-mlkem1024-part-3feat/gax-pqc-mlkem1024-part-2src/index.tsexport & TLS context verification)feat/gax-pqc-mlkem1024-part-3-style-maintenancefeat/gax-pqc-mlkem1024-part-3[Style Maintenance]PRChanges
ensurePqcCurvesConfigured()incore/packages/gax/src/grpc.tsto appendMLKEM1024(IANA0x0202) totls.DEFAULT_ECDH_CURVEwhen set to'auto'.'DEFAULT:MLKEM1024'for OpenSSL 3.5+ and'X25519MLKEM768:MLKEM1024:X25519:P-256:P-384'for BoringSSL/Bun) viatls.createSecureContext({ecdhCurve})with safe fallback to'auto'.tls.DEFAULT_ECDH_CURVEvalues when not'auto'.core/packages/gax/test/unit/grpc.tsverifyingtls.DEFAULT_ECDH_CURVEconfiguration, custom curve preservation, fallback behavior, and0x0202advertisement in TLSClientHellosupported_groups.Testing
tls.DEFAULT_ECDH_CURVE === 'auto'on Node.js >= 22.20 (OpenSSL 3.5+),ClientHellosupported_groupsomits0x0202(MLKEM1024) and handshakes withMLKEM1024-only servers fail (no suitable key share); runpnpm --dir core/packages/gax run compile && curl -fsSL https://gist.githubusercontent.com/danieljbruce/3880337b200084e1da370a616e9b211c/raw/repro-pqc-mlkem1024.mjs | node --input-type=module -(see repro-pqc-mlkem1024.mjs Gist).node bin/run-test.cjs --no-c8 core/packages/gax/build/test/unit/grpc.js) and Bun (JS_RUNTIME=bun MOCHA_PARALLEL=false bun --bun bin/run-test.cjs --no-c8 core/packages/gax/build/test/unit/grpc.js).node ./bin/linter.mjs --strict.Alternatives
'auto'curve list was rejected because OpenSSL 3.5'sDEFAULTgroup list only advertisesX25519MLKEM768(0x11ec) and omitsMLKEM1024(0x0202).ecdhCurveonly on individual HTTPS agents was rejected because@grpc/grpc-jscredentials.createSsl()callstls.createSecureContext()with noecdhCurveargument, which inheritstls.DEFAULT_ECDH_CURVE.