// Copyright 2018-2025 the Deno authors. MIT license. // Copyright Joyent and Node contributors. All rights reserved. MIT license. // TODO(petamoriken): enable prefer-primordials for node polyfills // deno-lint-ignore-file prefer-primordials import { ObjectAssign, StringPrototypeReplace, } from "ext:deno_node/internal/primordials.mjs"; import assert from "ext:deno_node/internal/assert.mjs"; import * as net from "node:net"; import { createSecureContext } from "node:_tls_common"; import { kStreamBaseField } from "ext:deno_node/internal_binding/stream_wrap.ts"; import { connResetException, ERR_TLS_CERT_ALTNAME_INVALID, } from "ext:deno_node/internal/errors.ts"; import { emitWarning } from "node:process"; import { debuglog } from "ext:deno_node/internal/util/debuglog.ts"; import { constants as TCPConstants, TCP, } from "ext:deno_node/internal_binding/tcp_wrap.ts"; import { constants as PipeConstants, Pipe, } from "ext:deno_node/internal_binding/pipe_wrap.ts"; import { EventEmitter } from "node:events"; import { kEmptyObject } from "ext:deno_node/internal/util.mjs"; import { nextTick } from "ext:deno_node/_next_tick.ts"; import { kHandle } from "ext:deno_node/internal/stream_base_commons.ts"; import { isAnyArrayBuffer, isArrayBufferView, } from "ext:deno_node/internal/util/types.ts"; import { startTlsInternal } from "ext:deno_net/02_tls.js"; import { core, internals } from "ext:core/mod.js"; import { op_node_tls_handshake, op_node_tls_start, op_tls_canonicalize_ipv4_address, op_tls_key_null, op_tls_key_static, } from "ext:core/ops"; const kConnectOptions = Symbol("connect-options"); const kIsVerified = Symbol("verified"); const kPendingSession = Symbol("pendingSession"); const kRes = Symbol("res"); const tlsStreamRids = new Uint32Array(2); let debug = debuglog("tls", (fn) => { debug = fn; }); function canonicalizeIP(ip) { return op_tls_canonicalize_ipv4_address(ip); } function onConnectEnd() { // NOTE: This logic is shared with _http_client.js if (!this._hadError) { const options = this[kConnectOptions]; this._hadError = true; const error = connResetException( "Client network socket disconnected " + "before secure TLS connection was " + "established", ); error.path = options.path; error.host = options.host; error.port = options.port; error.localAddress = options.localAddress; this.destroy(error); } } export class TLSSocket extends net.Socket { _start() { this[kHandle].afterConnectTls(); } constructor(socket, opts = kEmptyObject) { const tlsOptions = { ...opts }; const hostname = opts.servername ?? opts.host ?? socket?._host ?? "localhost"; tlsOptions.hostname = hostname; const cert = tlsOptions?.secureContext?.cert; const key = tlsOptions?.secureContext?.key; const hasTlsKey = key != undefined && cert != undefined; const keyPair = hasTlsKey ? op_tls_key_static(cert, key) : op_tls_key_null(); let caCerts = tlsOptions?.secureContext?.ca; if (typeof caCerts === "string") { caCerts = [caCerts]; } else if (isArrayBufferView(caCerts) || isAnyArrayBuffer(caCerts)) { caCerts = [new TextDecoder().decode(caCerts)]; } else if (Array.isArray(caCerts)) { caCerts = caCerts.map((cert) => { if (typeof cert === "string") { return cert; } else if (isArrayBufferView(cert) || isAnyArrayBuffer(cert)) { return new TextDecoder().decode(cert); } return cert; }); } tlsOptions.keyPair = keyPair; tlsOptions.caCerts = caCerts; tlsOptions.alpnProtocols = opts.ALPNProtocols; tlsOptions.rejectUnauthorized = opts.rejectUnauthorized !== false; try { if ( opts.checkServerIdentity && typeof opts.checkServerIdentity == "function" && opts.checkServerIdentity() == undefined ) { // If checkServerIdentity is no-op, we disable hostname verification. tlsOptions.unsafelyDisableHostnameVerification = true; } } catch { /* pass */ } super({ handle: _wrapHandle(tlsOptions, socket), ...opts, manualStart: true, // This prevents premature reading from TLS handle }); if (socket) { this.on("close", () => this._parent?.emit("close")); this._parent = socket; } this._tlsOptions = tlsOptions; this._secureEstablished = false; this._securePending = false; this._newSessionPending = false; this._controlReleased = false; this.secureConnecting = true; this._SNICallback = null; this.servername = null; this.alpnProtocol = null; this.alpnProtocols = tlsOptions.ALPNProtocols; this.authorized = false; this.authorizationError = null; this[kRes] = null; this[kIsVerified] = false; this[kPendingSession] = null; this.ssl = new class { verifyError() { return null; // Never fails, rejectUnauthorized is always true in Deno. } }(); // deno-lint-ignore no-this-alias const tlssock = this; /** Wraps the given socket and adds the tls capability to the underlying * handle */ function _wrapHandle(tlsOptions, socket) { let handle; let wrap; if (socket) { if (socket instanceof net.Socket && socket._handle) { wrap = socket; } else { wrap = new JSStreamSocket(socket); } handle = wrap._handle; } const options = tlsOptions; if (!handle) { handle = options.pipe ? new Pipe(PipeConstants.SOCKET) : new TCP(TCPConstants.SOCKET); } const { promise, resolve } = Promise.withResolvers(); // Set `afterConnectTls` hook. This is called in the `afterConnect` method of net.Socket handle.afterConnectTls = async () => { options.hostname ??= undefined; // coerce to undefined if null, startTls expects hostname to be undefined if (tlssock._needsSockInitWorkaround) { // skips the TLS handshake for @npmcli/agent as it's handled by // onSocket handler of ClientRequest object. tlssock.emit("secure"); tlssock.removeListener("end", onConnectEnd); return; } try { const conn = await startTls( wrap, handle, options, ); try { const hs = await conn.handshake(); if (hs?.alpnProtocol) { tlssock.alpnProtocol = hs.alpnProtocol; } else { tlssock.alpnProtocol = false; } } catch { // Don't interrupt "secure" event to let the first read/write // operation emit the error. } // Assign the TLS connection to the handle and resume reading. handle[kStreamBaseField] = conn; handle.upgrading = false; if (!handle.pauseOnCreate) { handle.readStart(); } resolve(); tlssock.emit("secure"); tlssock.removeListener("end", onConnectEnd); } catch { // TODO(kt3k): Handle this } }; handle.upgrading = promise; handle.verifyError = function () { return null; // Never fails, rejectUnauthorized is always true in Deno. }; // Pretends `handle` is `tls_wrap.wrap(handle, ...)` to make some npm modules happy // An example usage of `_parentWrap` in npm module: // https://github.com/szmarczak/http2-wrapper/blob/51eeaf59ff9344fb192b092241bfda8506983620/source/utils/js-stream-socket.js#L6 handle._parent = handle; handle._parentWrap = socket; return handle; } } _tlsError(err) { this.emit("_tlsError", err); if (this._controlReleased) { return err; } return null; } _releaseControl() { if (this._controlReleased) { return false; } this._controlReleased = true; this.removeListener("error", this._tlsError); return true; } getEphemeralKeyInfo() { return {}; } isSessionReused() { return false; } setSession(_session) { // TODO(kt3k): implement this } setServername(_servername) { // TODO(kt3k): implement this } setMaxSendFragment(_maxSendFragment) { // TODO(littledivy): implement this } getPeerCertificate(detailed = false) { const conn = this[kHandle]?.[kStreamBaseField]; if (conn) return conn[internals.getPeerCertificate](detailed); } getCipher() { return ""; } } class JSStreamSocket { #rid; constructor(stream) { this.stream = stream; } init(options) { op_node_tls_start(options, tlsStreamRids); this.#rid = tlsStreamRids[0]; const channelRid = tlsStreamRids[1]; this.stream.on("data", (data) => { core.write(channelRid, data); }); const buf = new Uint8Array(1024 * 16); (async () => { while (true) { try { const nread = await core.read(channelRid, buf); this.stream.write(buf.slice(0, nread)); } catch { break; } } })(); this.stream.on("close", () => { core.close(this.#rid); core.close(channelRid); }); } handshake() { return op_node_tls_handshake(this.#rid); } read(buf) { return core.read(this.#rid, buf); } write(data) { return core.write(this.#rid, data); } } function startTls(wrap, handle, options) { if (wrap instanceof JSStreamSocket) { options.caCerts ??= []; wrap.init(options); return wrap; } else { return startTlsInternal(handle[kStreamBaseField], options); } } function normalizeConnectArgs(listArgs) { const args = net._normalizeArgs(listArgs); const options = args[0]; const cb = args[1]; // If args[0] was options, then normalize dealt with it. // If args[0] is port, or args[0], args[1] is host, port, we need to // find the options and merge them in, normalize's options has only // the host/port/path args that it knows about, not the tls options. // This means that options.host overrides a host arg. if (listArgs[1] !== null && typeof listArgs[1] === "object") { ObjectAssign(options, listArgs[1]); } else if (listArgs[2] !== null && typeof listArgs[2] === "object") { ObjectAssign(options, listArgs[2]); } return cb ? [options, cb] : [options]; } let ipServernameWarned = false; export function Server(options, listener) { return new ServerImpl(options, listener); } export class ServerImpl extends EventEmitter { listener; #closed = false; #unrefed = false; constructor(options, listener) { super(); this.options = options; if (listener) { this.on("secureConnection", listener); } } unref() { this.#unrefed = true; if (this.listener) { this.listener.unref(); } } ref() { this.#unrefed = false; if (this.listener) { this.listener.ref(); } } listen(port, callback) { const key = this.options.key?.toString(); const cert = this.options.cert?.toString(); // TODO(kt3k): The default host should be "localhost" const hostname = this.options.host ?? "0.0.0.0"; this.listener = Deno.listenTls({ port, hostname, cert, key }); callback?.call(this); this.#listen(this.listener); return this; } async #listen(listener) { if (this.#unrefed) { listener.unref(); return; } while (!this.#closed) { try { // Creates TCP handle and socket directly from Deno.TlsConn. // This works as TLS socket. We don't use TLSSocket class for doing // this because Deno.startTls only supports client side tcp connection. // TODO(@satyarohith): set TLSSocket.alpnProtocol when we use TLSSocket class. const handle = new TCP(TCPConstants.SOCKET, await listener.accept()); const socket = new net.Socket({ handle }); this.emit("secureConnection", socket); } catch (e) { if (e instanceof Deno.errors.BadResource) { this.#closed = true; } // swallow } } } close(cb) { if (this.listener) { this.listener.close(); } cb?.(); nextTick(() => { this.emit("close"); }); return this; } address() { const addr = this.listener.addr; return { port: addr.port, address: addr.hostname, }; } } Server.prototype = ServerImpl.prototype; export function createServer(options, listener) { return new ServerImpl(options, listener); } function onConnectSecure() { this.authorized = true; this.secureConnecting = false; debug("client emit secureConnect. authorized:", this.authorized); this.emit("secureConnect"); this.removeListener("end", onConnectEnd); } export function connect(...args) { args = normalizeConnectArgs(args); let options = args[0]; const cb = args[1]; const allowUnauthorized = getAllowUnauthorized(); options = { rejectUnauthorized: !allowUnauthorized, ciphers: DEFAULT_CIPHERS, checkServerIdentity, minDHSize: 1024, ...options, }; if (!options.keepAlive) { options.singleUse = true; } assert(typeof options.checkServerIdentity === "function"); assert( typeof options.minDHSize === "number", "options.minDHSize is not a number: " + options.minDHSize, ); assert( options.minDHSize > 0, "options.minDHSize is not a positive number: " + options.minDHSize, ); const context = options.secureContext || createSecureContext(options); const tlssock = new TLSSocket(options.socket, { allowHalfOpen: options.allowHalfOpen, pipe: !!options.path, secureContext: context, isServer: false, requestCert: true, rejectUnauthorized: options.rejectUnauthorized !== false, session: options.session, ALPNProtocols: options.ALPNProtocols, requestOCSP: options.requestOCSP, enableTrace: options.enableTrace, pskCallback: options.pskCallback, highWaterMark: options.highWaterMark, onread: options.onread, signal: options.signal, ...options, // Caveat emptor: Node does not do this. }); // rejectUnauthorized property can be explicitly defined as `undefined` // causing the assignment to default value (`true`) fail. Before assigning // it to the tlssock connection options, explicitly check if it is false // and update rejectUnauthorized property. The property gets used by TLSSocket // connection handler to allow or reject connection if unauthorized options.rejectUnauthorized = options.rejectUnauthorized !== false; tlssock[kConnectOptions] = options; if (cb) { tlssock.once("secureConnect", cb); } if (!options.socket) { // If user provided the socket, it's their responsibility to manage its // connectivity. If we created one internally, we connect it. if (options.timeout) { tlssock.setTimeout(options.timeout); } tlssock.connect(options, tlssock._start); } tlssock._releaseControl(); if (options.session) { tlssock.setSession(options.session); } if (options.servername) { if (!ipServernameWarned && net.isIP(options.servername)) { emitWarning( "Setting the TLS ServerName to an IP address is not permitted by " + "RFC 6066. This will be ignored in a future version.", "DeprecationWarning", "DEP0123", ); ipServernameWarned = true; } tlssock.setServername(options.servername); } if (options.socket) { tlssock._start(); } tlssock.on("secure", onConnectSecure); tlssock.prependListener("end", onConnectEnd); return tlssock; } function getAllowUnauthorized() { return false; } // This pattern is used to determine the length of escaped sequences within // the subject alt names string. It allows any valid JSON string literal. // This MUST match the JSON specification (ECMA-404 / RFC8259) exactly. const jsonStringPattern = // deno-lint-ignore no-control-regex /^"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/; function splitEscapedAltNames(altNames) { const result = []; let currentToken = ""; let offset = 0; while (offset !== altNames.length) { const nextSep = altNames.indexOf(",", offset); const nextQuote = altNames.indexOf('"', offset); if (nextQuote !== -1 && (nextSep === -1 || nextQuote < nextSep)) { // There is a quote character and there is no separator before the quote. currentToken += altNames.substring(offset, nextQuote); const match = jsonStringPattern.exec(altNames.substring(nextQuote)); if (!match) { throw new ERR_TLS_CERT_ALTNAME_FORMAT(); } currentToken += JSON.parse(match[0]); offset = nextQuote + match[0].length; } else if (nextSep !== -1) { // There is a separator and no quote before it. currentToken += altNames.substring(offset, nextSep); result.push(currentToken); currentToken = ""; offset = nextSep + 2; } else { currentToken += altNames.substring(offset); offset = altNames.length; } } result.push(currentToken); return result; } function unfqdn(host) { return StringPrototypeReplace(host, /[.]$/, ""); } // String#toLowerCase() is locale-sensitive so we use // a conservative version that only lowercases A-Z. function toLowerCase(c) { return String.fromCharCode(32 + c.charCodeAt(0)); } function splitHost(host) { return unfqdn(host).replace(/[A-Z]/g, toLowerCase).split("."); } function check(hostParts, pattern, wildcards) { // Empty strings, null, undefined, etc. never match. if (!pattern) { return false; } const patternParts = splitHost(pattern); if (hostParts.length !== patternParts.length) { return false; } // Pattern has empty components, e.g. "bad..example.com". if (patternParts.includes("")) { return false; } // RFC 6125 allows IDNA U-labels (Unicode) in names but we have no // good way to detect their encoding or normalize them so we simply // reject them. Control characters and blanks are rejected as well // because nothing good can come from accepting them. const isBad = (s) => /[^\u0021-\u007F]/u.test(s); if (patternParts.some(isBad)) { return false; } // Check host parts from right to left first. for (let i = hostParts.length - 1; i > 0; i -= 1) { if (hostParts[i] !== patternParts[i]) { return false; } } const hostSubdomain = hostParts[0]; const patternSubdomain = patternParts[0]; const patternSubdomainParts = patternSubdomain.split("*", 3); // Short-circuit when the subdomain does not contain a wildcard. // RFC 6125 does not allow wildcard substitution for components // containing IDNA A-labels (Punycode) so match those verbatim. if ( patternSubdomainParts.length === 1 || patternSubdomain.includes("xn--") ) { return hostSubdomain === patternSubdomain; } if (!wildcards) { return false; } // More than one wildcard is always wrong. if (patternSubdomainParts.length > 2) { return false; } // *.tld wildcards are not allowed. if (patternParts.length <= 2) { return false; } const { 0: prefix, 1: suffix } = patternSubdomainParts; if (prefix.length + suffix.length > hostSubdomain.length) { return false; } if (!hostSubdomain.startsWith(prefix)) { return false; } if (!hostSubdomain.endsWith(suffix)) { return false; } return true; } export function checkServerIdentity(hostname, cert) { const subject = cert.subject; const altNames = cert.subjectaltname; const dnsNames = []; const ips = []; hostname = "" + hostname; if (altNames) { const splitAltNames = altNames.includes('"') ? splitEscapedAltNames(altNames) : altNames.split(", "); splitAltNames.forEach((name) => { if (name.startsWith("DNS:")) { dnsNames.push(name.slice(4)); } else if (name.startsWith("IP Address:")) { ips.push(canonicalizeIP(name.slice(11))); } }); } let valid = false; let reason = "Unknown reason"; hostname = unfqdn(hostname); // Remove trailing dot for error messages. if (net.isIP(hostname)) { valid = ips.includes(canonicalizeIP(hostname)); if (!valid) { reason = `IP: ${hostname} is not in the cert's list: ` + ips.join(", "); } } else if (dnsNames.length > 0 || subject?.CN) { const hostParts = splitHost(hostname); const wildcard = (pattern) => check(hostParts, pattern, true); if (dnsNames.length > 0) { valid = dnsNames.some(wildcard); if (!valid) { reason = `Host: ${hostname}. is not in the cert's altnames: ${altNames}`; } } else { // Match against Common Name only if no supported identifiers exist. const cn = subject.CN; if (ArrayIsArray(cn)) { valid = cn.some(wildcard); } else if (cn) { valid = wildcard(cn); } if (!valid) { reason = `Host: ${hostname}. is not cert's CN: ${cn}`; } } } else { reason = "Cert does not contain a DNS name"; } if (!valid) { return new ERR_TLS_CERT_ALTNAME_INVALID(reason, hostname, cert); } } // Order matters. Mirrors ALL_CIPHER_SUITES from rustls/src/suites.rs but // using openssl cipher names instead. Mutable in Node but not (yet) in Deno. export const DEFAULT_CIPHERS = [ // TLSv1.3 suites "AES256-GCM-SHA384", "AES128-GCM-SHA256", "TLS_CHACHA20_POLY1305_SHA256", // TLSv1.2 suites "ECDHE-ECDSA-AES256-GCM-SHA384", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-CHACHA20-POLY1305", "ECDHE-RSA-AES256-GCM-SHA384", "ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-RSA-CHACHA20-POLY1305", ].join(":"); export default { TLSSocket, connect, createServer, checkServerIdentity, DEFAULT_CIPHERS, Server, unfqdn, };