Files
node/test/parallel/test-dtls-psk.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

469 lines
15 KiB
JavaScript

// Flags: --experimental-dtls --no-warnings
// Test: pre-shared keys (RFC 4279).
//
// PSK is the usual way DTLS is deployed to constrained devices, which often
// have no certificate at all. RFC 7252 makes TLS_PSK_WITH_AES_128_CCM_8
// mandatory to implement for CoAP.
import { hasCrypto, mustCall, mustNotCall, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const dtls = await import('node:dtls');
const { connect, listen } = dtls;
const KEY = Buffer.from('0123456789abcdef');
const OTHER = Buffer.from('fedcba9876543210');
const HOST = '127.0.0.1';
const PSK_CIPHER = 'PSK-AES128-CCM8@SECLEVEL=0';
const readKey = (name) => fixtures.readKey(name).toString();
function open(endpoint, options) {
return connect(HOST, endpoint.address.port, options);
}
// A map on the server, an identity on the client. No certificate anywhere.
{
const endpoint = listen(mustCall(), {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await client.opened;
// A PSK suite must actually have been negotiated -- the default cipher
// list excludes PSK, so this also pins the implicit enabling below.
assert.match(client.cipher.standardName, /_PSK_/);
assert.strictEqual(client.authorized, true);
await client.close();
await endpoint.close();
}
// A callback on either side, for keys that are looked up or derived rather
// than known up front.
{
const endpoint = listen(() => {}, {
psk: mustCall((identity) => {
assert.strictEqual(identity, 'device-42');
return KEY;
}),
host: HOST, port: 0,
});
const client = open(endpoint, {
psk: mustCall(() => ({ identity: 'device-42', key: KEY })),
});
await client.opened;
assert.match(client.cipher.standardName, /_PSK_/);
await client.close();
await endpoint.close();
}
// The map answers first; the callback is only reached on a miss. This is what
// keeps a map-only configuration from running JavaScript inside the
// handshake at all.
{
const endpoint = listen(() => {}, {
psk: { 'in-map': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'in-map', key: KEY } });
await client.opened;
await client.close();
await endpoint.close();
}
// An identity the server does not know fails closed rather than negotiating
// with an empty key.
{
const endpoint = listen(() => {}, {
psk: { 'known': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'unknown', key: KEY } });
await assert.rejects(client.opened, { name: 'Error' });
await endpoint.close();
}
// The right identity with the wrong key does not fail -- it stalls.
//
// The identity only names the key, so the handshake proceeds and the two
// sides derive different secrets. The first record that fails authentication
// is then silently discarded rather than answered with an alert, because
// DTLS discards invalid records instead of replying to them (RFC 6347
// section 4.1.2.1). Neither peer is told anything, and both retransmit.
//
// Asserted rather than fixed: it is the protocol's behaviour, not this
// implementation's. What ends it is the handshake timeout, which is left at
// its default here and covered by test-dtls-handshake-timeout.mjs; the short
// wait below is only to observe the stall, not to wait one out.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: OTHER } });
const stalled = Symbol('stalled');
const outcome = await Promise.race([
client.opened.then(() => 'opened', () => 'rejected'),
setTimeout(200, stalled),
]);
assert.strictEqual(outcome, stalled);
client.destroy();
await endpoint.close();
}
// A callback returning nothing is a refusal, not an error.
{
const endpoint = listen(() => {}, {
psk: mustCall(() => undefined), host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'anyone', key: KEY } });
await assert.rejects(client.opened, { name: 'Error' });
await endpoint.close();
}
// The cookie exchange still runs, so PSK is not a way around the address
// validation the endpoint's DoS protection depends on.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const before = Number(endpoint.stats.packetsReceived);
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await client.opened;
const received = Number(endpoint.stats.packetsReceived) - before;
assert.ok(received >= 2,
`PSK handshake cost the server ${received} packets; fewer than ` +
'2 means the cookie exchange was skipped');
await client.close();
await endpoint.close();
}
// An explicit cipher list wins, including the suite CoAP requires.
//
// TLS_PSK_WITH_AES_128_CCM_8 authenticates with a 64-bit tag, which OpenSSL
// refuses at security level 1 and above -- and Node's default is above it.
// Asking for it therefore needs @SECLEVEL=0, and asking without one does not
// report anything: the handshake stalls, because a DTLS peer with nothing to
// select discards rather than replies. Anyone implementing CoAP will meet
// this, so it is pinned here.
{
const ciphers = 'PSK-AES128-CCM8@SECLEVEL=0';
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, ciphers, host: HOST, port: 0,
});
const client = open(endpoint, {
psk: { identity: 'device-42', key: KEY }, ciphers,
});
await client.opened;
assert.strictEqual(client.cipher.standardName,
'TLS_PSK_WITH_AES_128_CCM_8');
await client.close();
await endpoint.close();
}
// Without an explicit list a forward-secret suite is chosen. Plain PSK
// derives its keys from the shared secret alone, so anyone who later learns
// the key can decrypt recorded traffic.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await client.opened;
assert.match(client.cipher.standardName, /_(EC)?DHE_PSK_/);
await client.close();
await endpoint.close();
}
// A certificate still works alongside PSK, because DEFAULT stays on the end
// of the implicit cipher list.
{
const endpoint = listen(() => {}, {
cert: readKey('agent1-cert.pem'),
key: readKey('agent1-key.pem'),
psk: { 'device-42': KEY },
host: HOST, port: 0,
});
const psk = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await psk.opened;
assert.match(psk.cipher.standardName, /_PSK_/);
await psk.close();
const cert = open(endpoint, { rejectUnauthorized: false });
await cert.opened;
assert.doesNotMatch(cert.cipher.standardName, /_PSK_/);
await cert.close();
await endpoint.close();
}
// The identity hint is a server-side courtesy (RFC 4279 section 5.2) and
// reaches the client's callback.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY },
pskIdentityHint: 'example.com',
host: HOST, port: 0,
});
const client = open(endpoint, {
psk: mustCall((hint) => {
assert.strictEqual(hint, 'example.com');
return { identity: 'device-42', key: KEY };
}),
});
await client.opened;
await client.close();
await endpoint.close();
}
// A callback that throws fails that handshake and reports the error on the
// session. It must not reach the process as an uncaughtException: one bad
// callback should not take a server down.
{
process.on('uncaughtException', mustNotCall());
const thrown = new Error('from the psk callback');
const seen = Promise.withResolvers();
const endpoint = listen((session) => {
session.onerror = (err) => seen.resolve(err);
}, {
psk: () => { throw thrown; },
host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await assert.rejects(client.opened, { name: 'Error' });
// The user's own error, not something wrapped around it.
assert.strictEqual(await seen.promise, thrown);
await endpoint.close();
process.removeAllListeners('uncaughtException');
}
// Validation.
{
const port = 0;
// A server map must have entries, and they must be non-empty buffers.
assert.throws(() => listen(() => {}, { psk: {}, host: HOST, port }),
{ code: 'ERR_INVALID_ARG_VALUE' });
assert.throws(() => listen(() => {}, {
psk: { 'a': Buffer.alloc(0) }, host: HOST, port,
}), { code: 'ERR_INVALID_ARG_VALUE' });
for (const value of ['string', 42, null]) {
assert.throws(() => listen(() => {}, {
psk: { 'a': value }, host: HOST, port,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
// A client needs both halves.
assert.throws(() => connect(HOST, 1234, { psk: { key: KEY } }),
{ code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => connect(HOST, 1234, { psk: { identity: 'a' } }),
{ code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => connect(HOST, 1234, {
psk: { identity: 'a', key: Buffer.alloc(0) },
}), { code: 'ERR_INVALID_ARG_VALUE' });
// The hint is a server-side notion.
assert.throws(() => connect(HOST, 1234, {
psk: { identity: 'a', key: KEY }, pskIdentityHint: 'nope',
}), { code: 'ERR_INVALID_ARG_VALUE' });
for (const value of [42, 'string', null]) {
assert.throws(() => listen(() => {}, { psk: value, host: HOST, port }),
{ code: /^ERR_INVALID_ARG_(TYPE|VALUE)$/ });
}
}
// A PSK-only server needs no certificate, but one without either is still an
// error rather than a server that can never complete a handshake.
{
assert.throws(() => listen(() => {}, { host: HOST, port: 0 }),
{ code: 'ERR_MISSING_ARGS' });
}
// A client callback may return an object whose properties are accessors, or a
// Proxy. Reading identity and key therefore runs user code that can throw,
// inside the binding, inside the handshake.
//
// That exception used to be caught and then dropped: the binding returned
// without reporting it, so the caller saw an opaque SSL error instead of what
// it threw, and the TryCatch was left to unwind with the exception still
// caught on the way back into OpenSSL.
{
const server = listen(() => {}, {
host: HOST, port: 0, psk: { 'id-1': KEY },
});
for (const build of [
() => ({ get identity() { throw new Error('identity getter threw'); },
key: KEY }),
() => ({ identity: 'id-1',
get key() { throw new Error('key getter threw'); } }),
() => new Proxy({ identity: 'id-1', key: KEY }, {
get() { throw new Error('proxy trap threw'); },
}),
]) {
const expected = (() => {
try {
const o = build();
return (o.identity, o.key, undefined);
} catch (e) {
return e.message;
}
})();
const client = connect(HOST, server.address.port, { psk: build });
const seen = Promise.withResolvers();
client.onerror = (error) => seen.resolve(error);
const error = await seen.promise;
// The caller's own error, not "error:0A0000DF:SSL routines".
assert.strictEqual(error.message, expected);
await assert.rejects(client.opened, { message: expected });
}
await server.close();
}
// Pre-shared keys keep working on a server that also serves SNI identities.
//
// The PSK callbacks have no argument of their own, so they recover their
// context from the SSL's ex_data. That used to be read through
// SSL_get_SSL_CTX(), which SNI reassigns: selecting any identity -- including
// the '*' fallback, so this needed no servername at all -- pointed the lookup
// at a context with no PSK configuration and every PSK handshake failed.
{
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const identity = { cert, key };
const server = listen(() => {}, {
host: HOST,
port: 0,
cert,
key,
psk: { 'id-1': KEY },
sni: { 'a.example': identity, '*': identity },
});
for (const servername of [undefined, 'a.example', 'unmatched.example']) {
const client = connect(HOST, server.address.port, {
psk: { identity: 'id-1', key: KEY },
servername,
});
await client.opened;
await client.close();
}
await server.close();
}
// Which identity SNI selected does not change which keys are accepted. The
// callbacks are installed on the connection by SSL_new(), before any name is
// known, and SSL_set_SSL_CTX() does not replace them -- so they stay the
// endpoint's, and a PSK configured on an SNI identity is never consulted.
{
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const other = Buffer.from('fedcba9876543210');
const server = listen(() => {}, {
host: HOST,
port: 0,
cert,
key,
psk: { 'endpoint-id': KEY },
sni: { 'a.example': { cert, key, psk: { 'sni-id': other } } },
});
const accepted = connect(HOST, server.address.port, {
psk: { identity: 'endpoint-id', key: KEY },
servername: 'a.example',
});
await accepted.opened;
await accepted.close();
const refused = connect(HOST, server.address.port, {
psk: { identity: 'sni-id', key: other },
servername: 'a.example',
});
await assert.rejects(refused.opened, { name: 'Error' });
await server.close();
}
// A psk callback that gives back something unusable says so.
//
// Returning 0 is how OpenSSL is told there is no PSK, and it was also what
// every one of these took, so a mistake in the callback arrived as
// "error:0A0000DF:SSL routines::psk identity not found" -- the same string
// for all of them, naming nothing the caller had done.
{
const server = listen(() => {}, {
psk: () => ({ key: KEY }),
pskIdentityHint: 'hint',
host: HOST,
port: 0,
ciphers: PSK_CIPHER,
});
const cases = [
[() => 42, /must return an object/],
[() => ({ key: KEY }), /string 'identity'/],
[() => ({ identity: 7, key: KEY }), /string 'identity'/],
[() => ({ identity: 'a', key: 'not a view' }), /string 'identity'/],
[() => ({ identity: 'x'.repeat(9000), key: KEY }), /identity is too long/],
[() => ({ identity: 'a', key: Buffer.alloc(9000) }), /key is too long/],
];
for (const [psk, expected] of cases) {
const session = connect(HOST, server.address.port, {
psk,
ciphers: PSK_CIPHER,
handshakeTimeout: 5000,
});
const error = await session.opened.then(() => null, (e) => e);
assert.ok(error, 'the handshake should not have completed');
assert.match(error.message, expected);
// Not the opaque OpenSSL failure these all used to produce.
assert.doesNotMatch(error.message, /psk identity not found/);
session.destroy();
}
await server.close();
}