thekuwayama/tttls1.3

View on GitHub
lib/tttls1.3/client.rb

Summary

Maintainability
F
1 wk
Test Coverage
# encoding: ascii-8bit
# frozen_string_literal: true

module TTTLS13
  using Refinements

  module ClientState
    # initial value is 0, eof value is -1
    START         = 1
    WAIT_SH       = 2
    WAIT_EE       = 3
    WAIT_CERT_CR  = 4
    WAIT_CERT     = 5
    WAIT_CV       = 6
    WAIT_FINISHED = 7
    CONNECTED     = 8
  end

  DEFAULT_CH_CIPHER_SUITES = [
    CipherSuite::TLS_AES_256_GCM_SHA384,
    CipherSuite::TLS_CHACHA20_POLY1305_SHA256,
    CipherSuite::TLS_AES_128_GCM_SHA256
  ].freeze
  private_constant :DEFAULT_CH_CIPHER_SUITES

  DEFAULT_CH_SIGNATURE_ALGORITHMS = [
    SignatureScheme::ECDSA_SECP256R1_SHA256,
    SignatureScheme::ECDSA_SECP384R1_SHA384,
    SignatureScheme::ECDSA_SECP521R1_SHA512,
    SignatureScheme::RSA_PSS_RSAE_SHA256,
    SignatureScheme::RSA_PSS_RSAE_SHA384,
    SignatureScheme::RSA_PSS_RSAE_SHA512,
    SignatureScheme::RSA_PKCS1_SHA256,
    SignatureScheme::RSA_PKCS1_SHA384,
    SignatureScheme::RSA_PKCS1_SHA512
  ].freeze
  private_constant :DEFAULT_CH_SIGNATURE_ALGORITHMS

  DEFAULT_CH_NAMED_GROUP_LIST = [
    NamedGroup::SECP256R1,
    NamedGroup::SECP384R1,
    NamedGroup::SECP521R1
  ].freeze
  private_constant :DEFAULT_CH_NAMED_GROUP_LIST

  DEFALUT_CH_COMPRESS_CERTIFICATE_ALGORITHMS = [
    Message::Extension::CertificateCompressionAlgorithm::ZLIB
  ].freeze
  private_constant :DEFALUT_CH_COMPRESS_CERTIFICATE_ALGORITHMS

  DEFAULT_CLIENT_SETTINGS = {
    ca_file: nil,
    cipher_suites: DEFAULT_CH_CIPHER_SUITES,
    signature_algorithms: DEFAULT_CH_SIGNATURE_ALGORITHMS,
    signature_algorithms_cert: nil,
    supported_groups: DEFAULT_CH_NAMED_GROUP_LIST,
    key_share_groups: nil,
    alpn: nil,
    process_new_session_ticket: nil,
    ticket: nil,
    resumption_secret: nil,
    psk_cipher_suite: nil,
    ticket_nonce: nil,
    ticket_age_add: nil,
    ticket_timestamp: nil,
    record_size_limit: nil,
    check_certificate_status: false,
    process_certificate_status: nil,
    compress_certificate_algorithms: DEFALUT_CH_COMPRESS_CERTIFICATE_ALGORITHMS,
    ech_config: nil,
    ech_hpke_cipher_suites: nil,
    compatibility_mode: true,
    sslkeylogfile: nil,
    loglevel: Logger::WARN
  }.freeze
  private_constant :DEFAULT_CLIENT_SETTINGS

  STANDARD_CLIENT_ECH_HPKE_SYMMETRIC_CIPHER_SUITES = [
    HpkeSymmetricCipherSuite.new(
      HpkeSymmetricCipherSuite::HpkeKdfId.new(
        Ech::KdfId::HKDF_SHA256
      ),
      HpkeSymmetricCipherSuite::HpkeAeadId.new(
        Ech::AeadId::AES_128_GCM
      )
    )
  ].freeze
  # rubocop: disable Metrics/ClassLength
  class Client
    include Logging

    HpkeSymmetricCipherSuit = \
      ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite

    attr_reader :transcript

    # @param socket [Socket]
    # @param hostname [String]
    # @param settings [Hash]
    def initialize(socket, hostname, **settings)
      @connection = Connection.new(socket, :client)
      @hostname = hostname
      @settings = DEFAULT_CLIENT_SETTINGS.merge(settings)
      logger.level = @settings[:loglevel]

      @early_data = ''
      @succeed_early_data = false
      @retry_configs = []
      @rejected_ech = false
      raise Error::ConfigError unless valid_settings?
    end

    # NOTE:
    #                           START <----+
    #            Send ClientHello |        | Recv HelloRetryRequest
    #       [K_send = early data] |        |
    #                             v        |
    #        /                 WAIT_SH ----+
    #        |                    | Recv ServerHello
    #        |                    | K_recv = handshake
    #    Can |                    V
    #   send |                 WAIT_EE
    #  early |                    | Recv EncryptedExtensions
    #   data |           +--------+--------+
    #        |     Using |                 | Using certificate
    #        |       PSK |                 v
    #        |           |            WAIT_CERT_CR
    #        |           |        Recv |       | Recv CertificateRequest
    #        |           | Certificate |       v
    #        |           |             |    WAIT_CERT
    #        |           |             |       | Recv Certificate
    #        |           |             v       v
    #        |           |              WAIT_CV
    #        |           |                 | Recv CertificateVerify
    #        |           +> WAIT_FINISHED <+
    #        |                  | Recv Finished
    #        \                  | [Send EndOfEarlyData]
    #                           | K_send = handshake
    #                           | [Send Certificate [+ CertificateVerify]]
    # Can send                  | Send Finished
    # app data   -->            | K_send = K_recv = application
    # after here                v
    #                       CONNECTED
    #
    # https://datatracker.ietf.org/doc/html/rfc8446#appendix-A.1
    #
    # rubocop: disable Metrics/AbcSize
    # rubocop: disable Metrics/BlockLength
    # rubocop: disable Metrics/CyclomaticComplexity
    # rubocop: disable Metrics/MethodLength
    # rubocop: disable Metrics/PerceivedComplexity
    def connect
      @transcript = Transcript.new
      key_schedule = nil # TTTLS13::KeySchedule
      psk = nil
      priv_keys = {} # Hash of NamedGroup => OpenSSL::PKey::$Object
      if use_psk?
        psk = gen_psk_from_nst(
          @settings[:resumption_secret],
          @settings[:ticket_nonce],
          CipherSuite.digest(@settings[:psk_cipher_suite])
        )
        key_schedule = KeySchedule.new(
          psk: psk,
          shared_secret: nil,
          cipher_suite: @settings[:psk_cipher_suite],
          transcript: @transcript
        )
      end

      hs_wcipher = nil # TTTLS13::Cryptograph::$Object
      hs_rcipher = nil # TTTLS13::Cryptograph::$Object
      e_wcipher = nil # TTTLS13::Cryptograph::$Object
      sslkeylogfile = nil # TTTLS13::SslKeyLogFile::Writer
      ch1_outer = nil # TTTLS13::Message::ClientHello for rejected ECH
      ch_outer = nil # TTTLS13::Message::ClientHello for rejected ECH
      ech_state = nil # TTTLS13::EchState for ECH with HRR
      unless @settings[:sslkeylogfile].nil?
        begin
          sslkeylogfile = SslKeyLogFile::Writer.new(@settings[:sslkeylogfile])
        rescue SystemCallError => e
          msg = "\"#{@settings[:sslkeylogfile]}\" file can NOT open: #{e}"
          logger.warn(msg)
        end
      end

      @connection.state = ClientState::START
      loop do
        case @connection.state
        when ClientState::START
          logger.debug('ClientState::START')

          extensions, priv_keys = gen_ch_extensions
          binder_key = (use_psk? ? key_schedule.binder_key_res : nil)
          ch, inner, ech_state = send_client_hello(extensions, binder_key)
          ch_outer = ch
          # use ClientHelloInner messages for the transcript hash
          ch = inner.nil? ? ch : inner
          @transcript[CH] = [ch, ch.serialize]
          @connection.send_ccs if @settings[:compatibility_mode]
          if use_early_data?
            e_wcipher = Endpoint.gen_cipher(
              @settings[:psk_cipher_suite],
              key_schedule.early_data_write_key,
              key_schedule.early_data_write_iv
            )
            sslkeylogfile&.write_client_early_traffic_secret(
              @transcript[CH].first.random,
              key_schedule.client_early_traffic_secret
            )
            send_early_data(e_wcipher)
          end

          @connection.state = ClientState::WAIT_SH
        when ClientState::WAIT_SH
          logger.debug('ClientState::WAIT_SH')

          sh, = @transcript[SH] = recv_server_hello

          # downgrade protection
          if !sh.negotiated_tls_1_3? && sh.downgraded?
            @connection.terminate(:illegal_parameter)
          # support only TLS 1.3
          elsif !sh.negotiated_tls_1_3?
            @connection.terminate(:protocol_version)
          end

          # validate parameters
          @connection.terminate(:illegal_parameter) \
            unless sh.appearable_extensions?
          @connection.terminate(:illegal_parameter) \
            unless sh.legacy_compression_method == "\x00"

          # validate sh using ch
          ch, = @transcript[CH]
          @connection.terminate(:illegal_parameter) \
            unless sh.legacy_version == ch.legacy_version
          @connection.terminate(:illegal_parameter) \
            unless sh.legacy_session_id_echo == ch.legacy_session_id
          @connection.terminate(:illegal_parameter) \
            unless ch.cipher_suites.include?(sh.cipher_suite)
          @connection.terminate(:unsupported_extension) \
            unless (sh.extensions.keys - ch.extensions.keys).empty?

          # validate sh using hrr
          if @transcript.include?(HRR)
            hrr, = @transcript[HRR]
            @connection.terminate(:illegal_parameter) \
              unless sh.cipher_suite == hrr.cipher_suite

            sh_sv = sh.extensions[Message::ExtensionType::SUPPORTED_VERSIONS]
            hrr_sv = hrr.extensions[Message::ExtensionType::SUPPORTED_VERSIONS]
            @connection.terminate(:illegal_parameter) \
              unless sh_sv.versions == hrr_sv.versions
          end

          # handling HRR
          if sh.hrr?
            @connection.terminate(:unexpected_message) \
              if @transcript.include?(HRR)

            ch1, = @transcript[CH1] = @transcript.delete(CH)
            hrr, = @transcript[HRR] = @transcript.delete(SH)
            ch1_outer = ch_outer
            ch_outer = nil

            # validate cookie
            diff_sets = sh.extensions.keys - ch1.extensions.keys
            @connection.terminate(:unsupported_extension) \
              unless (diff_sets - [Message::ExtensionType::COOKIE]).empty?

            # validate key_share
            # TODO: validate pre_shared_key
            ngl = ch1.extensions[Message::ExtensionType::SUPPORTED_GROUPS]
                     .named_group_list
            kse = ch1.extensions[Message::ExtensionType::KEY_SHARE]
                     .key_share_entry
            group = hrr.extensions[Message::ExtensionType::KEY_SHARE]
                       .key_share_entry.first.group
            @connection.terminate(:illegal_parameter) \
              unless ngl.include?(group) && !kse.map(&:group).include?(group)

            # send new client_hello
            extensions, pk = gen_newch_extensions(ch1, hrr)
            priv_keys = pk.merge(priv_keys)
            binder_key = (use_psk? ? key_schedule.binder_key_res : nil)
            ch, inner = send_new_client_hello(
              ch1,
              hrr,
              extensions,
              binder_key,
              ech_state
            )
            # use ClientHelloInner messages for the transcript hash
            ch_outer = ch
            ch = inner.nil? ? ch : inner
            @transcript[CH] = [ch, ch.serialize]

            @connection.state = ClientState::WAIT_SH
            next
          end

          # generate shared secret
          if sh.extensions.include?(Message::ExtensionType::PRE_SHARED_KEY)
          # TODO: validate pre_shared_key
          else
            psk = nil
          end
          ch_ks = ch.extensions[Message::ExtensionType::KEY_SHARE]
                    .key_share_entry.map(&:group)
          sh_ks = sh.extensions[Message::ExtensionType::KEY_SHARE]
                    .key_share_entry.first.group
          @connection.terminate(:illegal_parameter) unless ch_ks.include?(sh_ks)

          kse = sh.extensions[Message::ExtensionType::KEY_SHARE]
                  .key_share_entry.first
          ke = kse.key_exchange
          @named_group = kse.group
          priv_key = priv_keys[@named_group]
          shared_secret = Endpoint.gen_shared_secret(ke, priv_key, @named_group)
          @cipher_suite = sh.cipher_suite
          key_schedule = KeySchedule.new(
            psk: psk,
            shared_secret: shared_secret,
            cipher_suite: @cipher_suite,
            transcript: @transcript
          )

          # rejected ECH
          # NOTE: It can compute (hrr_)accept_ech until client selects the
          # cipher_suite.
          if !sh.hrr? && use_ech?
            if !@transcript.include?(HRR) && !key_schedule.accept_ech?
              # 1sh SH
              @transcript[CH] = [ch_outer, ch_outer.serialize]
              @rejected_ech = true
            elsif @transcript.include?(HRR) &&
                  key_schedule.hrr_accept_ech? != key_schedule.accept_ech?
              # 2nd SH
              @connection.terminate(:illegal_parameter)
            elsif @transcript.include?(HRR) && !key_schedule.hrr_accept_ech?
              # 2nd SH
              @transcript[CH1] = [ch1_outer, ch1_outer.serialize]
              @transcript[CH] = [ch_outer, ch_outer.serialize]
              @rejected_ech = true
            end
          end

          @connection.alert_wcipher = hs_wcipher = Endpoint.gen_cipher(
            @cipher_suite,
            key_schedule.client_handshake_write_key,
            key_schedule.client_handshake_write_iv
          )
          sslkeylogfile&.write_client_handshake_traffic_secret(
            @transcript[CH].first.random,
            key_schedule.client_handshake_traffic_secret
          )
          hs_rcipher = Endpoint.gen_cipher(
            @cipher_suite,
            key_schedule.server_handshake_write_key,
            key_schedule.server_handshake_write_iv
          )
          sslkeylogfile&.write_server_handshake_traffic_secret(
            @transcript[CH].first.random,
            key_schedule.server_handshake_traffic_secret
          )
          @connection.state = ClientState::WAIT_EE
        when ClientState::WAIT_EE
          logger.debug('ClientState::WAIT_EE')

          ee, = @transcript[EE] = recv_encrypted_extensions(hs_rcipher)
          @connection.terminate(:illegal_parameter) \
            unless ee.appearable_extensions?

          ch, = @transcript[CH]
          @connection.terminate(:unsupported_extension) \
            unless (ee.extensions.keys - ch.extensions.keys).empty?

          rsl = ee.extensions[Message::ExtensionType::RECORD_SIZE_LIMIT]
          @recv_record_size = rsl.record_size_limit unless rsl.nil?
          @succeed_early_data = true \
            if ee.extensions.include?(Message::ExtensionType::EARLY_DATA)
          @alpn = ee.extensions[
            Message::ExtensionType::APPLICATION_LAYER_PROTOCOL_NEGOTIATION
          ]&.protocol_name_list&.first
          @retry_configs = ee.extensions[
            Message::ExtensionType::ENCRYPTED_CLIENT_HELLO
          ]&.retry_configs
          @connection.terminate(:unsupported_extension) \
            if !rejected_ech? && !@retry_configs.nil?

          @connection.state = ClientState::WAIT_CERT_CR
          @connection.state = ClientState::WAIT_FINISHED unless psk.nil?
        when ClientState::WAIT_CERT_CR
          logger.debug('ClientState::WAIT_CERT_CR')

          message, orig_msg = @connection.recv_message(
            receivable_ccs: true,
            cipher: hs_rcipher
          )
          case message.msg_type
          when Message::HandshakeType::CERTIFICATE,
               Message::HandshakeType::COMPRESSED_CERTIFICATE
            ct, = @transcript[CT] = [message, orig_msg]
            @connection.terminate(:bad_certificate) \
              if ct.is_a?(Message::CompressedCertificate) &&
                 !@settings[:compress_certificate_algorithms]
                 .include?(ct.algorithm)

            ct = ct.certificate_message \
              if ct.is_a?(Message::CompressedCertificate)
            alert = check_invalid_certificate(ct, @transcript[CH].first)
            @connection.terminate(alert) unless alert.nil?

            @connection.state = ClientState::WAIT_CV
          when Message::HandshakeType::CERTIFICATE_REQUEST
            @transcript[CR] = [message, orig_msg]
            # TODO: client authentication
            @connection.state = ClientState::WAIT_CERT
          else
            @connection.terminate(:unexpected_message)
          end
        when ClientState::WAIT_CERT
          logger.debug('ClientState::WAIT_CERT')

          ct, = @transcript[CT] = recv_certificate(hs_rcipher)
          if ct.is_a?(Message::CompressedCertificate) &&
             !@settings[:compress_certificate_algorithms].include?(ct.algorithm)
            @connection.terminate(:bad_certificate)
          elsif ct.is_a?(Message::CompressedCertificate)
            ct = ct.certificate_message
          end

          alert = check_invalid_certificate(ct, @transcript[CH].first)
          @connection.terminate(alert) unless alert.nil?

          @connection.state = ClientState::WAIT_CV
        when ClientState::WAIT_CV
          logger.debug('ClientState::WAIT_CV')

          cv, = @transcript[CV] = recv_certificate_verify(hs_rcipher)
          digest = CipherSuite.digest(@cipher_suite)
          hash = @transcript.hash(digest, CT)
          ct, = @transcript[CT]
          ct = ct.certificate_message \
            if ct.is_a?(Message::CompressedCertificate)
          @connection.terminate(:decrypt_error) \
            unless verified_certificate_verify?(ct, cv, hash)

          @signature_scheme = cv.signature_scheme
          @connection.state = ClientState::WAIT_FINISHED
        when ClientState::WAIT_FINISHED
          logger.debug('ClientState::WAIT_FINISHED')

          sf, = @transcript[SF] = recv_finished(hs_rcipher)
          digest = CipherSuite.digest(@cipher_suite)
          @connection.terminate(:decrypt_error) \
            unless Endpoint.verified_finished?(
              finished: sf,
              digest: digest,
              finished_key: key_schedule.server_finished_key,
              hash: @transcript.hash(digest, CV)
            )

          if use_early_data? && succeed_early_data?
            eoed = send_eoed(e_wcipher)
            @transcript[EOED] = [eoed, eoed.serialize]
          end
          # TODO: Send Certificate [+ CertificateVerify]
          signature = Endpoint.sign_finished(
            digest: digest,
            finished_key: key_schedule.client_finished_key,
            hash: @transcript.hash(digest, EOED)
          )
          cf = send_finished(signature, hs_wcipher)
          @transcript[CF] = [cf, cf.serialize]
          @connection.ap_wcipher = Endpoint.gen_cipher(
            @cipher_suite,
            key_schedule.client_application_write_key,
            key_schedule.client_application_write_iv
          )
          @connection.alert_wcipher = @connection.ap_wcipher
          sslkeylogfile&.write_client_traffic_secret_0(
            @transcript[CH].first.random,
            key_schedule.client_application_traffic_secret
          )
          @connection.ap_rcipher = Endpoint.gen_cipher(
            @cipher_suite,
            key_schedule.server_application_write_key,
            key_schedule.server_application_write_iv
          )
          sslkeylogfile&.write_server_traffic_secret_0(
            @transcript[CH].first.random,
            key_schedule.server_application_traffic_secret
          )
          @exporter_secret = key_schedule.exporter_secret
          @resumption_secret = key_schedule.resumption_secret
          @connection.state = ClientState::CONNECTED
        when ClientState::CONNECTED
          logger.debug('ClientState::CONNECTED')

          @connection.send_alert(:ech_required) \
            if use_ech? && (!@retry_configs.nil? && !@retry_configs.empty?)
          break
        end
      end
      sslkeylogfile&.close
    end
    # rubocop: enable Metrics/AbcSize
    # rubocop: enable Metrics/BlockLength
    # rubocop: enable Metrics/CyclomaticComplexity
    # rubocop: enable Metrics/MethodLength
    # rubocop: enable Metrics/PerceivedComplexity

    # @raise [TTTLS13::Error::ConfigError]
    #
    # @return [String]
    def read
      nst_process = method(:process_new_session_ticket)
      @connection.read(nst_process)
    end

    # @param binary [String]
    def write(binary)
      # the client can regard ECH as securely disabled by the server, and it
      # SHOULD retry the handshake with a new transport connection and ECH
      # disabled.
      if !@retry_configs.nil? && !@retry_configs.empty?
        msg = 'SHOULD retry the handshake with a new transport connection'
        logger.warn(msg)
        return
      end

      @connection.write(binary)
    end

    # return [Boolean]
    def eof?
      @connection.eof?
    end

    def close
      @connection.close
    end

    # @return [TTTLS13::CipherSuite, nil]
    def negotiated_cipher_suite
      @cipher_suite
    end

    # @return [TTTLS13::NamedGroup, nil]
    def negotiated_named_group
      @named_group
    end

    # @return [TTTLS13::SignatureScheme, nil]
    def negotiated_signature_scheme
      @signature_scheme
    end

    # @return [String]
    def negotiated_alpn
      @alpn
    end

    # @param label [String]
    # @param context [String]
    # @param key_length [Integer]
    #
    # @return [String, nil]
    def exporter(label, context, key_length)
      return nil if @exporter_secret.nil? || @cipher_suite.nil?

      digest = CipherSuite.digest(@cipher_suite)
      Endpoint.exporter(@exporter_secret, digest, label, context, key_length)
    end

    # @param binary [String]
    #
    # @raise [TTTLS13::Error::ConfigError]
    def early_data(binary)
      raise Error::ConfigError unless @connection.state == INITIAL && use_psk?

      @early_data = binary
    end

    # @return [Array of ECHConfig]
    def retry_configs
      @retry_configs.filter do |c|
        SUPPORTED_ECHCONFIG_VERSIONS.include?(c.version)
      end
    end

    # @return [Boolean]
    def succeed_early_data?
      @succeed_early_data
    end

    # @return [Boolean]
    def rejected_ech?
      @rejected_ech
    end

    # @param res [OpenSSL::OCSP::Response]
    # @param cert [OpenSSL::X509::Certificate]
    # @param chain [Array of OpenSSL::X509::Certificate, nil]
    #
    # @return [Boolean]
    #
    # @example
    #   m = Client.method(:softfail_check_certificate_status)
    #   Client.new(
    #     socket,
    #     hostname,
    #     check_certificate_status: true,
    #     process_certificate_status: m
    #   )
    def self.softfail_check_certificate_status(res, cert, chain)
      ocsp_response = res
      cid = OpenSSL::OCSP::CertificateId.new(cert, chain.first)

      # When NOT received OCSPResponse in TLS handshake, this method will
      # send OCSPRequest. If ocsp_uri is NOT presented in Certificate, return
      # true. Also, if it sends OCSPRequest and does NOT receive a HTTPresponse
      # within 2 seconds, return true.
      if ocsp_response.nil?
        uri = cert.ocsp_uris&.find { |u| URI::DEFAULT_PARSER.make_regexp =~ u }
        return true if uri.nil?

        begin
          # send OCSP::Request
          ocsp_request = gen_ocsp_request(cid)
          Timeout.timeout(2) do
            ocsp_response = send_ocsp_request(ocsp_request, uri)
          end

          # check nonce of OCSP::Response
          check_nonce = ocsp_request.check_nonce(ocsp_response.basic)
          return true unless [-1, 1].include?(check_nonce)
        rescue StandardError
          return true
        end
      end
      return true \
        if ocsp_response.status != OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL

      status = ocsp_response.basic.status.find { |s| s.first.cmp(cid) }
      status[1] != OpenSSL::OCSP::V_CERTSTATUS_REVOKED
    end

    private

    # @return [Boolean]
    # rubocop: disable Metrics/AbcSize
    # rubocop: disable Metrics/CyclomaticComplexity
    # rubocop: disable Metrics/PerceivedComplexity
    def valid_settings?
      mod = CipherSuite
      defined_cipher_suites = mod.constants.map { |c| mod.const_get(c) }
      return false \
        unless (@settings[:cipher_suites] - defined_cipher_suites).empty?

      sa = @settings[:signature_algorithms]
      mod = SignatureScheme
      defined_signature_schemes = mod.constants.map { |c| mod.const_get(c) }
      return false unless (sa - defined_signature_schemes).empty?

      sac = @settings[:signature_algorithms_cert] || []
      return false unless (sac - defined_signature_schemes).empty?

      sg = @settings[:supported_groups]
      return false unless (sac - defined_signature_schemes).empty?

      ksg = @settings[:key_share_groups]
      return false \
        unless ksg.nil? ||
               ((ksg - sg).empty? && sg.select { |g| ksg.include?(g) } == ksg)

      rsl = @settings[:record_size_limit]
      return false if !rsl.nil? && (rsl < 64 || rsl > 2**14 + 1)

      return false if @settings[:check_certificate_status] &&
                      @settings[:process_certificate_status].nil?

      ehcs = @settings[:ech_hpke_cipher_suites] || []
      return false if !@settings[:ech_config].nil? && ehcs.empty?

      true
    end
    # rubocop: enable Metrics/AbcSize
    # rubocop: enable Metrics/CyclomaticComplexity
    # rubocop: enable Metrics/PerceivedComplexity

    # @return [Boolean]
    def use_psk?
      !@settings[:ticket].nil? &&
        !@settings[:resumption_secret].nil? &&
        !@settings[:psk_cipher_suite].nil? &&
        !@settings[:ticket_nonce].nil? &&
        !@settings[:ticket_age_add].nil? &&
        !@settings[:ticket_timestamp].nil?
    end

    # @return [Boolean]
    def use_early_data?
      !(@early_data.nil? || @early_data.empty?)
    end

    # @return [Boolean]
    def use_ech?
      !@settings[:ech_hpke_cipher_suites].nil? &&
        !@settings[:ech_hpke_cipher_suites].empty?
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    def send_early_data(cipher)
      ap = Message::ApplicationData.new(@early_data)
      ap_record = Message::Record.new(
        type: Message::ContentType::APPLICATION_DATA,
        legacy_record_version: Message::ProtocolVersion::TLS_1_2,
        messages: [ap],
        cipher: cipher
      )
      @connection.send_record(ap_record)
    end

    # @param resumption_secret [String]
    # @param ticket_nonce [String]
    # @param digest [String] name of digest algorithm
    #
    # @return [String]
    def gen_psk_from_nst(resumption_secret, ticket_nonce, digest)
      hash_len = OpenSSL::Digest.new(digest).digest_length
      KeySchedule.hkdf_expand_label(resumption_secret, 'resumption',
                                    ticket_nonce, hash_len, digest)
    end

    # @return [TTTLS13::Message::Extensions]
    # @return [Hash of NamedGroup => OpenSSL::PKey::EC.$Object]
    # rubocop: disable Metrics/AbcSize
    # rubocop: disable Metrics/CyclomaticComplexity
    # rubocop: disable Metrics/MethodLength
    # rubocop: disable Metrics/PerceivedComplexity
    def gen_ch_extensions
      exs = Message::Extensions.new
      # server_name
      exs << Message::Extension::ServerName.new(@hostname)

      # record_size_limit
      unless @settings[:record_size_limit].nil?
        exs << Message::Extension::RecordSizeLimit.new(
          @settings[:record_size_limit]
        )
      end

      # supported_versions: only TLS 1.3
      exs << Message::Extension::SupportedVersions.new(
        msg_type: Message::HandshakeType::CLIENT_HELLO
      )

      # signature_algorithms
      exs << Message::Extension::SignatureAlgorithms.new(
        @settings[:signature_algorithms]
      )

      # signature_algorithms_cert
      if !@settings[:signature_algorithms_cert].nil? &&
         !@settings[:signature_algorithms_cert].empty?
        exs << Message::Extension::SignatureAlgorithmsCert.new(
          @settings[:signature_algorithms_cert]
        )
      end

      # supported_groups
      groups = @settings[:supported_groups]
      exs << Message::Extension::SupportedGroups.new(groups)

      # key_share
      ksg = @settings[:key_share_groups] || groups
      key_share, priv_keys \
                 = Message::Extension::KeyShare.gen_ch_key_share(ksg)
      exs << key_share

      # early_data
      exs << Message::Extension::EarlyDataIndication.new if use_early_data?

      # alpn
      exs << Message::Extension::Alpn.new(@settings[:alpn].reject(&:empty?)) \
        if !@settings[:alpn].nil? && !@settings[:alpn].empty?

      # status_request
      exs << Message::Extension::OCSPStatusRequest.new \
        if @settings[:check_certificate_status]

      # compress_certificate
      if !@settings[:compress_certificate_algorithms].nil? &&
         !@settings[:compress_certificate_algorithms].empty?
        exs << Message::Extension::CompressCertificate.new(
          @settings[:compress_certificate_algorithms]
        )
      end

      [exs, priv_keys]
    end
    # rubocop: enable Metrics/AbcSize
    # rubocop: enable Metrics/CyclomaticComplexity
    # rubocop: enable Metrics/MethodLength
    # rubocop: enable Metrics/PerceivedComplexity

    # @param extensions [TTTLS13::Message::Extensions]
    # @param binder_key [String, nil]
    #
    # @return [TTTLS13::Message::ClientHello] outer
    # @return [TTTLS13::Message::ClientHello] inner
    # @return [TTTLS13::EchState]
    # rubocop: disable Metrics/MethodLength
    def send_client_hello(extensions, binder_key = nil)
      ch = Message::ClientHello.new(
        cipher_suites: CipherSuites.new(@settings[:cipher_suites]),
        extensions: extensions
      )

      # encrypted_client_hello
      inner = nil # TTTLS13::Message::ClientHello
      if use_ech?
        inner = ch
        inner_ech = Message::Extension::ECHClientHello.new_inner
        inner.extensions[Message::ExtensionType::ENCRYPTED_CLIENT_HELLO] \
          = inner_ech
        ch, inner, ech_state = Ech.offer_ech(
          inner,
          @settings[:ech_config],
          method(:select_ech_hpke_cipher_suite)
        )
      end

      # psk_key_exchange_modes
      # In order to use PSKs, clients MUST also send a
      # "psk_key_exchange_modes" extension.
      #
      # https://datatracker.ietf.org/doc/html/rfc8446#section-4.2.9
      if use_psk?
        pkem = Message::Extension::PskKeyExchangeModes.new(
          [Message::Extension::PskKeyExchangeMode::PSK_DHE_KE]
        )
        ch.extensions[Message::ExtensionType::PSK_KEY_EXCHANGE_MODES] = pkem
      end

      # pre_shared_key
      # at the end, sign PSK binder
      if use_psk?
        sign_psk_binder(
          ch: ch,
          binder_key: binder_key
        )

        if use_ech?
          sign_grease_psk_binder(
            ch_outer: ch,
            inner_pks: inner.extensions[Message::ExtensionType::PRE_SHARED_KEY]
          )
        end
      end

      @connection.send_handshakes(Message::ContentType::HANDSHAKE, [ch],
                                  Cryptograph::Passer.new)

      [ch, inner, ech_state]
    end
    # rubocop: enable Metrics/MethodLength

    # @param ch1 [TTTLS13::Message::ClientHello]
    # @param hrr [TTTLS13::Message::ServerHello]
    # @param ch [TTTLS13::Message::ClientHello]
    # @param binder_key [String]
    #
    # @return [String]
    def sign_psk_binder(ch1: nil, hrr: nil, ch:, binder_key:)
      # pre_shared_key
      #
      # binder is computed as an HMAC over a transcript hash containing a
      # partial ClientHello up to and including the
      # PreSharedKeyExtension.identities field.
      #
      # https://datatracker.ietf.org/doc/html/rfc8446#section-4.2.11.2
      digest = CipherSuite.digest(@settings[:psk_cipher_suite])
      hash_len = OpenSSL::Digest.new(digest).digest_length
      placeholder_binders = [hash_len.zeros]
      psk = Message::Extension::PreSharedKey.new(
        msg_type: Message::HandshakeType::CLIENT_HELLO,
        offered_psks: Message::Extension::OfferedPsks.new(
          identities: [Message::Extension::PskIdentity.new(
            identity: @settings[:ticket],
            obfuscated_ticket_age: calc_obfuscated_ticket_age
          )],
          binders: placeholder_binders
        )
      )
      ch.extensions[Message::ExtensionType::PRE_SHARED_KEY] = psk

      psk.offered_psks.binders[0] = Endpoint.sign_psk_binder(
        ch1: ch1,
        hrr: hrr,
        ch: ch,
        binder_key: binder_key,
        digest: digest
      )
    end

    # @param ch1 [TTTLS13::Message::ClientHello]
    # @param hrr [TTTLS13::Message::ServerHello]
    # @param ch_outer [TTTLS13::Message::ClientHello]
    # @param inner_psk [Message::Extension::PreSharedKey]
    # @param binder_key [String]
    #
    # @return [String]
    def sign_grease_psk_binder(ch1: nil,
                               hrr: nil,
                               ch_outer:,
                               inner_psk:,
                               binder_key:)
      digest = CipherSuite.digest(@settings[:psk_cipher_suite])
      hash_len = OpenSSL::Digest.new(digest).digest_length
      placeholder_binders = [hash_len.zeros]
      # For each PSK identity advertised in the ClientHelloInner, the client
      # generates a random PSK identity with the same length. It also generates
      # a random, 32-bit, unsigned integer to use as the obfuscated_ticket_age.
      # Likewise, for each inner PSK binder, the client generates a random
      # string of the same length.
      #
      # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.1.2-2
      identity = inner_psk.offered_psks
                          .identities
                          .first
                          .identity
                          .length
                          .then { |len| OpenSSL::Random.random_bytes(len) }
      ota = OpenSSL::Random.random_bytes(4)
      psk = Message::Extension::PreSharedKey.new(
        msg_type: Message::HandshakeType::CLIENT_HELLO,
        offered_psks: Message::Extension::OfferedPsks.new(
          identities: [Message::Extension::PskIdentity.new(
            identity: identity,
            obfuscated_ticket_age: ota
          )],
          binders: placeholder_binders
        )
      )
      ch_outer.extensions[Message::ExtensionType::PRE_SHARED_KEY] = psk

      psk.offered_psks.binders[0] = Endpoint.sign_psk_binder(
        ch1: ch1,
        hrr: hrr,
        ch: ch_outer,
        binder_key: binder_key,
        digest: digest
      )
    end

    # @param conf [HpkeKeyConfig]
    #
    # @return [HpkeSymmetricCipherSuite, nil]
    def select_ech_hpke_cipher_suite(conf)
      @settings[:ech_hpke_cipher_suites].find do |cs|
        conf.cipher_suites.include?(cs)
      end
    end

    # @return [Integer]
    def calc_obfuscated_ticket_age
      # the "ticket_lifetime" field in the NewSessionTicket message is
      # in seconds but the "obfuscated_ticket_age" is in milliseconds.
      age = (Time.now.to_f * 1000).to_i - @settings[:ticket_timestamp] * 1000
      (age + Convert.bin2i(@settings[:ticket_age_add])) % (2**32)
    end

    # @param ch1 [TTTLS13::Message::ClientHello]
    # @param hrr [TTTLS13::Message::ServerHello]
    #
    # @return [TTTLS13::Message::Extensions]
    # @return [Hash of NamedGroup => OpenSSL::PKey::EC.$Object]
    def gen_newch_extensions(ch1, hrr)
      exs = Message::Extensions.new
      # key_share
      if hrr.extensions.include?(Message::ExtensionType::KEY_SHARE)
        group = hrr.extensions[Message::ExtensionType::KEY_SHARE]
                   .key_share_entry.first.group
        key_share, priv_keys \
          = Message::Extension::KeyShare.gen_ch_key_share([group])
        exs << key_share
      end

      # cookie
      #
      # When sending a HelloRetryRequest, the server MAY provide a "cookie"
      # extension to the client. When sending the new ClientHello, the client
      # MUST copy the contents of the extension received in the
      # HelloRetryRequest into a "cookie" extension in the new ClientHello.
      #
      # https://datatracker.ietf.org/doc/html/rfc8446#section-4.2.2
      exs << hrr.extensions[Message::ExtensionType::COOKIE] \
        if hrr.extensions.include?(Message::ExtensionType::COOKIE)

      # early_data
      new_exs = ch1.extensions.merge(exs)
      new_exs.delete(Message::ExtensionType::EARLY_DATA)

      [new_exs, priv_keys]
    end

    # NOTE:
    # https://datatracker.ietf.org/doc/html/rfc8446#section-4.1.2
    #
    # @param ch1 [TTTLS13::Message::ClientHello]
    # @param hrr [TTTLS13::Message::ServerHello]
    # @param extensions [TTTLS13::Message::Extensions]
    # @param binder_key [String, nil]
    # @param ech_state [TTTLS13::EchState]
    #
    # @return [TTTLS13::Message::ClientHello] outer
    # @return [TTTLS13::Message::ClientHello] inner
    # rubocop: disable Metrics/AbcSize
    # rubocop: disable Metrics/MethodLength
    def send_new_client_hello(ch1,
                              hrr,
                              extensions,
                              binder_key = nil,
                              ech_state = nil)
      ch = Message::ClientHello.new(
        legacy_version: ch1.legacy_version,
        random: ch1.random,
        legacy_session_id: ch1.legacy_session_id,
        cipher_suites: ch1.cipher_suites,
        legacy_compression_methods: ch1.legacy_compression_methods,
        extensions: extensions
      )

      # encrypted_client_hello
      if use_ech? && ech_state.nil?
        # If sending a second ClientHello in response to a HelloRetryRequest,
        # the client copies the entire "encrypted_client_hello" extension from
        # the first ClientHello.
        #
        # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.2-3
        inner = ch.clone
        ch.extensions[Message::ExtensionType::ENCRYPTED_CLIENT_HELLO] \
          = ch1.extensions[Message::ExtensionType::ENCRYPTED_CLIENT_HELLO]
      elsif use_ech?
        ch, inner = Ech.offer_new_ech(ch, ech_state)
      end

      # pre_shared_key
      #
      # Updating the "pre_shared_key" extension if present by recomputing
      # the "obfuscated_ticket_age" and binder values.
      if ch1.extensions.include?(Message::ExtensionType::PRE_SHARED_KEY)
        sign_psk_binder(ch1: ch1, hrr: hrr, ch: ch, binder_key: binder_key)

        if use_ech?
          # it MUST also copy the "psk_key_exchange_modes" from the
          # ClientHelloInner into the ClientHelloOuter.
          ch.extensions[Message::ExtensionType::PSK_KEY_EXCHANGE_MODES] \
            = inner.extensions[Message::ExtensionType::PSK_KEY_EXCHANGE_MODES]
          # it MUST also include the "early_data" extension in ClientHelloOuter.
          ch.extensions[Message::ExtensionType::EARLY_DATA] \
            = inner.extensions[Message::ExtensionType::EARLY_DATA]
          sign_grease_psk_binder(
            ch1: ch1,
            hrr: hrr,
            ch_outer: ch,
            inner_psk: inner.extensions[Message::ExtensionType::PRE_SHARED_KEY],
            binder_key: binder_key
          )
        end
      end

      @connection.send_handshakes(Message::ContentType::HANDSHAKE, [ch],
                                  Cryptograph::Passer.new)

      [ch, inner]
    end
    # rubocop: enable Metrics/AbcSize
    # rubocop: enable Metrics/MethodLength

    # @raise [TTTLS13::Error::ErrorAlerts]
    #
    # @return [TTTLS13::Message::ServerHello]
    # @return [String]
    def recv_server_hello
      sh, orig_msg = @connection.recv_message(
        receivable_ccs: true,
        cipher: Cryptograph::Passer.new
      )
      @connection.terminate(:unexpected_message) \
        unless sh.is_a?(Message::ServerHello)

      [sh, orig_msg]
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    #
    # @raise [TTTLS13::Error::ErrorAlerts]
    #
    # @return [TTTLS13::Message::EncryptedExtensions]
    # @return [String]
    def recv_encrypted_extensions(cipher)
      ee, orig_msg \
        = @connection.recv_message(receivable_ccs: true, cipher: cipher)
      @connection.terminate(:unexpected_message) \
        unless ee.is_a?(Message::EncryptedExtensions)

      [ee, orig_msg]
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    #
    # @raise [TTTLS13::Error::ErrorAlerts]
    #
    # @return [TTTLS13::Message::Certificate]
    # @return [String]
    def recv_certificate(cipher)
      ct, orig_msg \
        = @connection.recv_message(receivable_ccs: true, cipher: cipher)
      @connection.terminate(:unexpected_message) \
        unless ct.is_a?(Message::Certificate)

      [ct, orig_msg]
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    #
    # @raise [TTTLS13::Error::ErrorAlerts]
    #
    # @return [TTTLS13::Message::CertificateVerify]
    # @return [String]
    def recv_certificate_verify(cipher)
      cv, orig_msg \
        = @connection.recv_message(receivable_ccs: true, cipher: cipher)
      @connection.terminate(:unexpected_message) \
        unless cv.is_a?(Message::CertificateVerify)

      [cv, orig_msg]
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    #
    # @raise [TTTLS13::Error::ErrorAlerts]
    #
    # @return [TTTLS13::Message::Finished]
    # @return [String]
    def recv_finished(cipher)
      sf, orig_msg \
        = @connection.recv_message(receivable_ccs: true, cipher: cipher)
      @connection.terminate(:unexpected_message) \
        unless sf.is_a?(Message::Finished)

      [sf, orig_msg]
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    #
    # @return [TTTLS13::Message::Finished]
    def send_finished(signature, cipher)
      cf = Message::Finished.new(signature)
      @connection.send_handshakes(
        Message::ContentType::APPLICATION_DATA,
        [cf],
        cipher
      )

      cf
    end

    # @param cipher [TTTLS13::Cryptograph::Aead]
    #
    # @return [TTTLS13::Message::EndOfEarlyData]
    def send_eoed(cipher)
      eoed = Message::EndOfEarlyData.new
      @connection.send_handshakes(
        Message::ContentType::APPLICATION_DATA,
        [eoed],
        cipher
      )

      eoed
    end

    # @param ct [TTTLS13::Message::Certificate]
    # @param ch [TTTLS13::Message::ClientHello]
    #
    # @return [Symbol, nil] return key of ALERT_DESCRIPTION, if invalid
    def check_invalid_certificate(ct, ch)
      return :illegal_parameter unless ct.appearable_extensions?

      return :unsupported_extension \
        unless ct.certificate_list.map(&:extensions)
                 .all? { |e| (e.keys - ch.extensions.keys).empty? }

      return :certificate_unknown unless Endpoint.trusted_certificate?(
        ct.certificate_list,
        @settings[:ca_file],
        @hostname
      )

      if @settings[:check_certificate_status]
        ee = ct.certificate_list.first
        ocsp_response = ee.extensions[Message::ExtensionType::STATUS_REQUEST]
                         &.ocsp_response
        cert = ee.cert_data
        chain = ct.certificate_list[1..]&.map(&:cert_data)
        return :bad_certificate_status_response \
          unless satisfactory_certificate_status?(ocsp_response, cert, chain)
      end

      nil
    end

    # @param ct [TTTLS13::Message::Certificate]
    # @param cv [TTTLS13::Message::CertificateVerify]
    # @param hash [String]
    #
    # @return [Boolean]
    def verified_certificate_verify?(ct, cv, hash)
      public_key = ct.certificate_list.first.cert_data.public_key
      signature_scheme = cv.signature_scheme
      signature = cv.signature

      Endpoint.verified_certificate_verify?(
        public_key: public_key,
        signature_scheme: signature_scheme,
        signature: signature,
        context: 'TLS 1.3, server CertificateVerify',
        hash: hash
      )
    end

    # @param ocsp_response [OpenSSL::OCSP::Response]
    # @param cert [OpenSSL::X509::Certificate]
    # @param chain [Array of OpenSSL::X509::Certificate, nil]
    #
    # @return [Boolean]
    def satisfactory_certificate_status?(ocsp_response, cert, chain)
      @settings[:process_certificate_status]&.call(ocsp_response, cert, chain)
    end

    # @param nst [TTTLS13::Message::NewSessionTicket]
    #
    # @raise [TTTLS13::Error::ErrorAlerts]
    def process_new_session_ticket(nst)
      rms = @resumption_secret
      cs = @cipher_suite
      @settings[:process_new_session_ticket]&.call(nst, rms, cs)
    end

    # @param cid [OpenSSL::OCSP::CertificateId]
    #
    # @return [OpenSSL::OCSP::Request]
    def gen_ocsp_request(cid)
      ocsp_request = OpenSSL::OCSP::Request.new
      ocsp_request.add_certid(cid)
      ocsp_request.add_nonce
      ocsp_request
    end
  end
  # rubocop: enable Metrics/ClassLength
end