class Damnx509::CLI

Constants

CAS
CLI
YAML_FILE

Public Instance Methods

init(name) click to toggle source
# File lib/damnx509.rb, line 14
def init(name)
  if File.exist?(name)
    puts "#{name} already exists in the current directory."
    return false
  end
  Dir.mkdir(name)
  subject = _ask_subject
  csr = R509::CSR.new(
    type: 'RSA',
    bit_length: 4096,
    subject: subject,
    san_names: _to_san(subject) + _ask_san
  )
  key_filename = "#{name}/root.key.pem"
  _write_with_password(key_filename, csr.key)
  cert_filename = "#{name}/root.cert.pem"
  ext = []
  crl_uri = CLI.ask('CRL URI?')
  ext << R509::Cert::Extensions::CRLDistributionPoints.new(value: [{type: 'URI', value: crl_uri}]) unless crl_uri.empty?
  cert = R509::CertificateAuthority::Signer.selfsign(
    csr: csr,
    extensions: ext,
    not_before: Time.now.to_i,
    not_after: Time.now.to_i + _ask_duration
  )
  File.write(cert_filename, cert.to_pem)
  crl_list_filename = "#{name}/crl.list.txt"
  File.write(crl_list_filename, '')
  crl_number_filename = "#{name}/crl.number.txt"
  File.write(crl_number_filename , '')
  conf = File.exist?(YAML_FILE) ? YAML.load_file(YAML_FILE) : {}
  conf['default_ca'] ||= name
  conf[CAS] ||= {}
  conf[CAS][name] ||= {}
  conf[CAS][name]['ca_cert'] = { 'cert' => cert_filename, 'key' => key_filename }
  conf[CAS][name]['crl_md'] = 'SHA256'
  conf[CAS][name]['crl_validity_hours'] = 24 * 365
  conf[CAS][name]['crl_start_skew_seconds'] = 30
  conf[CAS][name]['crl_list_file'] = crl_list_filename
  conf[CAS][name]['crl_number_file'] = crl_number_filename
  File.write(YAML_FILE, conf.to_yaml)
end
issue(name, ca=nil) click to toggle source
# File lib/damnx509.rb, line 58
def issue(name, ca=nil)
  ca ||= ca || _conf['default_ca']
  ca_config = _ca_config(ca)
  if !ca_config
    puts "CA #{ca} not found."
    return false
  end
  subj_defaults = Hash[ca_config.ca_cert.cert.subject.to_a.map { |e| [e[0], e[1]] }]
  ext = []

  ext << R509::Cert::Extensions::BasicConstraints.new(:ca => false)
  CLI.choose do |menu|
    menu.prompt = 'Certificate usage?'
    menu.choice('TLS (HTTPS/SMTPS/IMAPS/OpenVPN/WPA2 EAP-TLS/etc.) Server') {
      ext << R509::Cert::Extensions::ExtendedKeyUsage.new(value: ['serverAuth'])
    }
    menu.choice('TLS (HTTPS/SMTPS/IMAPS/OpenVPN/WPA2 EAP-TLS/etc.) Client') {
      ext << R509::Cert::Extensions::ExtendedKeyUsage.new(value: ['clientAuth'])
    }
    menu.choice('Code Signing') {
      ext << R509::Cert::Extensions::ExtendedKeyUsage.new(value: ['codeSigning'])
    }
    menu.choice('E-mail Protection') {
      ext << R509::Cert::Extensions::ExtendedKeyUsage.new(value: ['emailProtection'])
    }
  end

  cert_type = 'RSA'
  CLI.choose do |menu|
    menu.prompt = 'Signature algorithm?'
    menu.choice('RSA') {}
    menu.choice('EC') { cert_type = 'EC' }
  end
  bit_length = nil
  CLI.choose do |menu|
    menu.prompt = 'Key length?'
    menu.choice('2048') { bit_length = 2048 }
    menu.choice('4096') { bit_length = 4096 }
  end if cert_type == 'RSA'

  crl_ext_p = (ca_config.ca_cert.cert.extensions || []).find { |e| e.oid == 'crlDistributionPoints' }
  crl_uri = CLI.ask('CRL URI?') { |q| q.default = crl_ext_p && crl_ext_p.value.gsub(/\n[^:]+:/, '').strip }
  ext << R509::Cert::Extensions::CRLDistributionPoints.new(value: [{type: 'URI', value: crl_uri}]) unless crl_uri.empty?

  subject = _ask_subject(subj_defaults)
  csr = R509::CSR.new(
    type: cert_type,
    bit_length: bit_length,
    subject: subject,
    san_names: _to_san(subject) + _ask_san
  )
  ext << R509::Cert::Extensions::SubjectAlternativeName.new(value: csr.san)
  Dir.mkdir("#{ca}/issued") unless File.directory?("#{ca}/issued")
  key_filename = "#{ca}/issued/#{name}.key.pem"
  password = _write_with_password(key_filename, csr.key)
  signer = R509::CertificateAuthority::Signer.new(ca_config)
  cert = signer.sign(
    csr: csr,
    extensions: ext,
    not_before: Time.now.to_i,
    not_after: Time.now.to_i + _ask_duration
  )
  cert_filename = "#{ca}/issued/#{name}.cert.pem"
  File.write(cert_filename, cert.to_pem)
  unless password.empty?
    p12_filename = "#{ca}/issued/#{name}.p12"
    cert.write_pkcs12(p12_filename, password, "#{name} cert+key signed by #{ca}")
    puts "Wrote #{cert_filename}, #{key_filename}, #{p12_filename}."
  else
    puts "Wrote #{cert_filename}, #{key_filename}."
  end
end
revoke(serial, ca=nil) click to toggle source
# File lib/damnx509.rb, line 132
def revoke(serial, ca=nil)
  # TODO: revoke from file
  ca ||= ca || _conf['default_ca']
  ca_config = _ca_config(ca)
  if !ca_config
    puts "CA #{ca} not found."
    return false
  end
  admin = R509::CRL::Administrator.new(ca_config)
  ser = serial.gsub(':', '').to_i(16)
  admin.revoke_cert(ser)
  crl = admin.generate_crl
  crl_filename = "#{ca}/crl.pem"
  crl.write_pem(crl_filename)
  puts "Wrote #{crl_filename}."
end

Private Instance Methods

_ask_duration() click to toggle source
# File lib/damnx509.rb, line 184
def _ask_duration
  ChronicDuration.parse(CLI.ask('Expires in (natural input):') { |q| q.default = '365d'}, keep_zero: true)
end
_ask_password() click to toggle source
# File lib/damnx509.rb, line 188
def _ask_password
  CLI.ask('Private key password (empty to skip key encryption):') { |q| q.echo = '*' }
end
_ask_san() click to toggle source
# File lib/damnx509.rb, line 171
def _ask_san
  result = []
  while cur = CLI.ask("SAN - Subject Alternative Name (enter one; type is automatically recognized, don't write 'DNS' etc.; empty to #{result.empty? ? 'skip' : 'stop'}):")
    break if cur.empty?
    result << cur
  end
  result
end
_ask_subject(defaults=nil) click to toggle source
# File lib/damnx509.rb, line 160
def _ask_subject(defaults=nil)
  [
    ['C',  CLI.ask('C   - Country (2 letter code):') { |q| q.default = defaults && defaults['C'] }],
    ['ST', CLI.ask('ST  - State or Province (full name):') { |q| q.default = defaults && defaults['ST'] }],
    ['L',  CLI.ask('L   - Locality (e.g. city):') { |q| q.default = defaults && defaults['L'] }],
    ['O',  CLI.ask('O   - Organization (e.g. company):') { |q| q.default = defaults && defaults['O'] }],
    ['OU', CLI.ask('OU  - Organizational Unit (e.g. section):') { |q| q.default = defaults && defaults['OU'] }],
    ['CN', CLI.ask('CN  - Common Name (e.g. fully qualified host name):')]
  ]
end
_ca_config(ca_name) click to toggle source
# File lib/damnx509.rb, line 154
def _ca_config(ca_name)
  @ca_config ||= R509::Config::CAConfig.load_from_hash(_conf[CAS][ca_name])
rescue ArgumentError
  nil
end
_conf() click to toggle source
# File lib/damnx509.rb, line 150
def _conf
  @conf ||= YAML.load_file(YAML_FILE)
end
_to_san(subject) click to toggle source
# File lib/damnx509.rb, line 180
def _to_san(subject)
  [Hash[subject]['CN']]
end
_write_with_password(key_filename, key) click to toggle source
# File lib/damnx509.rb, line 192
def _write_with_password(key_filename, key)
  password = _ask_password
  if password.empty?
    key.write_pem(key_filename)
  else
    key.write_encrypted_pem(key_filename, 'aes256', password)
  end
  password
end