|
- require 'socket'
- require 'openssl'
- require 'httparty'
-
- module CryptCheck
- module Tls
- class Server
- TCP_TIMEOUT = 10
- SSL_TIMEOUT = 2*TCP_TIMEOUT
- EXISTING_METHODS = %i(TLSv1_2 TLSv1_1 TLSv1 SSLv3 SSLv2)
- SUPPORTED_METHODS = ::OpenSSL::SSL::SSLContext::METHODS
-
- class TLSException < ::StandardError
- end
- class TLSNotAvailableException < TLSException
- def to_s
- 'TLS seems not supported on this server'
- end
- end
- class MethodNotAvailable < TLSException
- end
- class CipherNotAvailable < TLSException
- end
- class InappropriateFallback < TLSException
- end
- class Timeout < ::StandardError
- end
- class TLSTimeout < Timeout
- end
- class ConnectionError < ::StandardError
- end
-
- attr_reader :family, :ip, :port, :hostname, :prefered_ciphers, :cert, :cert_valid, :cert_trusted, :dh
-
- def initialize(hostname, family, ip, port)
- @hostname, @family, @ip, @port = hostname, family, ip, port
- @dh = []
- @chains = []
- Logger.info { name.colorize :blue }
- fetch_prefered_ciphers
- check_supported_cipher
- verify_certs
- check_fallback_scsv
- uniq_dh
- end
-
- def cipher_size
- supported_ciphers.collect { |c| c.size }.min
- end
-
- def supported_protocols
- @supported_ciphers.keys
- end
-
- def supported_ciphers
- @supported_ciphers.values.flatten 1
- end
-
- def supported_ciphers_by_protocol(protocol)
- @supported_ciphers[protocol]
- end
-
- EXISTING_METHODS.each do |method|
- class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
- def #{method.to_s.downcase}?
- !prefered_ciphers[:#{method}].nil?
- end
- RUBY_EVAL
- end
-
- def key_status
- Status[@keys]
- end
-
- def dh_status
- Status[@dh]
- end
-
- SIGNATURE_ALGORITHMS = {
- 'dsaWithSHA' => %i(sha1 dss),
- 'dsaWithSHA1' => %i(sha1 dss),
- 'dsaWithSHA1_2' => %i(sha1 dss),
- 'dsa_with_SHA224' => %i(sha2 dss),
- 'dsa_with_SHA256' => %i(sha2 dss),
-
- 'mdc2WithRSA' => %i(mdc2 rsa),
-
- 'md2WithRSAEncryption' => %i(md2 rsa),
-
- 'md4WithRSAEncryption' => %i(md4, rsa),
-
- 'md5WithRSA' => %i(md5 rsa),
- 'md5WithRSAEncryption' => %i(md5 rsa),
-
- 'shaWithRSAEncryption' => %i(sha rsa),
- 'sha1WithRSA' => %i(sha1 rsa),
- 'sha1WithRSAEncryption' => %i(sha1 rsa),
- 'sha224WithRSAEncryption' => %i(sha2 rsa),
- 'sha256WithRSAEncryption' => %i(sha2 rsa),
- 'sha384WithRSAEncryption' => %i(sha2 rsa),
- 'sha512WithRSAEncryption' => %i(sha2 rsa),
-
- 'ripemd160WithRSA' => %i(ripemd160 rsa),
-
- 'ecdsa-with-SHA1' => %i(sha1 ecc),
- 'ecdsa-with-SHA224' => %i(sha2 ecc),
- 'ecdsa-with-SHA256' => %i(sha2 ecc),
- 'ecdsa-with-SHA384' => %i(sha2 ecc),
- 'ecdsa-with-SHA512' => %i(sha2 ecc),
-
- 'id_GostR3411_94_with_GostR3410_2001' => %i(ghost),
- 'id_GostR3411_94_with_GostR3410_94' => %i(ghost),
- 'id_GostR3411_94_with_GostR3410_94_cc' => %i(ghost),
- 'id_GostR3411_94_with_GostR3410_2001_cc' => %i(ghost)
- }
-
- %i(md2 mdc2 md4 md5 ripemd160 sha sha1 sha2 rsa dss ecc ghost).each do |name|
- class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
- def #{name}_sig?
- @chains.any? do |chain|
- SIGNATURE_ALGORITHMS[chain[:cert].signature_algorithm].include? :#{name}
- end
- end
- RUBY_EVAL
- end
-
- Cipher::TYPES.each do |type, _|
- class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
- def #{type}?
- supported_ciphers.any? { |c| c.#{type}? }
- end
- RUBY_EVAL
- end
-
- def ssl?
- sslv2? or sslv3?
- end
-
- def tls?
- tlsv1? or tlsv1_1? or tlsv1_2?
- end
-
- def tls_only?
- tls? and !ssl?
- end
-
- def tlsv1_2_only?
- tlsv1_2? and not ssl? and not tlsv1? and not tlsv1_1?
- end
-
- def pfs?
- supported_ciphers.any? { |c| c.pfs? }
- end
-
- def pfs_only?
- supported_ciphers.all? { |c| c.pfs? }
- end
-
- def ecdhe?
- supported_ciphers.any? { |c| c.ecdhe? }
- end
-
- def ecdhe_only?
- supported_ciphers.all? { |c| c.ecdhe? }
- end
-
- def aead?
- supported_ciphers.any? { |c| c.aead? }
- end
-
- def aead_only?
- supported_ciphers.all? { |c| c.aead? }
- end
-
- def sweet32?
- supported_ciphers.any? { |c| c.sweet32? }
- end
-
- def fallback_scsv?
- @fallback_scsv
- end
-
- def must_staple?
- @cert.extensions.any? { |e| e.oid == '1.3.6.1.5.5.7.1.24' }
- end
-
- private
- def name
- name = "#@ip:#@port"
- name += " [#@hostname]" if @hostname
- name
- end
-
- def connect(&block)
- socket = ::Socket.new @family, sock_type
- sockaddr = ::Socket.sockaddr_in @port, @ip
- #Logger.trace { "Connecting to #{@ip}:#{@port}" }
- begin
- status = socket.connect_nonblock sockaddr
- #Logger.trace { "Connecting to #{@ip}:#{@port} status : #{status}" }
- raise ConnectionError, status unless status == 0
- #Logger.trace { "Connected to #{@ip}:#{@port}" }
- block_given? ? block.call(socket) : nil
- rescue ::IO::WaitReadable
- #Logger.trace { "Waiting for read to #{@ip}:#{@port}" }
- raise Timeout, "Timeout when connect to #{@ip}:#{@port} (max #{TCP_TIMEOUT.humanize})" unless IO.select [socket], nil, nil, TCP_TIMEOUT
- retry
- rescue ::IO::WaitWritable
- #Logger.trace { "Waiting for write to #{@ip}:#{@port}" }
- raise Timeout, "Timeout when connect to #{@ip}:#{@port} (max #{TCP_TIMEOUT.humanize})" unless IO.select nil, [socket], nil, TCP_TIMEOUT
- retry
- ensure
- socket.close
- end
- end
-
- def ssl_connect(socket, context, method, &block)
- ssl_socket = ::OpenSSL::SSL::SSLSocket.new socket, context
- ssl_socket.hostname = @hostname if @hostname and method != :SSLv2
- #Logger.trace { "SSL connecting to #{name}" }
- begin
- ssl_socket.connect_nonblock
- #Logger.trace { "SSL connected to #{name}" }
- return block_given? ? block.call(ssl_socket) : nil
- rescue ::OpenSSL::SSL::SSLErrorWaitReadable
- #Logger.trace { "Waiting for SSL read to #{name}" }
- raise TLSTimeout, "Timeout when TLS connect to #{@ip}:#{@port} (max #{SSL_TIMEOUT.humanize})" unless IO.select [ssl_socket], nil, nil, SSL_TIMEOUT
- retry
- rescue ::OpenSSL::SSL::SSLErrorWaitWritable
- #Logger.trace { "Waiting for SSL write to #{name}" }
- raise TLSTimeout, "Timeout when TLS connect to #{@ip}:#{@port} (max #{SSL_TIMEOUT.humanize})" unless IO.select nil, [ssl_socket], nil, SSL_TIMEOUT
- retry
- rescue ::OpenSSL::SSL::SSLError => e
- case e.message
- when /state=SSLv.* read server hello A$/
- raise TLSNotAvailableException, e
- when /state=SSLv.* read server hello A: wrong version number$/
- raise MethodNotAvailable, e
- when /state=error: no ciphers available$/,
- /state=SSLv.* read server hello A: sslv.* alert handshake failure$/
- raise CipherNotAvailable, e
- when /state=SSLv.* read server hello A: tlsv.* alert inappropriate fallback$/
- raise InappropriateFallback, e
- end
- raise
- rescue ::SystemCallError => e
- case e.message
- when /^Connection reset by peer - SSL_connect$/
- raise TLSNotAvailableException, e
- end
- raise
- ensure
- ssl_socket.close
- end
- end
-
- # SUPPORTED_CURVES = %w(sect163k1 sect163r1 sect163r2 sect193r1
- # sect193r2 sect233k1 sect233r1 sect239k1 sect283k1 sect283r1
- # sect409k1 sect409r1 sect571k1 sect571r1 secp160k1 secp160r1
- # secp160r2 secp192k1 secp192r1 secp224k1 secp224r1 secp256k1
- # secp256r1 secp384r1 secp521r1
- # prime256v1
- # brainpoolP256r1 brainpoolP384r1 brainpoolP512r1)
- SUPPORTED_CURVES = %w(secp256k1 sect283k1 sect283r1 secp384r1
- sect409k1 sect409r1 secp521r1 sect571k1 sect571r1
- prime192v1 prime256v1
- brainpoolP256r1 brainpoolP384r1 brainpoolP512r1)
-
- def ssl_client(method, ciphers = %w(ALL COMPLEMENTOFALL), curves = nil, fallback: false, &block)
- ssl_context = ::OpenSSL::SSL::SSLContext.new method
- ssl_context.enable_fallback_scsv if fallback
- ssl_context.ciphers = ciphers.join ':'
-
- ssl_context.ecdh_curves = curves.join ':' if curves
- #ssl_context.ecdh_auto = false
- #ecdh = OpenSSL::PKey::EC.new('sect163r1').generate_key
- #ssl_context.tmp_ecdh_callback = proc { ecdh }
-
- Logger.trace { "Try method=#{method} / ciphers=#{ciphers} / curves=#{curves} / scsv=#{fallback}" }
- connect do |socket|
- ssl_connect socket, ssl_context, method do |ssl_socket|
- return block_given? ? block.call(ssl_socket) : nil
- end
- end
- end
-
- def verify_certs
- Logger.info { '' }
-
- view = {}
- @chains.each do |cert, chain|
- id = cert.subject, cert.serial, cert.issuer
- next if view.include? id
- subject, serial, issuer = id
- key = cert.public_key
-
- Logger.info { "Certificate #{subject} [#{serial}] issued by #{issuer}" }
- Logger.info { "Key : #{Tls.key_to_s key }" }
- valid = ::OpenSSL::SSL.verify_certificate_identity cert, (@hostname || @ip)
- trusted = verify_trust chain, cert
- view[id] = { cert: cert, chain: chain, key: key, valid: valid, trusted: trusted }
- end
- @chains = view.values
- @keys = @chains.collect { |c| c[:key] }
- end
-
- def prefered_cipher(method)
- cipher = ssl_client(method) { |s| Cipher.new method, s.cipher, s.tmp_key }
- Logger.info { "Prefered cipher for #{Tls.colorize method} : #{cipher.colorize}" }
- cipher
- rescue => e
- Logger.debug { "Method #{Tls.colorize method} not supported : #{e}" }
- nil
- end
-
- def fetch_prefered_ciphers
- @prefered_ciphers = {}
- EXISTING_METHODS.each do |method|
- next unless SUPPORTED_METHODS.include? method
- @prefered_ciphers[method] = prefered_cipher method
- end
- raise TLSNotAvailableException unless @prefered_ciphers.any? { |_, c| !c.nil? }
- end
-
- def available_ciphers(method)
- context = ::OpenSSL::SSL::SSLContext.new method
- context.ciphers = %w(ALL COMPLEMENTOFALL)
- context.ciphers
- end
-
- def supported_cipher?(method, cipher, curves = nil)
- cert, chain, dh = ssl_client(method, [cipher], curves) do |s|
- [s.peer_cert, s.peer_cert_chain, s.tmp_key]
- end
- @chains << [cert, chain]
- @dh << dh if dh
- p dh.group.curve_name
- cipher = Cipher.new method, cipher, dh
- dh = dh ? " (#{'PFS'.colorize :good} : #{Tls.key_to_s dh})" : ''
-
- states = cipher.states
- text = %i(critical error warning good perfect best).collect do |s|
- states[s].collect { |t| t.to_s.colorize s }.join ' '
- end.reject &:empty?
- text = text.join ' '
-
- Logger.info { "#{Tls.colorize method} / #{cipher.colorize}#{dh} [#{text}]" }
-
- cipher
- rescue => e
- cipher = Cipher.new method, cipher
- Logger.debug { "#{Tls.colorize method} / #{cipher.colorize} : Not supported (#{e})" }
- nil
- end
-
- def check_supported_cipher
- Logger.info { '' }
- @supported_ciphers = {}
- EXISTING_METHODS.each do |method|
- next unless SUPPORTED_METHODS.include? method and @prefered_ciphers[method]
- supported_ciphers = []
-
- available_ciphers = available_ciphers method
- available_ciphers.each do |c|
- cipher = Cipher.new method, c
- supported = supported_cipher? method, c.first
- if supported
- if cipher.ecdhe?
- SUPPORTED_CURVES.each do |curve|
- supported = supported_cipher? method, c.first, [curve]
- supported_ciphers << supported if supported
- end
- else
- supported_ciphers << supported
- end
- end
- end
- @supported_ciphers[method] = supported_ciphers
- end
- end
-
- def check_fallback_scsv
- Logger.info { '' }
- @fallback_scsv = false
-
- methods = @prefered_ciphers.reject { |_, v| v.nil? }.keys
- if methods.size > 1
- # We will try to connect to the not better supported method
- method = methods[1]
-
- begin
- ssl_client method, fallback: true
- rescue InappropriateFallback
- @fallback_scsv = true
- end
- else
- @fallback_scsv = nil
- end
-
- text, color = case @fallback_scsv
- when true
- ['Supported', :good]
- when false
- ['Not supported', :error]
- when nil
- ['Not applicable', :unknown]
- end
- Logger.info { "Fallback SCSV : #{text.colorize color}" }
- end
-
- def verify_trust(chain, cert)
- store = ::OpenSSL::X509::Store.new
- store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT
- store.set_default_paths
-
- %w(/etc/ssl/certs).each do |directory|
- ::Dir.glob(::File.join directory, '*.pem').each do |file|
- cert = ::OpenSSL::X509::Certificate.new ::File.read file
- begin
- store.add_cert cert
- rescue ::OpenSSL::X509::StoreError
- end
- end
- end
- chain.each do |cert|
- begin
- store.add_cert cert
- rescue ::OpenSSL::X509::StoreError
- end
- end
- trusted = store.verify cert
- p store.error_string unless trusted
- trusted
- end
-
- def uniq_dh
- dh, find = [], []
- @dh.each do |k|
- f = [k.type, k.size]
- unless find.include? f
- dh << k
- find << f
- end
- end
- @dh = dh
- end
- end
-
- class TcpServer < Server
- private
- def sock_type
- ::Socket::SOCK_STREAM
- end
- end
-
- class UdpServer < Server
- private
- def sock_type
- ::Socket::SOCK_DGRAM
- end
- end
- end
- end
|