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