current commit
260916.1 · lib,src: apply multiple updates to dtls implementation
'use strict';

// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests
// are still being developed.
/* c8 ignore start */

const {
  ArrayFrom,
  ArrayIsArray,
  ArrayPrototypeJoin,
  FunctionPrototypeBind,
  ObjectKeys,
  PromisePrototypeThen,
  PromiseWithResolvers,
  SafeSet,
  StringPrototypeSlice,
  SymbolAsyncDispose,
} = primordials;

const {
  getOptionValue,
} = require('internal/options');

// DTLS requires that Node.js be compiled with crypto support.
if (!process.features.dtls || !getOptionValue('--experimental-dtls')) {
  return;
}

const {
  codes: {
    ERR_ILLEGAL_CONSTRUCTOR,
    ERR_INVALID_ARG_TYPE,
    ERR_INVALID_ARG_VALUE,
    ERR_INVALID_STATE,
    ERR_MISSING_ARGS,
    ERR_OUT_OF_RANGE,
  },
} = require('internal/errors');

const {
  validateBoolean,
  validateBuffer,
  validateFunction,
  validateObject,
  validateString,
  validateInteger,
  validateUint32,
} = require('internal/validators');

const {
  Buffer,
} = require('buffer');

const {
  isIP,
} = require('internal/net');

const {
  isArrayBufferView,
} = require('internal/util/types');

const {
  InternalX509Certificate,
} = require('internal/crypto/x509');

const {
  DTLSEndpointState,
  DTLSSessionState,
} = require('internal/dtls/state');

const {
  DTLSEndpointStats,
  DTLSSessionStats,
} = require('internal/dtls/stats');

const {
  kHandle,
  kIsServer,
  kBind,
  kDoConnect,
  kDoListen,
  kOwner,
  kPrivateConstructor,
  kFinishClose,
  kOwnsEndpoint,
  kRemoveSession,
  kSNIContexts,
  kSessionHandshake,
  kSessionMessage,
  kSessionError,
  kSessionClose,
  kSessionKeylog,
} = require('internal/dtls/symbols');

const {
  DTLSContext: DTLSContext_,
  DTLSEndpoint: DTLSEndpoint_,
  SSL_VERIFY_NONE_VALUE,
  SSL_VERIFY_PEER_VALUE,
  SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE,
} = internalBinding('dtls');

const kEmptyObject = { __proto__: null };

// Upper bound on exported keying material. RFC 5705 sets no limit, but every
// real exporter wants tens of bytes -- DTLS-SRTP's is 60.
const kMaxKeyingMaterialLength = 65536;

// Matches node:tls, which derives the default from the process invocation so
// that two differently-launched servers do not share resumable sessions.
let sessionIdContextCache;
function defaultSessionIdContext() {
  if (sessionIdContextCache === undefined) {
    const { createHash } = require('crypto');
    sessionIdContextCache = StringPrototypeSlice(
      createHash('sha1')
        .update(ArrayPrototypeJoin(process.argv, ' '))
        .digest('hex'),
      0, 32);
  }
  return sessionIdContextCache;
}

// Walks a pre-encoded ALPN protocol list: a sequence of one length byte
// followed by that many bytes, exactly filling the buffer. RFC 7301 gives
// ProtocolName a 1..255 length, so a zero byte is malformed rather than an
// empty entry.
function validateALPNWireFormat(buf) {
  let offset = 0;
  while (offset < buf.length) {
    const len = buf[offset];
    if (len === 0) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.alpn', buf,
        `contains a zero-length protocol name at offset ${offset}`);
    }
    if (offset + 1 + len > buf.length) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.alpn', buf,
        `protocol name at offset ${offset} runs past the end of the buffer`);
    }
    offset += 1 + len;
  }
}

const kDefaultMaxSessions = 10000;
const kDefaultMaxSessionsPerHost = 1000;

// ============================================================================
// DTLSSession -- represents a single DTLS peer association
// ============================================================================

// Assigned from the static blocks below. These reach private fields that are
// deliberately not public API, for tests run with --expose-internals.
let getDTLSSessionState;
let getDTLSEndpointState;
let getDTLSEndpointSessions;

class DTLSSession {
  #handle;
  #endpoint;
  #state;
  #stats;
  #pendingOpen;
  #pendingClose;
  #openSettled = false;
  #peerX509Certificate;
  // The identity this connection verifies against; undefined on the server.
  // Used to bind a resumable session to the host it was authenticated for.
  #verifyHost;
  #onmessage;
  #onerror;
  #onhandshake;
  #onkeylog;
  #ownsEndpoint = false;

  constructor(privateSymbol, handle, endpoint, verifyHost) {
    if (privateSymbol !== kPrivateConstructor) {
      throw new ERR_ILLEGAL_CONSTRUCTOR();
    }

    this.#handle = handle;
    this.#handle[kOwner] = this;
    this.#endpoint = endpoint;
    this.#verifyHost = verifyHost;
    this.#state = new DTLSSessionState(
      kPrivateConstructor, handle.getState());
    this.#stats = new DTLSSessionStats(
      kPrivateConstructor, handle.getStats());
    this.#pendingOpen = PromiseWithResolvers();
    this.#pendingClose = PromiseWithResolvers();
    // opened/closed may reject (handshake error, destroy(error)). Attach a
    // no-op rejection handler so a caller that uses the callback API and never
    // awaits them does not trigger an unhandled rejection; an explicit
    // await/then/catch on opened/closed still observes the rejection.
    PromisePrototypeThen(this.#pendingOpen.promise, undefined, () => {});
    PromisePrototypeThen(this.#pendingClose.promise, undefined, () => {});
  }

  // --- Callback setters ---

  set onmessage(fn) {
    if (fn !== undefined && fn !== null) {
      validateFunction(fn, 'onmessage');
      this.#onmessage = FunctionPrototypeBind(fn, this);
      this.#state.hasMessageListener = true;
    } else {
      this.#onmessage = undefined;
      this.#state.hasMessageListener = false;
    }
  }

  get onmessage() { return this.#onmessage; }

  set onerror(fn) {
    if (fn !== undefined && fn !== null) {
      validateFunction(fn, 'onerror');
      this.#onerror = FunctionPrototypeBind(fn, this);
    } else {
      this.#onerror = undefined;
    }
  }

  get onerror() { return this.#onerror; }

  set onhandshake(fn) {
    if (fn !== undefined && fn !== null) {
      validateFunction(fn, 'onhandshake');
      this.#onhandshake = FunctionPrototypeBind(fn, this);
    } else {
      this.#onhandshake = undefined;
    }
  }

  get onhandshake() { return this.#onhandshake; }

  set onkeylog(fn) {
    if (fn !== undefined && fn !== null) {
      validateFunction(fn, 'onkeylog');
      this.#onkeylog = FunctionPrototypeBind(fn, this);
      // Tells C++ it is worth turning key material into JS strings. Without a
      // listener the secrets never leave OpenSSL.
      this.#state.hasKeylogListener = true;
    } else {
      this.#onkeylog = undefined;
      this.#state.hasKeylogListener = false;
    }
  }

  get onkeylog() { return this.#onkeylog; }

  // --- Send data ---

  send(data) {
    if (this.#handle === null) {
      throw new ERR_INVALID_STATE('Session is destroyed');
    }
    if (typeof data === 'string') {
      data = Buffer.from(data);
    }
    // Any view over bytes. A Uint8Array is the obvious thing to send and was
    // refused, while exportKeyingMaterial() next to it took one.
    if (!isArrayBufferView(data)) {
      throw new ERR_INVALID_ARG_TYPE(
        'data', ['string', 'Buffer', 'TypedArray', 'DataView'], data);
    }
    return this.#handle.send(data);
  }

  // --- Lifecycle ---

  // Settle `opened` when the session goes away before the handshake finished.
  // Without this, close() and destroy() settled only `closed`, and anything
  // awaiting `opened` waited forever. A handshake that already completed --
  // or already failed -- has settled it, and must not be overwritten.
  #settleOpenOnTeardown(error) {
    if (this.#openSettled) return;
    this.#openSettled = true;
    this.#pendingOpen.reject(
      error ??
        new ERR_INVALID_STATE(
          'Session was closed before the handshake completed'));
  }

  close() {
    if (this.#handle === null) return this.closed;
    const handle = this.#handle;
    this.#handle = null;
    handle.close();
    this.#settleOpenOnTeardown();
    return this.closed;
  }

  destroy(error) {
    if (this.#handle === null) return;
    const handle = this.#handle;
    this.#handle = null;
    handle.destroy();
    this.#stats[kFinishClose]();
    this.#settleOpenOnTeardown(error);
    if (error) {
      this.#pendingClose.reject(error);
    } else {
      this.#pendingClose.resolve();
    }
  }

  get opened() { return this.#pendingOpen.promise; }
  get closed() { return this.#pendingClose.promise; }

  // --- Properties ---

  get remoteAddress() {
    if (this.#handle === null) return undefined;
    return this.#handle.getRemoteAddress();
  }

  get protocol() {
    if (this.#handle === null) return undefined;
    return this.#handle.getProtocol();
  }

  get cipher() {
    if (this.#handle === null) return undefined;
    return this.#handle.getCipher();
  }

  get peerCertificate() {
    if (this.#handle === null) return undefined;
    return this.#handle.getPeerCertificate();
  }

  get session() {
    // Server sessions are not resumable by this API: a server does not hold
    // an identity to bind the blob to, and it is the client that carries a
    // session between connections.
    if (this.#handle === null || this.#verifyHost === undefined) {
      return undefined;
    }
    const session = this.#handle.getSession();
    if (session === undefined) return undefined;
    return wrapSession(Buffer.from(session.buffer,
                                   session.byteOffset,
                                   session.byteLength),
                       this.#verifyHost);
  }

  get reused() {
    if (this.#handle === null) return false;
    return this.#handle.wasReused();
  }

  get peerX509Certificate() {
    if (this.#peerX509Certificate !== undefined) {
      return this.#peerX509Certificate;
    }
    if (this.#handle === null) return undefined;

    const cert = this.#handle.getPeerX509Certificate();
    if (!cert) {
      return undefined;
    }

    // The binding hands back the raw handle; the public shape comes from
    // wrapping it, as node:tls does at the same point.
    this.#peerX509Certificate = new InternalX509Certificate(cert);
    return this.#peerX509Certificate;
  }

  get alpnProtocol() {
    if (this.#handle === null) return undefined;
    return this.#handle.getALPNProtocol();
  }

  get srtpProfile() {
    if (this.#handle === null) return undefined;
    return this.#handle.getSRTPProfile();
  }

  get servername() {
    if (this.#handle === null) return undefined;
    return this.#handle.getServername();
  }

  /**
   * The short X509 verification error code for the peer's certificate chain,
   * e.g. `'CERT_HAS_EXPIRED'` or `'UNABLE_TO_GET_ISSUER_CERT'`, or `undefined`
   * if the chain verified. A peer that presented no certificate at all reports
   * `'UNABLE_TO_GET_ISSUER_CERT'` rather than verifying.
   *
   * Only meaningful once the handshake has completed.
   * @type {string|undefined}
   */
  get authorizationError() {
    if (this.#handle === null) return undefined;
    return this.#handle.getVerifyError();
  }

  /**
   * Whether the peer presented a certificate chain that verified against the
   * configured CAs. Always false before the handshake completes.
   * @type {boolean}
   */
  get authorized() {
    if (this.#handle === null) return false;
    // Before the handshake the binding reports HANDSHAKE_INCOMPLETE rather
    // than undefined, so this reads false: undefined is "no fault found",
    // which would otherwise read as authorized.
    return this.#handle.getVerifyError() === undefined;
  }

  get stats() { return this.#stats; }

  /**
   * Whether the session has been destroyed.
   * @returns {boolean}
   */
  get destroyed() { return this.#state.destroyed; }

  static {
    getDTLSSessionState = function(session) {
      return session.#state;
    };
  }
  get endpoint() { return this.#endpoint; }

  exportKeyingMaterial(length, label, context) {
    if (this.#handle === null) {
      throw new ERR_INVALID_STATE('Session is destroyed');
    }
    validateUint32(length, 'length', true);
    if (length > kMaxKeyingMaterialLength) {
      throw new ERR_OUT_OF_RANGE(
        'length', `<= ${kMaxKeyingMaterialLength}`, length);
    }
    validateString(label, 'label');
    if (context !== undefined) {
      validateBuffer(context, 'context');
    }
    return this.#handle.exportKeyingMaterial(length, label, context);
  }

  // --- Internal callbacks (called from C++ via endpoint dispatch) ---

  [kSessionHandshake](protocol) {
    this.#openSettled = true;
    this.#pendingOpen.resolve({ protocol });
    if (this.#onhandshake) {
      this.#onhandshake(protocol);
    }
  }

  [kSessionMessage](data) {
    if (this.#onmessage) {
      this.#onmessage(data);
    }
  }

  [kSessionError](message) {
    // The binding reports OpenSSL failures as a string, but hands back the
    // exception itself when one was thrown by a callback it invoked during
    // the handshake. Wrapping that would bury the user's own error, its type
    // and its stack inside a message.
    const error = typeof message === 'string' ?
      new ERR_INVALID_STATE(message) : message;
    if (this.#onerror) {
      this.#onerror(error);
    }
    this.#openSettled = true;
    this.#pendingOpen.reject(error);

    // The session has failed and cannot continue. Tear it down so it does not
    // linger in the endpoint's table, and -- for a client session that owns
    // its internal endpoint -- close the endpoint too so the event loop can
    // drain. destroy() removes the session from the C++ table first, so the
    // endpoint.close() below won't try to re-close it. Reentrant destroy from
    // within the error emit is safe: Cycle()/the timer hold a strong ref.
    const endpoint = this.#endpoint;
    const ownsEndpoint = this.#ownsEndpoint;
    this.destroy();
    if (endpoint) {
      endpoint[kRemoveSession](this);
      if (ownsEndpoint) {
        endpoint.close();
      }
    }
  }

  [kSessionClose]() {
    // A peer that closes mid-handshake settles `closed` here; `opened` has to
    // be settled too or it hangs.
    this.#settleOpenOnTeardown();
    // Stats stop tracking anything once the session is gone. Told so, they
    // keep the last values and report isConnected false; not told, they went
    // on claiming to be live and returning numbers that had stopped moving.
    this.#stats[kFinishClose]();
    this.#pendingClose.resolve();
    this.#handle = null;
    // Remove from the endpoint's JS-side session set.
    if (this.#endpoint) {
      this.#endpoint[kRemoveSession](this);
    }
    // If this session owns its endpoint (client-side connect()),
    // close the endpoint too so the process can exit.
    if (this.#ownsEndpoint && this.#endpoint) {
      this.#endpoint.close();
    }
  }

  get [kOwnsEndpoint]() { return this.#ownsEndpoint; }
  set [kOwnsEndpoint](val) { this.#ownsEndpoint = val; }

  [kSessionKeylog](line) {
    if (this.#onkeylog) {
      this.#onkeylog(line);
    }
  }

  async [SymbolAsyncDispose]() {
    await this.close();
  }
}

// ============================================================================
// DTLSEndpoint -- manages a UDP socket and routes datagrams to sessions
// ============================================================================

class DTLSEndpoint {
  #handle;
  #state;
  #stats;
  #sessions = new SafeSet();
  #pendingClose;
  #onsession;
  #onerror;

  constructor(options = kEmptyObject) {
    validateObject(options, 'options');
    this.#handle = new DTLSEndpoint_();
    this.#handle[kOwner] = this;
    this.#state = new DTLSEndpointState(
      kPrivateConstructor, this.#handle.getState());
    this.#stats = new DTLSEndpointStats(
      kPrivateConstructor, this.#handle.getStats());
    this.#pendingClose = PromiseWithResolvers();
    // See DTLSSession: keep an unobserved closed rejection from surfacing as an
    // unhandled rejection.
    PromisePrototypeThen(this.#pendingClose.promise, undefined, () => {});

    if (options.mtu !== undefined) {
      validateInteger(options.mtu, 'options.mtu', 256, 65535);
      this.#handle.setMTU(options.mtu);
    }

    // Socket options are applied when the handle binds, so they are read here
    // and held rather than passed to bind().
    if (options.ipv6Only !== undefined) {
      validateBoolean(options.ipv6Only, 'options.ipv6Only');
    }
    if (options.reusePort !== undefined) {
      validateBoolean(options.reusePort, 'options.reusePort');
    }
    // TTL is 1-255; the buffer sizes have no meaningful ceiling here, since
    // the kernel clamps to its own maximum and reports what it settled on.
    if (options.udpReceiveBufferSize !== undefined) {
      validateInteger(options.udpReceiveBufferSize,
                      'options.udpReceiveBufferSize', 1, 0xffffffff);
    }
    if (options.udpSendBufferSize !== undefined) {
      validateInteger(options.udpSendBufferSize,
                      'options.udpSendBufferSize', 1, 0xffffffff);
    }
    if (options.udpTTL !== undefined) {
      validateInteger(options.udpTTL, 'options.udpTTL', 1, 255);
    }
    this.#handle.setSocketOptions(options.ipv6Only === true,
                                  options.reusePort === true,
                                  options.udpReceiveBufferSize ?? 0,
                                  options.udpSendBufferSize ?? 0,
                                  options.udpTTL ?? 0);
    if (options.handshakeTimeout !== undefined) {
      validateInteger(
        options.handshakeTimeout, 'options.handshakeTimeout', 0);
      this.#handle.setHandshakeTimeout(options.handshakeTimeout);
    }

    // Bounds on accepted server sessions. Zero disables the cap.
    const maxSessions = options.maxSessions ?? kDefaultMaxSessions;
    const maxSessionsPerHost =
      options.maxSessionsPerHost ?? kDefaultMaxSessionsPerHost;
    validateInteger(maxSessions, 'options.maxSessions', 0, 0xffffffff);
    validateInteger(
      maxSessionsPerHost, 'options.maxSessionsPerHost', 0, 0xffffffff);
    this.#handle.setSessionLimits(maxSessions, maxSessionsPerHost);

    // Set up the callback dispatch from C++ to JS.
    this.#handle.setCallbacks({
      __proto__: null,
      onEndpointClose: () => this.#onEndpointClose(),
      onEndpointError: (msg) => this.#onEndpointError(msg),
      onSessionNew: (handle) => this.#onSessionNew(handle),
      onSessionClose: function() {
        this[kOwner]?.[kSessionClose]();
      },
      onSessionError: function(msg) {
        this[kOwner]?.[kSessionError](msg);
      },
      onSessionHandshake: function(protocol) {
        this[kOwner]?.[kSessionHandshake](protocol);
      },
      onSessionMessage: function(data) {
        this[kOwner]?.[kSessionMessage](data);
      },
      onSessionKeylog: function(line) {
        this[kOwner]?.[kSessionKeylog](line);
      },
    });
  }

  // --- Server mode ---

  [kDoListen](callback, context) {
    validateFunction(callback, 'callback');
    this.#onsession = callback;
    this.#handle.listen(context);
    return this;
  }

  // --- Client mode ---

  [kDoConnect](context, host, port, servername, session, ownsEndpoint = false) {
    // Resolve SNI and the expected peer identity here so that every caller of
    // the endpoint API -- not only the top-level dtls.connect() -- gets safe
    // defaults. The identity is always bound to the requested servername (or,
    // failing that, the host). OpenSSL only *enforces* it when the context is
    // in a verifying mode, so binding it is a no-op for non-verifying
    // (rejectUnauthorized: false) contexts.
    //
    // These are applied to the client SSL inside the binding, before the
    // handshake's ClientHello is emitted; they cannot be set afterwards.
    // The identity verified against is derived from servername below. A
    // non-string reached the binding's IsString() test, failed it, and became
    // a null verify host -- so SSL_set1_host() was never called and hostname
    // verification was silently skipped while chain verification still ran.
    // Validated here rather than in connect() because every path that
    // establishes a session comes through this method.
    if (servername !== undefined) {
      validateString(servername, 'options.servername');
    }

    let sni = servername !== undefined ? (servername || undefined) : host;
    if (sni !== undefined && isIP(sni) !== 0) {
      sni = undefined;  // SNI is never sent for IP literals (matching TLS).
    }
    const verifyHost = servername || host;
    const verifyIsIp = isIP(verifyHost) !== 0;

    // Checked against the identity this connection will actually verify,
    // before any of it reaches OpenSSL.
    const resume = session !== undefined ?
      unwrapSession(session, verifyHost) : undefined;

    // The binding's connect() builds the session but does not start its
    // handshake, because the first flight can fail synchronously and until
    // the wrapper below exists there is nowhere for that failure to go: the
    // callback dispatch reaches the session through its handle. Whether the
    // session owns the endpoint has to be settled before the handshake too,
    // since the error path closes the endpoint only for a session that owns
    // it -- set afterwards, a first-flight failure left the endpoint open and
    // the event loop with nothing to drain it.
    const sessionHandle = this.#handle.connect(
      context, host, port, sni, verifyHost, verifyIsIp, resume);
    const newSession = new DTLSSession(
      kPrivateConstructor, sessionHandle, this, verifyHost);
    this.#sessions.add(newSession);
    newSession[kOwnsEndpoint] = ownsEndpoint;
    sessionHandle.start();
    return newSession;
  }

  // --- Bind ---

  [kBind](host, port) {
    this.#handle.bind(host, port);
    return this;
  }

  // --- Lifecycle ---

  close() {
    if (this.#handle === null) return this.closed;
    const handle = this.#handle;
    this.#handle = null;
    handle.close();
    return this.closed;
  }

  destroy(error) {
    if (this.#handle === null) return;
    const handle = this.#handle;
    this.#handle = null;

    // The binding destroys the sessions too, and emits nothing for them, so
    // anything awaiting one would wait for a session that no longer exists.
    // Torn down here instead, with the endpoint's error, before the endpoint
    // goes. Copied because destroying a session can remove it from the set.
    for (const session of ArrayFrom(this.#sessions)) {
      session.destroy(error);
    }
    this.#sessions.clear();

    handle.destroy();
    this.#stats[kFinishClose]();
    if (error) {
      this.#pendingClose.reject(error);
    } else {
      this.#pendingClose.resolve();
    }
  }

  get closed() { return this.#pendingClose.promise; }

  // --- Properties ---

  get address() {
    if (this.#handle === null) return undefined;
    return this.#handle.getAddress();
  }

  get stats() { return this.#stats; }

  /**
   * Whether the endpoint has been destroyed.
   * @returns {boolean}
   */
  get destroyed() { return this.#state.destroyed; }

  [kRemoveSession](session) {
    this.#sessions.delete(session);
  }

  static {
    getDTLSEndpointState = function(endpoint) {
      return endpoint.#state;
    };

    getDTLSEndpointSessions = function(endpoint) {
      return endpoint.#sessions;
    };
  }

  get onerror() { return this.#onerror; }
  set onerror(fn) {
    if (fn !== undefined && fn !== null) {
      validateFunction(fn, 'onerror');
      this.#onerror = fn;
    } else {
      this.#onerror = undefined;
    }
  }

  set busy(val) {
    this.#state.busy = !!val;
  }

  get busy() {
    return this.#state.busy;
  }

  // --- Internal callbacks ---

  #onEndpointClose() {
    this.#sessions.clear();
    this.#stats[kFinishClose]();
    this.#pendingClose.resolve();
    this.#handle = null;
  }

  #onEndpointError(message) {
    if (this.#onerror) {
      this.#onerror(new ERR_INVALID_STATE(message));
    }
  }

  #onSessionNew(handle) {
    const session = new DTLSSession(kPrivateConstructor, handle, this);
    this.#sessions.add(session);
    if (this.#onsession) {
      this.#onsession(session);
    }
  }

  async [SymbolAsyncDispose]() {
    await this.close();
  }
}

// ============================================================================
// Public API functions
// ============================================================================

function createContext(options = kEmptyObject) {
  validateObject(options, 'options');

  const isServer = options.isServer === true;

  // Server-only options were each treated differently on a client context:
  // sni threw, sessionIdContext was ignored, ticketKeys was applied anyway,
  // and requestCert was validated and then ignored. Rejected uniformly, so
  // that naming one on a client is a mistake the caller hears about.
  if (!isServer) {
    for (const name of kServerOnlyContextOptions) {
      if (options[name] !== undefined) {
        throw new ERR_INVALID_ARG_VALUE(
          `options.${name}`, options[name],
          'is only meaningful for a server context');
      }
    }
  }

  const context = new DTLSContext_(isServer);

  // Pre-shared keys (RFC 4279).
  if (options.psk !== undefined) {
    applyPSK(context, options.psk, isServer, options.pskIdentityHint);
  } else if (options.pskIdentityHint !== undefined) {
    // The hint names which key a client should pick. Without one to name it
    // was dropped, and the handshake then failed for want of a PSK with no
    // mention of the option that had been set.
    throw new ERR_INVALID_ARG_VALUE(
      'options.pskIdentityHint', options.pskIdentityHint,
      'is only meaningful together with options.psk');
  }

  // Session ticket keys. Without them OpenSSL generates a random key per
  // context, so tickets stop working the moment the process restarts or a
  // second endpoint is involved -- each has its own key and rejects the
  // other's tickets, falling back to a full handshake.
  if (options.ticketKeys !== undefined) {
    validateBuffer(options.ticketKeys, 'options.ticketKeys');
    // The required length is OpenSSL's, so the binding checks it and reports
    // what it wanted rather than this repeating a number that is not ours.
    context.setTicketKeys(options.ticketKeys);
  }

  // Certificate
  if (options.cert !== undefined) {
    let cert = options.cert;
    if (Buffer.isBuffer(cert)) cert = cert.toString();
    validateString(cert, 'options.cert');
    context.setCert(cert);
  }

  // Private key. The passphrase is a string only, matching node:tls, even
  // though key and cert also accept a Buffer.
  const { passphrase } = options;
  if (passphrase !== undefined && passphrase !== null) {
    validateString(passphrase, 'options.passphrase');
  }

  if (options.key !== undefined) {
    let key = options.key;
    if (Buffer.isBuffer(key)) key = key.toString();
    validateString(key, 'options.key');
    context.setKey(key, passphrase);
  }

  // CA certificates: if custom CAs are provided, use only those.
  // Otherwise load system default CAs. This matches Node.js TLS behavior.
  if (options.ca !== undefined) {
    const cas = ArrayIsArray(options.ca) ? options.ca : [options.ca];
    for (let ca of cas) {
      if (Buffer.isBuffer(ca)) ca = ca.toString();
      validateString(ca, 'options.ca');
      context.addCACert(ca);
    }
  } else {
    context.loadDefaultCAs();
  }

  // Ciphers
  if (options.ciphers !== undefined) {
    validateString(options.ciphers, 'options.ciphers');
    context.setCiphers(options.ciphers);
  } else if (options.psk !== undefined) {
    // The default cipher list excludes PSK, so a psk option with no ciphers
    // would negotiate nothing and fail with a bare handshake_failure. Turn
    // the suites on rather than leave that trap, but only when the caller has
    // not stated a preference of their own.
    //
    // Forward-secret suites come first: plain PSK derives its keys from the
    // shared secret alone, so anyone who later learns that key can decrypt
    // recorded traffic. DEFAULT stays on the end so a context that also has a
    // certificate can still use it.
    context.setCiphers(isServer ? kPSKCiphersServer : kPSKCiphersClient);
  }

  // ECDH curve (default: 'auto' = OpenSSL default selection)
  const ecdhCurve = options.ecdhCurve || 'auto';
  validateString(ecdhCurve, 'options.ecdhCurve');
  context.setECDHCurve(ecdhCurve);

  // ALPN protocols
  if (options.alpn !== undefined) {
    let protocols = options.alpn;
    if (ArrayIsArray(protocols)) {
      // Convert string array to wire-format buffer. Each entry is a single
      // length byte followed by that many bytes, so a name outside 1..255
      // cannot be represented: 256 would truncate to a zero length byte and
      // desynchronise the whole list.
      const bufs = [];
      for (let i = 0; i < protocols.length; i++) {
        validateString(protocols[i], `options.alpn[${i}]`);
        const buf = Buffer.from(protocols[i]);
        if (buf.length < 1 || buf.length > 255) {
          throw new ERR_OUT_OF_RANGE(
            `options.alpn[${i}] byte length`, '>= 1 && <= 255', buf.length);
        }
        bufs.push(Buffer.from([buf.length]), buf);
      }
      protocols = Buffer.concat(bufs);
    } else if (Buffer.isBuffer(protocols)) {
      // A pre-encoded list. Walk it so a malformed one is rejected here
      // rather than as an opaque handshake failure later.
      validateALPNWireFormat(protocols);
    }
    if (!Buffer.isBuffer(protocols)) {
      throw new ERR_INVALID_ARG_TYPE(
        'options.alpn', ['string[]', 'Buffer'], protocols);
    }
    context.setALPN(protocols);
  }

  // SRTP profiles
  if (options.srtp !== undefined) {
    validateString(options.srtp, 'options.srtp');
    context.setSRTP(options.srtp);
  }

  // Verification mode.
  //
  // A server only asks for a client certificate when requestCert says to;
  // rejectUnauthorized then decides whether a missing or unverifiable one is
  // fatal. A client always verifies the server, and rejectUnauthorized decides
  // whether a failure is fatal.
  //
  // The permissive verify callback is installed in exactly one case: a server
  // that asked for a certificate but disabled rejection. That is the only
  // combination where OpenSSL would otherwise abort a handshake the
  // application has said it wants to judge for itself. Elsewhere OpenSSL keeps
  // enforcing, so a rejected peer gets a proper alert rather than a silently
  // dropped session, and no state is held for a peer that is about to be
  // turned away.
  //
  // A client under SSL_VERIFY_NONE still has its chain verified -- OpenSSL
  // simply does not abort -- so session.authorized stays accurate there
  // without any callback.
  // Compared against false rather than coerced, so anything else has to be a
  // boolean: rejectUnauthorized: 0 read as "do not verify" and meant the
  // opposite, and isServer: 'yes' produced a client.
  if (options.rejectUnauthorized !== undefined) {
    validateBoolean(options.rejectUnauthorized, 'options.rejectUnauthorized');
  }
  const rejectUnauthorized = options.rejectUnauthorized !== false;
  if (options.requestCert !== undefined) {
    validateBoolean(options.requestCert, 'options.requestCert');
  }

  if (isServer) {
    // Scope cached sessions to this server, the way node:tls does. Without an
    // id context OpenSSL will resume a session against a server configured
    // differently from the one that issued it.
    let sessionIdContext = options.sessionIdContext;
    if (sessionIdContext === undefined) {
      sessionIdContext = defaultSessionIdContext();
    } else {
      validateString(sessionIdContext, 'options.sessionIdContext');
      // SSL_MAX_SID_CTX_LENGTH. Checked here so the caller is told the limit
      // rather than getting a bare "failed to set session id context".
      if (Buffer.byteLength(sessionIdContext) > 32) {
        throw new ERR_OUT_OF_RANGE(
          'options.sessionIdContext', '<= 32 bytes',
          `${Buffer.byteLength(sessionIdContext)} bytes`);
      }
    }
    context.setSessionIdContext(sessionIdContext);

    if (options.requestCert) {
      context.setVerifyMode(
        rejectUnauthorized ?
          (SSL_VERIFY_PEER_VALUE | SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE) :
          SSL_VERIFY_PEER_VALUE,
        !rejectUnauthorized);
    } else {
      // Nothing is requested, so there is nothing to reject.
      context.setVerifyMode(SSL_VERIFY_NONE_VALUE, false);
    }
  } else {
    context.setVerifyMode(
      rejectUnauthorized ? SSL_VERIFY_PEER_VALUE : SSL_VERIFY_NONE_VALUE,
      false);
  }

  // Server Name Indication. Part of the context because it is a set of
  // identities to serve, and because applying it to a context built
  // elsewhere would reconfigure that context for everything else using it.
  if (options.sni !== undefined) {
    applySNIContexts(context, options.sni);
  }

  return context;
}

// Cipher list used when psk is given and ciphers is not.
//
// DEFAULT has to come first: OpenSSL only honours it as the opening
// cipherstring, and trailing it contributes nothing -- a list ending in
// DEFAULT silently has no certificate suites at all, so a server holding both
// a certificate and a PSK could only ever use the PSK.
//
// kECDHEPSK and kDHEPSK are the forward-secret PSK key exchanges and kPSK is
// plain PSK. OpenSSL orders what follows by cipher strength, so a
// forward-secret suite comes ahead of a plain one of equal strength, though
// not ahead of a stronger cipher without it. kRSAPSK is dropped: it needs a
// certificate and gains no forward secrecy for the extra round trip.
// A server may serve certificate clients and PSK clients on the same port,
// so it keeps both. A client is one or the other, and one that configured a
// pre-shared key and no CA wants the key: leaving the certificate suites on
// lets a server pick one, and the handshake then fails verifying a
// certificate the caller never meant to rely on.
const kPSKCiphersServer = 'DEFAULT:kECDHEPSK:kDHEPSK:kPSK:!kRSAPSK';
const kPSKCiphersClient = 'kECDHEPSK:kDHEPSK:kPSK:!kRSAPSK';

/**
 * Configure pre-shared keys on a context.
 * @param {object} context Native context.
 * @param {object|Function} psk Identity map, {identity, key}, or a callback.
 * @param {boolean} isServer Which side is being configured.
 * @param {string} [hint] Identity hint to advertise (server only).
 */
function applyPSK(context, psk, isServer, hint) {
  if (hint !== undefined) {
    if (!isServer) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.pskIdentityHint', hint, 'is only meaningful for a server');
    }
    validateString(hint, 'options.pskIdentityHint');
  }

  let entries;
  let identity;
  let key;
  let callback;

  if (typeof psk === 'function') {
    callback = psk;
  } else {
    validateObject(psk, 'options.psk');

    if (isServer) {
      // A map of the identities this server will accept.
      entries = [];
      for (const name of ObjectKeys(psk)) {
        const value = psk[name];
        validateBuffer(value, `options.psk['${name}']`);
        if (value.byteLength === 0) {
          throw new ERR_INVALID_ARG_VALUE(
            `options.psk['${name}']`, value, 'must not be empty');
        }
        entries.push(name, value);
      }
      if (entries.length === 0) {
        throw new ERR_INVALID_ARG_VALUE(
          'options.psk', psk, 'must have at least one entry');
      }
    } else {
      // The single identity this client presents.
      ({ identity, key } = psk);
      validateString(identity, 'options.psk.identity');
      validateBuffer(key, 'options.psk.key');
      if (key.byteLength === 0) {
        throw new ERR_INVALID_ARG_VALUE(
          'options.psk.key', key, 'must not be empty');
      }
    }
  }

  context.setPSK(entries, hint, identity, key, callback);
}

// Resumable sessions are bound to the identity they were authenticated for.
//
// A resumed handshake does not re-send or re-verify the peer's certificate:
// it inherits the authenticated identity of the original session. Replaying a
// session against a different host therefore skips verification while looking
// like it succeeded. node:tls was fixed for this in CVE-2026-48934; the same
// hazard exists here the moment a session blob can be carried between
// connections, so the blob is prefixed with the identity it belongs to and
// refused anywhere else.
//
// The identity used is exactly the verifyHost handed to the binding for
// SSL_set1_host(), so the value that gets bound and the value that gets
// verified cannot drift apart.
const kSessionPrefix = Buffer.from('\0nodejs:dtls:session:1\0');

function wrapSession(session, verifyHost) {
  const identity = Buffer.from(verifyHost, 'utf8');
  const length = Buffer.allocUnsafe(2);
  length.writeUInt16BE(identity.length, 0);
  return Buffer.concat([kSessionPrefix, length, identity, session]);
}

function unwrapSession(session, verifyHost) {
  // Not being a Buffer is a type error. It was folded in with the checks
  // below and reported as ERR_INVALID_ARG_VALUE, so passing a string got the
  // code that means "the right type, the wrong contents".
  if (!Buffer.isBuffer(session)) {
    throw new ERR_INVALID_ARG_TYPE('options.session', 'Buffer', session);
  }
  if (session.length < kSessionPrefix.length + 2 ||
      Buffer.compare(session.subarray(0, kSessionPrefix.length),
                     kSessionPrefix) !== 0) {
    // Not one of ours. It may be a session blob from somewhere else, but
    // nothing here can say what identity it was authenticated for, so it
    // cannot safely be resumed.
    throw new ERR_INVALID_ARG_VALUE(
      'options.session', session,
      'is not a session produced by session.session');
  }

  const start = kSessionPrefix.length;
  const identityLength = session.readUInt16BE(start);
  const identityStart = start + 2;
  const identityEnd = identityStart + identityLength;
  if (session.length < identityEnd) {
    throw new ERR_INVALID_ARG_VALUE(
      'options.session', session, 'is truncated');
  }

  const identity = session.toString('utf8', identityStart, identityEnd);
  if (identity !== verifyHost) {
    throw new ERR_INVALID_ARG_VALUE(
      'options.session', session,
      `was authenticated for '${identity}' and cannot be reused for ` +
      `'${verifyHost}'`);
  }

  return session.subarray(identityEnd);
}

// The options createContext() consumes. Anything else -- host, port, mtu,
// servername and so on -- belongs to the endpoint or the individual
// connection, not the context, and stays legal alongside a secureContext.
// Options that only a server context can act on. A client naming one has
// misunderstood the option, so it is refused rather than quietly dropped.
const kServerOnlyContextOptions = [
  'pskIdentityHint', 'requestCert', 'sessionIdContext', 'sni', 'ticketKeys',
];

const kContextOptions = [
  'alpn', 'ca', 'cert', 'ciphers', 'ecdhCurve', 'key', 'passphrase',
  'psk', 'pskIdentityHint', 'rejectUnauthorized', 'requestCert',
  'sessionIdContext', 'sni', 'srtp', 'ticketKeys',
];

/**
 * An opaque, reusable bundle of credentials and TLS settings. Build it with
 * createSecureContext() and hand it to listen() or connect().
 */
class DTLSSecureContext {
  constructor(privateSymbol, handle, isServer) {
    if (privateSymbol !== kPrivateConstructor) {
      throw new ERR_ILLEGAL_CONSTRUCTOR();
    }
    this[kHandle] = handle;
    this[kIsServer] = isServer;
  }

  get isServer() {
    return this[kIsServer];
  }
}

/**
 * Create a reusable secure context.
 * @param {object} [options] Credentials and TLS settings.
 * @param {boolean} [options.isServer] Build a server context. A context can
 *   only be used by the side it was built for.
 * @returns {DTLSSecureContext}
 */
function createSecureContext(options = kEmptyObject) {
  validateObject(options, 'options');
  if (options.isServer !== undefined) {
    validateBoolean(options.isServer, 'options.isServer');
  }
  const isServer = options.isServer === true;
  return new DTLSSecureContext(
    kPrivateConstructor, createContext(options), isServer);
}

/**
 * Build the SNI map and install it on a server context.
 *
 * Values may be a DTLSSecureContext or a plain options bag; the bag is just
 * shorthand for createSecureContext({ ...bag, isServer: true }).
 * @param {object} context The endpoint's own native context.
 * @param {object} sni Host name to context or options.
 */
/**
 * Normalize what an sni map entry or callback produced into a native
 * context handle.
 * @param {object|DTLSSecureContext} value The configured or returned value.
 * @param {string} name Argument name for errors.
 * @returns {object} Native context handle.
 */
function resolveSNIValue(value, name) {
  if (value instanceof DTLSSecureContext) {
    if (!value.isServer) {
      throw new ERR_INVALID_ARG_VALUE(
        name, value,
        'was created for a client; SNI contexts need isServer: true');
    }
    return value[kHandle];
  }
  validateObject(value, name);
  return createSecureContext({ ...value, isServer: true })[kHandle];
}

function applySNIContexts(context, sni) {
  if (typeof sni === 'function') {
    const select = (servername) => {
      const value = sni(servername);
      if (value === undefined || value === null) return undefined;
      return resolveSNIValue(value, 'the value returned by options.sni');
    };
    context.setSNIContexts([], select);
    return;
  }

  validateObject(sni, 'options.sni');

  const hostnames = ObjectKeys(sni);
  if (hostnames.length === 0) {
    throw new ERR_INVALID_ARG_VALUE(
      'options.sni', sni, 'must have at least one entry');
  }

  // Flattened to [host, ctx, host, ctx, ...]: a plain array crosses into C++
  // without the binding having to walk a JS object.
  const flat = [];
  for (const hostname of hostnames) {
    flat.push(hostname,
              resolveSNIValue(sni[hostname], `options.sni['${hostname}']`));
  }

  context[kSNIContexts] = flat;
  context.setSNIContexts(flat, undefined);
}

/**
 * Resolve the context for listen()/connect(): either the caller's reusable
 * one or a fresh one built from the inline options.
 * @param {object} options The full option bag.
 * @param {boolean} isServer Which side is asking.
 * @param {object} extra Fields to force when building inline.
 * @returns {object} The native context handle.
 */
function resolveSecureContext(options, isServer, extra) {
  const { secureContext } = options;
  if (secureContext === undefined) {
    return createContext({ ...options, ...extra, isServer });
  }

  if (!(secureContext instanceof DTLSSecureContext)) {
    throw new ERR_INVALID_ARG_TYPE(
      'options.secureContext', 'DTLSSecureContext', secureContext);
  }

  // A context built for one side cannot serve the other: isServer selects
  // the OpenSSL method when the context is created, long before this point.
  if (secureContext.isServer !== isServer) {
    throw new ERR_INVALID_ARG_VALUE(
      'options.secureContext', secureContext,
      isServer ?
        'was created for a client; listen() needs isServer: true' :
        'was created for a server; connect() needs isServer: false');
  }

  // Credentials baked into the context cannot be overridden per call, so
  // say that rather than silently ignoring them.
  for (const name of kContextOptions) {
    if (options[name] !== undefined) {
      throw new ERR_INVALID_ARG_VALUE(
        `options.${name}`, options[name],
        'cannot be combined with options.secureContext; ' +
        'set it on the context instead');
    }
  }

  return secureContext[kHandle];
}

/**
 * Start a DTLS server.
 * @param {Function} onsession Callback invoked for each new DTLS session.
 * @param {object} options Server configuration.
 * @param {string|Buffer} options.cert Server certificate (PEM).
 * @param {string|Buffer} options.key Server private key (PEM).
 * @param {string|Buffer|Array} [options.ca] CA certificates (PEM).
 * @param {string} [options.host] Bind address.
 * @param {number} options.port Bind port.
 * @param {number} [options.mtu] MTU for DTLS records.
 * @param {boolean} [options.ipv6Only] Serve IPv6 only on an IPv6 endpoint.
 *   Defaults to false, so binding '::' also accepts IPv4 peers.
 * @param {boolean} [options.reusePort] Set SO_REUSEPORT.
 * @param {number} [options.udpReceiveBufferSize] Socket receive buffer bytes.
 * @param {number} [options.udpSendBufferSize] Socket send buffer bytes.
 * @param {number} [options.udpTTL] Outgoing datagram TTL, 1-255.
 * @param {string[]} [options.alpn] ALPN protocol list.
 * @param {string} [options.srtp] SRTP profile string.
 * @param {boolean} [options.requestCert] Request client certificates.
 * @param {number} [options.maxSessions] Maximum concurrent sessions.
 * @param {number} [options.maxSessionsPerHost] Maximum concurrent sessions
 *   from any single source address.
 * @returns {DTLSEndpoint}
 */
function listen(onsession, options = kEmptyObject) {
  validateFunction(onsession, 'onsession');
  validateObject(options, 'options');

  // A server needs a certificate and key, but they may already be in a
  // secureContext, in which case supplying them here is an error anyway.
  if (options.secureContext === undefined && options.psk === undefined) {
    // A PSK-only server authenticates with the shared key and has no
    // certificate to present.
    if (options.cert === undefined) {
      throw new ERR_MISSING_ARGS('options.cert');
    }
    if (options.key === undefined) {
      throw new ERR_MISSING_ARGS('options.key');
    }
  }
  if (options.port === undefined) {
    throw new ERR_MISSING_ARGS('options.port');
  }

  const host = options.host || '0.0.0.0';
  const port = options.port;

  validateString(host, 'options.host');
  validateInteger(port, 'options.port', 0, 65535);

  const context = resolveSecureContext(options, true, kEmptyObject);

  const endpoint = new DTLSEndpoint({
    mtu: options.mtu,
    handshakeTimeout: options.handshakeTimeout,
    ipv6Only: options.ipv6Only,
    reusePort: options.reusePort,
    udpReceiveBufferSize: options.udpReceiveBufferSize,
    udpSendBufferSize: options.udpSendBufferSize,
    udpTTL: options.udpTTL,
    maxSessions: options.maxSessions,
    maxSessionsPerHost: options.maxSessionsPerHost,
  });

  endpoint[kBind](host, port);
  endpoint[kDoListen](onsession, context);

  return endpoint;
}

/**
 * Connect to a DTLS server.
 * @param {string} host Remote host.
 * @param {number} port Remote port.
 * @param {object} [options] Client configuration.
 * @param {string|Buffer|Array} [options.ca] CA certificates (PEM).
 * @param {string|Buffer} [options.cert] Client certificate (PEM).
 * @param {string|Buffer} [options.key] Client private key (PEM).
 * @param {boolean} [options.rejectUnauthorized] When true (default), verify
 *   the server certificate against the trusted CAs and check its identity
 *   against servername (or host); aborts the handshake on failure.
 * @param {string} [options.servername] Server name for the SNI extension and
 *   the identity checked during certificate verification. Defaults to host;
 *   set to '' to disable SNI. Never sent for IP address literals.
 * @param {string} [options.bindHost] Local bind address.
 * @param {number} [options.bindPort] Local bind port (0 = ephemeral).
 * @param {number} [options.mtu] MTU for DTLS records.
 * @param {boolean} [options.ipv6Only] Serve IPv6 only on an IPv6 endpoint.
 *   Defaults to false, so binding '::' also accepts IPv4 peers.
 * @param {boolean} [options.reusePort] Set SO_REUSEPORT.
 * @param {number} [options.udpReceiveBufferSize] Socket receive buffer bytes.
 * @param {number} [options.udpSendBufferSize] Socket send buffer bytes.
 * @param {number} [options.udpTTL] Outgoing datagram TTL, 1-255.
 * @param {string[]} [options.alpn] ALPN protocol list.
 * @param {string} [options.srtp] SRTP profile string.
 * @returns {DTLSSession}
 */
function connect(host, port, options = kEmptyObject) {
  validateString(host, 'host');
  validateInteger(port, 'port', 0, 65535);
  validateObject(options, 'options');

  if (options.bindHost !== undefined) {
    validateString(options.bindHost, 'options.bindHost');
  }
  if (options.bindPort !== undefined) {
    validateInteger(options.bindPort, 'options.bindPort', 0, 65535);
  }
  const bindHost =
    options.bindHost || (isIP(host) === 6 ? '::' : '0.0.0.0');
  const bindPort = options.bindPort || 0;

  if (options.rejectUnauthorized !== undefined) {
    validateBoolean(options.rejectUnauthorized, 'options.rejectUnauthorized');
  }
  const context = resolveSecureContext(options, false, {
    rejectUnauthorized: options.rejectUnauthorized !== false,
  });

  const endpoint = new DTLSEndpoint({
    mtu: options.mtu,
    handshakeTimeout: options.handshakeTimeout,
    ipv6Only: options.ipv6Only,
    reusePort: options.reusePort,
    udpReceiveBufferSize: options.udpReceiveBufferSize,
    udpSendBufferSize: options.udpSendBufferSize,
    udpTTL: options.udpTTL,
  });

  endpoint[kBind](bindHost, bindPort);

  // SNI and peer-identity verification are resolved inside
  // DTLSEndpoint.connect(), which defaults both to the host argument.
  // The identity is enforced whenever the context verifies, i.e.
  // unless rejectUnauthorized is false.
  // The session owns the endpoint, so the endpoint gets closed automatically
  // when the session closes, allowing process exit. Passed in rather than set
  // on the way back out because the handshake starts inside, and a handshake
  // that fails immediately has to find the flag already set.
  return endpoint[kDoConnect](
    context, host, port, options.servername, options.session, true);
}

module.exports = {
  connect,
  listen,
  createContext,
  createSecureContext,
  DTLSEndpoint,
  DTLSSecureContext,
  DTLSSession,
  // Not public API. Exposed for tests run with --expose-internals.
  getDTLSSessionState,
  getDTLSEndpointState,
  getDTLSEndpointSessions,
};

/* c8 ignore stop */