duke-automation/varanus

View on GitHub
lib/varanus/ssl/csr.rb

Summary

Maintainability
A
35 mins
Test Coverage
A
96%
# frozen_string_literal: true

# Wrapper class around a OpenSSL::X509::Request
# Provides helper functions to make reading information from the CSR easier
class Varanus::SSL::CSR
  # Key size used when calling {.generate}
  DEFAULT_KEY_SIZE = 4096

  # Generate a CSR
  # @param names [Array<String>] List of DNS names.  The first one will be the CN
  # @param key [OpenSSL::PKey::RSA, OpenSSL::PKey::DSA, nil] Secret key for the cert.
  #   A DSA key will be generated if +nil+ is passed in.
  # @param subject [Hash] Options for the subject of the cert.  By default only CN will
  #   be set
  # @return [Array(OpenSSL::PKey::PKey, Varanus::SSL::CSR)] The private key for the cert
  #   and CSR
  def self.generate names, key = nil, subject = {}
    raise ArgumentError, 'names cannot be empty' if names.empty?

    subject = subject.dup
    subject['CN'] = names.first

    key ||= OpenSSL::PKey::DSA.new(DEFAULT_KEY_SIZE)

    request = OpenSSL::X509::Request.new
    request.version = 0
    request.subject = OpenSSL::X509::Name.parse subject.map { |k, v| "/#{k}=#{v}" }.join
    request.add_attribute names_to_san_attribute(names)
    if key.is_a? OpenSSL::PKey::EC
      request.public_key = key
    else
      request.public_key = key.public_key
    end

    request.sign(key, OpenSSL::Digest.new('SHA256'))

    [key, new(request)]
  end

  # :nodoc:
  # Create a Subject Alternate Names attribute from an Array of dns names
  def self.names_to_san_attribute names
    ef = OpenSSL::X509::ExtensionFactory.new
    name_str = names.map { |n| "DNS:#{n}" }.join(', ')
    ext = ef.create_extension('subjectAltName', name_str, false)
    seq = OpenSSL::ASN1::Sequence([ext])
    ext_req = OpenSSL::ASN1::Set([seq])
    OpenSSL::X509::Attribute.new('extReq', ext_req)
  end

  # Common Name (CN) for cert.
  # @return [String]
  attr_reader :cn

  # OpenSSL::X509::Request representation of CSR
  # @return [OpenSSL::X509::Request]
  attr_reader :request

  # @param csr [String, OpenSSL::X509::Request]
  def initialize csr
    if csr.is_a? OpenSSL::X509::Request
      @request = csr
      @text = csr.to_s
    else
      @text = csr.to_s
      @request = OpenSSL::X509::Request.new @text
    end

    raise 'Improperly signed CSR' unless @request.verify @request.public_key

    cn_ref = @request.subject.to_a.find { |a| a[0] == 'CN' }
    @cn = cn_ref && cn_ref[1].downcase

    _parse_sans

    # If we have no CN or SAN, raise an error
    raise 'CSR must have a CN and/or subjectAltName' if @cn.nil? && @sans.empty?
  end

  # Unique list of all DNS names for cert (CN and subject alt names)
  # @return [Array<String>]
  def all_names
    ([@cn] + @sans).compact.uniq
  end

  # Key size for the cert
  # @return [Integer]
  def key_size
    case @request.public_key
    when OpenSSL::PKey::RSA
      @request.public_key.n.num_bytes * 8
    when OpenSSL::PKey::DSA
      @request.public_key.p.num_bytes * 8
    when OpenSSL::PKey::EC
      @request.public_key.group.degree
    else
      raise "Unknown public key type: #{@request.public_key.class}"
    end
  end

  # PEM format for cert
  def to_s
    @text
  end

  # DNS subject alt names
  # @return [Array<String>]
  def subject_alt_names
    @sans
  end

  private

  def _parse_sans
    extensions = @request.attributes.select { |at| at.oid == 'extReq' }
    sans_extensions = extensions.flat_map do |extension|
      extension.value.value[0].value
               .select { |ext| ext.first.value == 'subjectAltName' }
               .map { |ext| ext.value.last }
    end
    @sans = sans_extensions.compact.flat_map do |san|
      _parse_sans_extension san
    end
  end

  def _parse_sans_extension ext
    OpenSSL::ASN1.decode(ext.value).map do |s_entry|
      unless s_entry.tag == 2 && s_entry.tag_class == :CONTEXT_SPECIFIC
        raise "unknown tag #{s_entry.tag}"
      end

      s_entry.value.downcase
    end
  end
end