Skip to content

Commit 428a444

Browse files
committed
tls: fix authorized state on no-cert TLS1.3 client cert resumption
Previously if you used TLS 1.3 and the server requested a client cert, but the client didn't send one, and you used rejectUnauthorized:false the resumed session would report authorized=true. This doesn't match TLS 1.2 behaviour or make any sense, and was purely an artifact of our internal logic for handling TLS 1.3 resumption details. We now correctly report the authorization state and/or error from the original connection in all cases, with a matrix test that fully checks the invariant: authorized state after resume should always match the initial state. Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent fbab458 commit 428a444

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

lib/internal/tls/wrap.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,13 @@ function onServerSocketSecure() {
13211321
if (verifyError) {
13221322
this.authorizationError = verifyError.code;
13231323

1324+
if (this._rejectUnauthorized)
1325+
this.destroy();
1326+
} else if (!this._handle.getPeerX509Certificate()) {
1327+
// Ncrypto reports X509_V_OK for TLS 1.3 resumption without a peer
1328+
// certificate, as it uses PSKs. Require one to authorize the socket.
1329+
this.authorizationError = 'UNABLE_TO_GET_ISSUER_CERT';
1330+
13241331
if (this._rejectUnauthorized)
13251332
this.destroy();
13261333
} else {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
'use strict';
2+
const common = require('../common');
3+
if (!common.hasCrypto)
4+
common.skip('missing crypto');
5+
6+
// Server-side client-certificate authorization must survive TLS session
7+
// resumption. On a resumed handshake the client does not re-send its
8+
// certificate, so the server has to report the same authorization state it
9+
// derived from the original full handshake:
10+
//
11+
// - a trusted certificate stays authorized,
12+
// - an untrusted certificate stays unauthorized with its verification error,
13+
// - a missing certificate stays unauthorized (UNABLE_TO_GET_ISSUER_CERT).
14+
//
15+
// The missing-certificate case is special on TLS 1.3: ncrypto reports X509_V_OK
16+
// for the resumed PSK handshake even though no certificate was presented, so
17+
// the absence has to be detected explicitly (see onServerSocketSecure() in
18+
// lib/internal/tls/wrap.js). The final case checks that such a certificate-less
19+
// resumed session is rejected outright when rejectUnauthorized is set.
20+
21+
const assert = require('assert');
22+
const crypto = require('crypto');
23+
const tls = require('tls');
24+
const fixtures = require('../common/fixtures');
25+
const { once } = require('events');
26+
27+
const ca = fixtures.readKey('ca1-cert.pem');
28+
const serverCert = {
29+
key: fixtures.readKey('agent2-key.pem'),
30+
cert: fixtures.readKey('agent2-cert.pem'),
31+
};
32+
33+
// Client certificate variants, keyed by the peer state they produce.
34+
const CLIENTS = {
35+
trusted: { // Signed by ca1
36+
creds: {
37+
key: fixtures.readKey('agent1-key.pem'),
38+
cert: fixtures.readKey('agent1-cert.pem'),
39+
},
40+
authorized: true,
41+
authorizationError: null,
42+
peerCN: 'agent1',
43+
},
44+
untrusted: { // Signed by ca2, not trusted
45+
creds: {
46+
key: fixtures.readKey('agent3-key.pem'),
47+
cert: fixtures.readKey('agent3-cert.pem'),
48+
},
49+
authorized: false,
50+
authorizationError: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
51+
peerCN: 'agent3',
52+
},
53+
missing: { // No client certificate
54+
creds: {},
55+
authorized: false,
56+
authorizationError: 'UNABLE_TO_GET_ISSUER_CERT',
57+
peerCN: undefined,
58+
},
59+
};
60+
61+
async function handshake(options, captureSession) {
62+
const socket = tls.connect(options);
63+
const sessionPromise = captureSession ?
64+
once(socket, 'session').then(([session]) => session) : null;
65+
66+
socket.resume();
67+
await once(socket, 'secureConnect');
68+
69+
const closePromise = once(socket, 'close');
70+
const session = sessionPromise ? await sessionPromise : undefined;
71+
socket.end();
72+
await closePromise;
73+
return session;
74+
}
75+
76+
// Test a single resumption configuration and expected result:
77+
async function testResumption(version, name) {
78+
const { creds, authorized, authorizationError, peerCN } = CLIENTS[name];
79+
80+
let connections = 0;
81+
const server = tls.createServer({
82+
...serverCert,
83+
ca,
84+
requestCert: true,
85+
rejectUnauthorized: false,
86+
minVersion: version,
87+
maxVersion: version,
88+
}, common.mustCall((socket) => {
89+
// 2nd conn must resume:
90+
const resumed = connections++ === 1;
91+
const where = `${version} ${name} ${resumed ? 'resumed' : 'new'}`;
92+
assert.strictEqual(socket.isSessionReused(), resumed, where);
93+
94+
// Both conns must report same expected auth state:
95+
assert.strictEqual(socket.authorized, authorized, where);
96+
assert.strictEqual(socket.authorizationError, authorizationError, where);
97+
const peer = socket.getPeerCertificate();
98+
if (peerCN === undefined)
99+
assert.deepStrictEqual(peer, {}, where);
100+
else
101+
assert.strictEqual(peer.subject.CN, peerCN, where);
102+
103+
socket.resume();
104+
}, 2));
105+
106+
server.listen(0);
107+
await once(server, 'listening');
108+
109+
const options = {
110+
port: server.address().port,
111+
host: '127.0.0.1',
112+
checkServerIdentity: () => undefined,
113+
rejectUnauthorized: false,
114+
minVersion: version,
115+
maxVersion: version,
116+
...creds,
117+
};
118+
119+
try {
120+
const session = await handshake(options, true);
121+
assert(session);
122+
await handshake({ ...options, session });
123+
} finally {
124+
server.close();
125+
await once(server, 'close');
126+
}
127+
}
128+
129+
// Test the special case of resumption from rejectUnauthorized:false to
130+
// rejectUnauthorized:true, which must be rejected even though the original
131+
// session worked initially.
132+
async function testRejectResumedWithoutCert() {
133+
const options = {
134+
...serverCert,
135+
ca,
136+
requestCert: true,
137+
minVersion: 'TLSv1.3',
138+
maxVersion: 'TLSv1.3',
139+
ticketKeys: crypto.randomBytes(48),
140+
};
141+
const lenient = tls.createServer({ ...options, rejectUnauthorized: false });
142+
lenient.on('secureConnection', common.mustCall((socket) => {
143+
assert.strictEqual(socket.authorized, false);
144+
assert.strictEqual(socket.authorizationError, 'UNABLE_TO_GET_ISSUER_CERT');
145+
socket.resume();
146+
}));
147+
148+
const strict = tls.createServer({ ...options, rejectUnauthorized: true });
149+
strict.on('secureConnection', common.mustNotCall());
150+
151+
const clientOptions = (port) => ({
152+
port,
153+
host: '127.0.0.1',
154+
rejectUnauthorized: false,
155+
checkServerIdentity: () => undefined,
156+
minVersion: 'TLSv1.3',
157+
maxVersion: 'TLSv1.3',
158+
});
159+
160+
lenient.listen(0);
161+
await once(lenient, 'listening');
162+
const session = await handshake(clientOptions(lenient.address().port), true);
163+
assert(session);
164+
lenient.close();
165+
await once(lenient, 'close');
166+
167+
strict.listen(0);
168+
await once(strict, 'listening');
169+
170+
const resumed = tls.connect({ ...clientOptions(strict.address().port), session });
171+
resumed.on('error', () => {}); // May observe the server's reset.
172+
resumed.resume();
173+
174+
// The client completes the resumed handshake (it has the server's Finished)
175+
// before the server's reset can arrive, so this asserts the strict server
176+
// actually resumed rather than falling back to a rejected full handshake.
177+
await once(resumed, 'secureConnect');
178+
assert.strictEqual(resumed.isSessionReused(), true);
179+
180+
// Then the socket is destroyed during 'secure', which surfaces as a reset
181+
// rather than a handshake failure.
182+
const [err] = await once(strict, 'tlsClientError');
183+
assert.strictEqual(err.code, 'ECONNRESET');
184+
185+
resumed.destroy();
186+
strict.close();
187+
await once(strict, 'close');
188+
}
189+
190+
(async function() {
191+
// Run the full matrix of configurations:
192+
for (const version of ['TLSv1.2', 'TLSv1.3'])
193+
for (const name of Object.keys(CLIENTS))
194+
await testResumption(version, name);
195+
196+
// Validate the rejectUnauth:false->true case
197+
await testRejectResumedWithoutCert();
198+
})().then(common.mustCall());

0 commit comments

Comments
 (0)