class LetsCert::Certificate

Class to handle ACME operations on certificates @author Sylvain Daubert

Attributes

cert[R]

@return [OpenSSL::X509::Certificate,nil]

chain[R]

Certification chain. Only set by {#get}. @return [Array<OpenSSL::X509::Certificate>]

client[R]

@return [Acme::Client,nil]

Public Class Methods

new(cert) click to toggle source

@param [OpenSSL::X509::Certificate,nil] cert

# File lib/letscert/certificate.rb, line 44
def initialize(cert)
  @cert = cert
  @chain = []
end

Public Instance Methods

get(account_key, key, options) click to toggle source

Get a new certificate, or renew an existing one @param [OpenSSL::PKey::PKey,nil] account_key private key to

authenticate to ACME server

@param [OpenSSL::PKey::PKey, nil] key private key from which make a

certificate. If +nil+, generate a new one with +options[:cert_key_size]+
bits.

@param [Hash] options option hash @option options [Fixnum] :account_key_size ACME account private key size

in bits

@option options [Fixnum] :cert_key_size private key size used to generate

a certificate

@option options [String] :email e-mail used as ACME account @option options [Array<String>] :files plugin names to use @option options [Boolean] :reuse_key reuse private key when getting a new

certificate

@option options [Hash] :roots hash associating domains as keys to web

roots as values

@option options [String] :server ACME servel URL @return [void] @raise [Acme::Client::Error] error in protocol ACME with server @raise [Error] issue with domain name, challenge fails,…

# File lib/letscert/certificate.rb, line 70
def get(account_key, key, options)
  logger.info { 'create key/cert/chain...' }
  check_roots(options[:roots])
  logger.debug { "webroots are: #{options[:roots].inspect}" }

  account_key = get_account_key(account_key, options[:account_key_type],
                                options[:account_key_size])

  client = get_acme_client(account_key, options)

  do_challenges client, options[:roots]

  pkey = if options[:reuse_key]
           raise Error, 'cannot reuse a non-existing key' if key.nil?
           logger.info { 'Reuse existing private key' }
           generate_certificate_from_pkey options[:roots].keys, key
         else
           logger.info { 'Generate new private key' }
           generate_certificate options[:roots].keys,
                                options
         end

  options[:files] ||= []
  options[:files].each do |plugname|
    IOPlugin.registered[plugname].save(account_key: account_key,
                                       key: pkey, cert: @cert,
                                       chain: @chain)
  end
end
get_acme_client(account_key, options) { |client| ... } click to toggle source

Get ACME client.

Client is only created on first call, then it is cached. @param [Hash] account_key @param [Hash] options @return [Acme::Client]

# File lib/letscert/certificate.rb, line 160
def get_acme_client(account_key, options)
  return @client if @client

  logger.debug { "connect to #{options[:server]}" }
  @client = Acme::Client.new(private_key: account_key, endpoint: options[:server])

  yield @client if block_given?

  if options[:email].nil?
    logger.warn { '--email was not provided. ACME CA will have no way to ' \
                  'contact you!' }
  end

  begin
    logger.debug { "register with #{options[:email]}" }
    registration = @client.register(contact: "mailto:#{options[:email]}")
  rescue Acme::Client::Error::Malformed => ex
    raise if ex.message != 'Registration key is already in use'
  else
    # Requesting ToS make acme-client throw an exception: Connection reset
    # by peer (Faraday::ConnectionFailed). To investigate...
    #if registration.term_of_service_uri
    #  @logger.debug { "get terms of service" }
    #  terms = registration.get_terms
    #  if !terms.nil?
    #    tos_digest = OpenSSL::Digest::SHA256.digest(terms)
    #    if tos_digest != @options[:tos_sha256]
    #      raise Error, 'Terms Of Service mismatch'
    #    end
         @logger.debug { 'agree terms of service' }
         registration.agree_terms
    #  end
    #end
  end

  @client
end
revoke(account_key, options = {}) click to toggle source

Revoke certificate @param [OpenSSL::PKey::PKey] account_key @param [Hash] options @option options [Fixnum] :account_key_size ACME account private key size

in bits

@option options [String] :email e-mail used as ACME account @option options [String] :server ACME servel URL @return [Boolean] @raise [Error] no certificate to revole.

# File lib/letscert/certificate.rb, line 109
def revoke(account_key, options = {})
  raise Error, 'no certification data to revoke' if @cert.nil?

  client = get_acme_client(account_key, options)
  result = client.revoke_certificate(@cert)

  if result
    logger.info { 'certificate is revoked' }
  else
    logger.warn { 'certificate is not revoked!' }
  end

  result
end
valid?(domains, valid_min = 0) click to toggle source

Check if certificate is still valid for at least valid_min seconds. Also checks that domains are certified by certificate. @param [Array<String>] domains list of certificate domains @param [Integer] valid_min minimum number of seconds of validity under

which a renewal is necessary.

@return [Boolean]

# File lib/letscert/certificate.rb, line 130
def valid?(domains, valid_min = 0)
  if @cert.nil?
    logger.debug { 'no existing certificate' }
    return false
  end

  subjects = []
  @cert.extensions.each do |ext|
    if ext.oid == 'subjectAltName'
      subjects += ext.value.split(/,\s*/).map { |s| s.sub(/DNS:/, '') }
    end
  end
  logger.debug { "cert SANs: #{subjects.join(', ')}" }

  # Check all domains are subjects of certificate
  unless domains.all? { |domain| subjects.include? domain }
    msg = 'At least one domain is not declared as a certificate subject. ' \
          'Backup and remove existing cert if you want to proceed.'
    raise Error, msg
  end

  !renewal_necessary?(valid_min)
end

Private Instance Methods

check_roots(roots) click to toggle source

check webroots. @param [Hash] roots @raise [Error] if some domains have no defined root.

# File lib/letscert/certificate.rb, line 203
def check_roots(roots)
  no_roots = roots.select { |_k, v| v.nil? }

  # rubocop:disable Style/GuardClause
  unless no_roots.empty?
    raise Error, 'root for the following domain(s) are not specified: ' \
                 "#{no_roots.keys.join(', ')}.\nTry --default_root or " \
                 'use -d example.com:/var/www/html syntax.'
  end
end
do_challenges(client, roots) click to toggle source

Do ACME challenges for each requested domain. @param [Acme::Client] client @param [Hash] roots

# File lib/letscert/certificate.rb, line 246
def do_challenges(client, roots)
  logger.debug { 'Get authorization for all domains' }
  challenges = get_challenges(client, roots)

  challenges.each do |domain, challenge|
    begin
      path = File.join(roots[domain], File.dirname(challenge.filename))
      FileUtils.mkdir_p path
    rescue SystemCallError => ex
      raise Error, ex.message
    end

    path = File.join(roots[domain], challenge.filename)
    logger.debug { "Save validation #{challenge.file_content} to #{path}" }
    File.write path, challenge.file_content

    challenge.request_verification
    wait_for_verification challenge, domain

    File.unlink path
  end
end
generate_certificate(domains, options) click to toggle source

Generate new certificate for given domains @param [Array<String>] domains @param [Hash] options option hash containing :cert_ecdsa, :cert_rsa

or +:cert_key_size+ key.

@return [OpenSSL::PKey::PKey] generated private key

# File lib/letscert/certificate.rb, line 389
def generate_certificate(domains, options)
  pkey = generate_key(options)
  generate_certificate_from_pkey domains, pkey
end
generate_certificate_from_pkey(domains, pkey) click to toggle source

Generate new certificate for given domains with existing private key @param [Array<String>] domains @param [OpenSSL::PKey::PKey] pkey private key to use @return [OpenSSL::PKey::PKey] pkey

# File lib/letscert/certificate.rb, line 372
def generate_certificate_from_pkey(domains, pkey)
  logger.debug { 'generate certificate request' }
  csr = Acme::Client::CertificateRequest.new(names: domains,
                                             private_key: pkey)
  logger.debug { 'requesting certificate...' }
  acme_cert = client.new_certificate(csr)
  @cert = acme_cert.x509
  @chain = acme_cert.x509_chain

  pkey
end
generate_ecdsa_key(curve) click to toggle source

Generate a ECDSA key @param [String] curve curve name @return [OpenSSL::PKey::EC]

# File lib/letscert/certificate.rb, line 344
def generate_ecdsa_key(curve)
  key = (PatchedECPkey.needed? ? PatchedECPkey : OpenSSL::PKey::EC).new
  key.group = OpenSSL::PKey::EC::Group.new(curve)
  key.generate_key
rescue OpenSSL::PKey::EC::Group::Error => ex
  raise unless ex.message =~ /^unknown curve/
  msg = "unknown curve. Supported curves are:\n"
  msg << secure_curves.join("\n")
  raise Error, msg
end
generate_key(options) click to toggle source

Generate a key from options @param [Hash] options :cert_ecdsa and :cert_rsa are mutually

exclusive.

@option options [Integer] :cert_ecdsa curve for which generate a cert @option options [Integer] :cert_rsa key size to generate a RSA key @return [OpenSSL::Pkey::PKey] @raise [Error]

# File lib/letscert/certificate.rb, line 327
def generate_key(options)
  if options[:cert_ecdsa] and options[:cert_rsa]
    raise Error, 'cannot generate a ECDSA key and a RSA key in one shot'
  end

  if options[:cert_ecdsa]
    logger.debug { "generate a #{options[:cert_ecdsa]}-bit ECDSA private key" }
    generate_ecdsa_key options[:cert_ecdsa]
  else
    logger.debug { "generate a #{options[:cert_rsa]}-bit RSA private key" }
    OpenSSL::PKey::RSA.generate options[:cert_rsa]
  end
end
get_account_key(key, key_type, key_size) click to toggle source

Generate a new account key if no one is given in data @param [OpenSSL::PKey,nil] key @param [String] key_type +'rsa'+ or +'ecdsa'+ @param [Integer] key_size @return [OpenSSL::PKey::PKey]

# File lib/letscert/certificate.rb, line 219
def get_account_key(key, key_type, key_size)
  if key.nil?
    logger.info { 'No account key. Generate a new one...' }
    case key_type
    when 'rsa'
      OpenSSL::PKey::RSA.new key_size
    when 'ecdsa'
      curve = case key_size
              when 256
                'prime256v1'
              when 384
                'secp384r1'
              else
                raise Error, 'ECDSA account key size: only 256 or 384 bits'
              end
      generate_ecdsa_key curve
    else
      raise Error, "unsupported '#{key_type}' account key type"
    end
  else
    key
  end
end
get_challenges(client, roots) click to toggle source

Get challenges @param [Acme::Client] client @param [Hash] roots @return [Hash] key: domain, value: authorization @raise [Error] if any challenges does not support HTTP-01

# File lib/letscert/certificate.rb, line 274
def get_challenges(client, roots)
  challenges = {}
  roots.keys.each do |domain|
    authorization = client.authorize(domain: domain)
    challenges[domain] = authorization ? authorization.http01 : nil
  end

  logger.debug { 'Check all challenges are HTTP-01' }
  if challenges.values.any?(&:nil?)
    raise Error, 'CA did not offer http-01-only challenge. ' \
                 'This client is unable to solve any other challenges.'
  end

  challenges
end
renewal_necessary?(valid_min) click to toggle source

Check if a renewal is necessary @param [Number] valid_min minimum validity in seconds to ensure @return [Boolean]

# File lib/letscert/certificate.rb, line 307
def renewal_necessary?(valid_min)
  now = Time.now.utc
  diff = (@cert.not_after - now).to_i

  if diff < valid_min
    true
  else
    logger.info { "Certificate expires in #{ValidTime.time_in_words diff}" \
                  " on #{@cert.not_after} (relative to #{now})" }
    false
  end
end
secure_curves() click to toggle source

Return array of secure curve names @return [Array<String>]

# File lib/letscert/certificate.rb, line 357
def secure_curves
  curves = OpenSSL::PKey::EC.builtin_curves.map { |ary| '%-20s%s' % ary }
  # Remove all binary curves, and prime curves which field is less than
  # 256 bits
  curves.reject! do |el|
    el =~ /binary/ or (el =~ /(\d+) bit/ and $1.to_i < 256)
  end

  curves
end
wait_for_verification(challenge, domain) click to toggle source
# File lib/letscert/certificate.rb, line 290
def wait_for_verification(challenge, domain)
  status = 'pending'
  while status == 'pending'
    sleep(1)
    status = challenge.verify_status
  end

  if status != 'valid'
    logger.warn { "#{domain} was not successfully verified!" }
  else
    logger.info { "#{domain} was successfully verified." }
  end
end