Skip to content

tls: server getPeerCertificate(true) drops the peer certificate chain since v26.8.0 (regressed by #64677) #65579

Description

@tgies

Version

v26.8.0 and up. v26.7.0 is unaffected. Reproduces on current main.

Platform

Linux 6.18.35.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux

Also reproduced on GitHub Actions ubuntu-latest. OpenSSL is 3.5.7.

Subsystem

tls

What steps will reproduce the bug?

On the server side of a mutual-TLS connection, socket.getPeerCertificate(true) no longer returns the client certificate's issuer chain. The leaf is returned fully populated, but issuerCertificate is undefined, even though the client presented [leaf, intermediate] and the socket is authorized.

Here is a repo script that creates a root -> intermediate -> client chain with openssl, then runs a node:https mTLS handshake and reads the peer certificate on the server:

import https from 'node:https';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// Generate root CA -> intermediate CA -> client leaf and server cert
const dir = mkdtempSync(join(tmpdir(), 'mtls-'));
const f = (n) => join(dir, n);
const openssl = (...a) => execFileSync('openssl', a, { stdio: 'ignore' });
const ec = ['-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes'];

openssl('req', '-x509', ...ec, '-keyout', f('root.key'), '-out', f('root.crt'),
  '-days', '2', '-subj', '/CN=Repro Root CA', '-addext', 'basicConstraints=critical,CA:TRUE');

writeFileSync(f('int.ext'), 'basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n');
openssl('req', ...ec, '-keyout', f('int.key'), '-out', f('int.csr'), '-subj', '/CN=Repro Intermediate CA');
openssl('x509', '-req', '-in', f('int.csr'), '-CA', f('root.crt'), '-CAkey', f('root.key'),
  '-CAcreateserial', '-days', '2', '-extfile', f('int.ext'), '-out', f('int.crt'));

writeFileSync(f('leaf.ext'), 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n');
openssl('req', ...ec, '-keyout', f('leaf.key'), '-out', f('leaf.csr'), '-subj', '/CN=Repro Client');
openssl('x509', '-req', '-in', f('leaf.csr'), '-CA', f('int.crt'), '-CAkey', f('int.key'),
  '-CAcreateserial', '-days', '2', '-extfile', f('leaf.ext'), '-out', f('leaf.crt'));

openssl('req', '-x509', ...ec, '-keyout', f('srv.key'), '-out', f('srv.crt'),
  '-days', '2', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost');

const root = readFileSync(f('root.crt'));
const clientChain = Buffer.concat([readFileSync(f('leaf.crt')), readFileSync(f('int.crt'))]);

const server = https.createServer(
  { key: readFileSync(f('srv.key')), cert: readFileSync(f('srv.crt')), ca: [root],
    requestCert: true, rejectUnauthorized: false },
  (req, res) => {
    const cert = req.socket.getPeerCertificate(true);
    console.log('node', process.version, '| authorized:', req.socket.authorized,
                '| issuerCertificate present:', !!cert.issuerCertificate);
    res.writeHead(200); res.end('x');
  });
server.listen(0, 'localhost', () => {
  const req = https.request(
    { hostname: 'localhost', port: server.address().port,
      key: readFileSync(f('leaf.key')), cert: clientChain, ca: [root], rejectUnauthorized: false },
    (res) => { res.resume(); res.on('end', () => { server.close(); rmSync(dir, { recursive: true, force: true }); }); });
  req.end();
});

Compare output on an affected and an unaffected version:

$ node-v26.7.0 repro.mjs
node v26.7.0 | authorized: true | issuerCertificate present: true
$ node-v26.8.1 repro.mjs
node v26.8.1 | authorized: true | issuerCertificate present: false

How often does it reproduce? Is there a required condition?

Every time on Node >= 26.8.0. Requires requestCert: true, a client certificate signed through at least one intermediate, and reading the chain with getPeerCertificate(true) or getPeerCertificate with includeChain.

What is the expected behavior? Why is that the expected behavior?

getPeerCertificate(true) on the server should return the leaf with an issuerCertificate property linking to the intermediate (and so on up the chain), as it did through v26.7.0 and as documented: "If the full certificate chain was requested, each certificate will include an issuerCertificate property containing an object representing its issuer's certificate."

What do you see instead?

The leaf is returned fully populated (subject, issuer, raw, fingerprints, etc.) but issuerCertificate is undefined. getPeerX509Certificate().issuerCertificate is also absent.

Additional information

I bisected nightly builds and v27.0.0-nightly20260808 (aed4eaf89d) is the last one before the break; v27.0.0-nightly20260809 (0700e749eb) is where it breaks. The only tls/crypto commit in that range is c59cd6b (#64677), "tls: fix authorized state on no-cert TLS1.3 client cert resumption", which added to onServerSocketSecure() in lib/internal/tls/wrap.js.

Calling getPeerX509Certificate() caches the peer certificate as a leaf only, so subsequent getPeerCertificate(true) returns the cached leaf only.

The underlying issue is really a caching interaction that predates #64677, which is that calling getPeerX509Certificate() poisons getPeerCertificate(true). It can be reproduced on the unaffected versios by inserting that call before reading the chain, i.e., insert req.socket.getPeerX509Certificate(); as the first line of the request handler in the repro above. #64677 tripped this by calling getPeerX509Certificate() internally.

There are sort of two issues here AFAICT: the caching interaction where getPeerX509Certificate() mutates the chain for a later getPeerCertificate(true), and #64677 using getPeerX509Certificate() just to test presence of a peer certificate (instead of a check that does not populate/replace the cached peer certificate).

This regression affects any server that inspects the client certificate chain on Node >= 26.8.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions