deno/ext/node/polyfills/_tls_wrap.js
Divy 36e9eb2023
Some checks are pending
ci / pre-build (push) Waiting to run
ci / test debug linux-aarch64 (push) Blocked by required conditions
ci / test release linux-aarch64 (push) Blocked by required conditions
ci / test debug macos-aarch64 (push) Blocked by required conditions
ci / test release macos-aarch64 (push) Blocked by required conditions
ci / bench release linux-x86_64 (push) Blocked by required conditions
ci / lint debug linux-x86_64 (push) Blocked by required conditions
ci / lint debug macos-x86_64 (push) Blocked by required conditions
ci / lint debug windows-x86_64 (push) Blocked by required conditions
ci / test debug linux-x86_64 (push) Blocked by required conditions
ci / test release linux-x86_64 (push) Blocked by required conditions
ci / test debug macos-x86_64 (push) Blocked by required conditions
ci / test release macos-x86_64 (push) Blocked by required conditions
ci / test debug windows-x86_64 (push) Blocked by required conditions
ci / test release windows-x86_64 (push) Blocked by required conditions
ci / build libs (push) Blocked by required conditions
ci / publish canary (push) Blocked by required conditions
fix(ext/node): support JS underlying stream in TLS (#30465)
Fixes https://github.com/denoland/deno/issues/20594

This implements `JSStreamSocket` which drives the TLS underlying stream
in `rustls_tokio_stream` using 2 sets of channels. One for piping the
encrypted protocol transport and the other for plaintext application
data.

This fixes connecting to `npm:mssql`:
```js
import sql from "npm:mssql";

const sqlConfig = {
  server: "localhost",
  user: "divy",
  password: "123",
  database: "master",
  options: {
    trustServerCertificate: true,
  },
};

const pool = await sql.connect(sqlConfig);
const result = await pool.request().query(`SELECT * FROM sys.databases`);
```
2025-08-28 17:56:17 +05:30

799 lines
21 KiB
JavaScript

// 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,
};