⚡ Sangsakala Proxy 🏠 Home
Skip to content

feat(gax): add ensurePqcCurvesConfigured helper for MLKEM1024 TLS negotiation - #9798

Draft
danieljbruce wants to merge 2 commits into
mainfrom
feat/gax-pqc-mlkem1024-part-1
Draft

danieljbruce wants to merge 2 commits into
mainfrom
feat/gax-pqc-mlkem1024-part-1

Conversation

@danieljbruce

@danieljbruce danieljbruce commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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

# PR Branch Base Role
1 (this PR) #9798 feat/gax-pqc-mlkem1024-part-1 main Code PR 1 of 3 (src/grpc.ts ensurePqcCurvesConfigured)
2 #9799 feat/gax-pqc-mlkem1024-part-2 feat/gax-pqc-mlkem1024-part-1 Code PR 2 of 3 (GrpcClient constructor PQC curve check)
3 #9800 feat/gax-pqc-mlkem1024-part-3 feat/gax-pqc-mlkem1024-part-2 Code PR 3 of 3 (src/index.ts export & TLS context verification)
4 #9802 feat/gax-pqc-mlkem1024-part-3-style-maintenance feat/gax-pqc-mlkem1024-part-3 [Style Maintenance] PR

Changes

  • Added ensurePqcCurvesConfigured() in core/packages/gax/src/grpc.ts to append MLKEM1024 (IANA 0x0202) to tls.DEFAULT_ECDH_CURVE when set to 'auto'.
  • Validated curve candidates ('DEFAULT:MLKEM1024' for OpenSSL 3.5+ and 'X25519MLKEM768:MLKEM1024:X25519:P-256:P-384' for BoringSSL/Bun) via tls.createSecureContext({ecdhCurve}) with safe fallback to 'auto'.
  • Preserved caller-customized tls.DEFAULT_ECDH_CURVE values when not 'auto'.
  • Added unit tests in core/packages/gax/test/unit/grpc.ts verifying tls.DEFAULT_ECDH_CURVE configuration, custom curve preservation, fallback behavior, and 0x0202 advertisement in TLS ClientHello supported_groups.

Testing

  • Steps to Reproduce: With default tls.DEFAULT_ECDH_CURVE === 'auto' on Node.js >= 22.20 (OpenSSL 3.5+), ClientHello supported_groups omits 0x0202 (MLKEM1024) and handshakes with MLKEM1024-only servers fail (no suitable key share); run pnpm --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).
  • Verified targeted unit tests on Node.js (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).
  • Verified strict lint and type checking across the stack via node ./bin/linter.mjs --strict.

Alternatives

  • Relying on Node.js default 'auto' curve list was rejected because OpenSSL 3.5's DEFAULT group list only advertises X25519MLKEM768 (0x11ec) and omits MLKEM1024 (0x0202).
  • Setting ecdhCurve only on individual HTTPS agents was rejected because @grpc/grpc-js credentials.createSsl() calls tls.createSecureContext() with no ecdhCurve argument, which inherits tls.DEFAULT_ECDH_CURVE.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +43 to +51
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';
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
}
}

Comment on lines +907 to +945
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', () => {});
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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', () => {});
        });
      });

Comment on lines +32 to +54
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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Generated] Imports ensurePqcCurvesConfigured from ../../src/grpc for direct unit test coverage.

Comment on lines +872 to +952

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)`,
);
});
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@danieljbruce

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +47 to +55
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';
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
}
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant