Files
node/test/parallel/test-dtls-session-resumption.mjs
James M Snell bd2b98f549 lib,src: apply multiple updates to dtls implementation
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opecode
PR-URL: https://github.com/nodejs/node/pull/65511
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
2026-09-16 00:39:35 +00:00

281 lines
9.0 KiB
JavaScript

// Flags: --experimental-dtls --no-warnings
// Test: session resumption -- session.session, the `session` option on
// connect(), session.reused, and ticketKeys.
//
// Resumption matters more here than it does over TCP. A full DTLS handshake's
// Certificate flight is fragmented across several datagrams and any one of
// them being lost costs a retransmission timeout; a resumed handshake skips
// it. Measured on loopback, the server sends 1850 bytes in 4 packets for a
// full handshake against 280 bytes in 3 for a resumed one.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
import { inspect } from 'node:util';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { randomBytes } = await import('node:crypto');
const { connect, createSecureContext, listen } = await import('node:dtls');
const key = (name) => fixtures.readKey(name).toString();
const cert = key('agent1-cert.pem');
const privateKey = key('agent1-key.pem');
const serverOptions = {
cert, key: privateKey, host: '127.0.0.1', port: 0,
};
// A session can be carried from one connection to the next.
{
const endpoint = listen(() => {}, serverOptions);
const first = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await first.opened;
assert.strictEqual(first.reused, false);
const ticket = first.session;
assert.ok(Buffer.isBuffer(ticket));
assert.ok(ticket.length > 0);
await first.close();
const second = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
assert.strictEqual(second.reused, true);
// A resumed connection can itself be resumed from.
assert.ok(Buffer.isBuffer(second.session));
await second.close();
await endpoint.close();
}
// Resumption skips the Certificate flight, which is the point of it.
{
const endpoint = listen(() => {}, serverOptions);
const sent = () => Number(endpoint.stats.bytesSent);
const beforeFull = sent();
const full = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await full.opened;
const fullBytes = sent() - beforeFull;
const ticket = full.session;
await full.close();
const beforeResumed = sent();
const resumed = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: ticket,
});
await resumed.opened;
const resumedBytes = sent() - beforeResumed;
// Read before closing: like authorized, this reports false once the
// handle is gone.
assert.strictEqual(resumed.reused, true);
await resumed.close();
assert.ok(resumedBytes < fullBytes / 2,
`resumed handshake sent ${resumedBytes} bytes, ` +
`full sent ${fullBytes}; expected well under half`);
await endpoint.close();
}
// The cookie exchange still runs for a resumed handshake. Resumption must not
// become a way around the address validation that the endpoint's DoS
// protection depends on.
{
const endpoint = listen(() => {}, serverOptions);
const first = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await first.opened;
const ticket = first.session;
await first.close();
const before = Number(endpoint.stats.packetsReceived);
const second = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
const received = Number(endpoint.stats.packetsReceived) - before;
// At least two: the first ClientHello is answered with a
// HelloVerifyRequest and the client has to send another carrying the
// cookie. One would mean DTLSv1_listen() had been bypassed.
assert.ok(received >= 2,
`resumed handshake cost the server ${received} packets; ` +
'fewer than 2 means the cookie exchange was skipped');
assert.strictEqual(second.reused, true);
await second.close();
await endpoint.close();
}
// A session is bound to the identity it was authenticated for and refused
// anywhere else. A resumed handshake does not re-verify the peer's
// certificate, so replaying a session against another host would skip
// verification while appearing to succeed. This is CVE-2026-48934, fixed in
// node:tls; the same hazard applies to any API that lets a session travel
// between connections.
{
const endpoint = listen(() => {}, serverOptions);
const first = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'host-a',
});
await first.opened;
const ticket = first.session;
await first.close();
// The identity it was issued for: allowed.
const same = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'host-a', session: ticket,
});
await same.opened;
assert.strictEqual(same.reused, true);
await same.close();
// Any other: refused before OpenSSL sees it.
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'host-b', session: ticket,
}), {
code: 'ERR_INVALID_ARG_VALUE',
message: /authenticated for 'host-a'.*cannot be reused for 'host-b'/,
});
// A blob that did not come from session.session cannot be vouched for,
// because nothing records which identity it belongs to.
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: randomBytes(64),
}), { code: 'ERR_INVALID_ARG_VALUE' });
await endpoint.close();
}
// An unusable session falls back to a full handshake rather than failing.
{
const first = listen(() => {}, serverOptions);
const client = connect('127.0.0.1', first.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const ticket = client.session;
await client.close();
await first.close();
// A different endpoint has a different, randomly generated ticket key, so
// it cannot decrypt the ticket.
const second = listen(() => {}, serverOptions);
const stale = connect('127.0.0.1', second.address.port, {
rejectUnauthorized: false, session: ticket,
});
await stale.opened;
assert.strictEqual(stale.reused, false);
await stale.close();
await second.close();
}
// ticketKeys makes tickets portable between endpoints, which is what makes
// them survive a restart or work across a cluster.
{
const ticketKeys = randomBytes(80);
const a = listen(() => {}, { ...serverOptions, ticketKeys });
const b = listen(() => {}, { ...serverOptions, ticketKeys });
const first = connect('127.0.0.1', a.address.port, {
rejectUnauthorized: false,
});
await first.opened;
const ticket = first.session;
await first.close();
const second = connect('127.0.0.1', b.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
assert.strictEqual(second.reused, true);
await second.close();
await a.close();
await b.close();
}
// ticketKeys validation. The length is OpenSSL's rather than ours, so the
// error reports what it asked for.
{
assert.throws(() => createSecureContext({
cert, key: privateKey, isServer: true, ticketKeys: randomBytes(48),
}), { code: 'ERR_INVALID_ARG_VALUE', message: /exactly 80 bytes/ });
for (const value of ['keys', 42, null, {}]) {
assert.throws(() => createSecureContext({
cert, key: privateKey, isServer: true, ticketKeys: value,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
// It belongs to the context, so it cannot be passed alongside one.
const context = createSecureContext({
cert, key: privateKey, isServer: true,
});
assert.throws(() => listen(() => {}, {
secureContext: context, ticketKeys: randomBytes(80),
host: '127.0.0.1', port: 0,
}), { code: 'ERR_INVALID_ARG_VALUE' });
}
// A server session has no identity to bind a blob to, so it offers none.
{
const gotSession = Promise.withResolvers();
const endpoint = listen((s) => gotSession.resolve(s), serverOptions);
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const session = await gotSession.promise;
await session.opened;
assert.strictEqual(session.session, undefined);
assert.strictEqual(session.reused, false);
await client.close();
await endpoint.close();
}
// A session that is not a Buffer is a type error, not a value error.
//
// The Buffer check was folded in with the prefix and length checks and all
// four reported ERR_INVALID_ARG_VALUE, so passing a string got the code that
// means the type was right and the contents were wrong.
{
for (const value of ['a string', 7, {}, null, []]) {
assert.throws(() => connect('127.0.0.1', 4433, { session: value }), {
code: 'ERR_INVALID_ARG_TYPE',
}, `session: ${inspect(value)}`);
}
// A Buffer that is not one of ours is still a value error: the right type,
// contents this cannot resume.
for (const value of [Buffer.alloc(0), Buffer.alloc(40), Buffer.from('xx')]) {
assert.throws(() => connect('127.0.0.1', 4433, { session: value }), {
code: 'ERR_INVALID_ARG_VALUE',
}, `session: ${inspect(value)}`);
}
}