diff options
-rw-r--r-- | lib/leap_cli/commands/ca.rb | 493 | ||||
-rw-r--r-- | lib/leap_cli/commands/node.rb | 157 | ||||
-rw-r--r-- | lib/leap_cli/config/node.rb | 176 | ||||
-rw-r--r-- | lib/leap_cli/config/node_cert.rb | 124 | ||||
-rw-r--r-- | lib/leap_cli/util/x509.rb | 33 | ||||
-rw-r--r-- | lib/leap_cli/x509.rb | 16 | ||||
-rw-r--r-- | lib/leap_cli/x509/certs.rb | 232 | ||||
-rw-r--r-- | lib/leap_cli/x509/signing_profiles.rb | 104 | ||||
-rw-r--r-- | lib/leap_cli/x509/utils.rb | 26 |
9 files changed, 769 insertions, 592 deletions
diff --git a/lib/leap_cli/commands/ca.rb b/lib/leap_cli/commands/ca.rb index 6abffe7b..f998d0fe 100644 --- a/lib/leap_cli/commands/ca.rb +++ b/lib/leap_cli/commands/ca.rb @@ -1,8 +1,3 @@ -autoload :OpenSSL, 'openssl' -autoload :CertificateAuthority, 'certificate_authority' -autoload :Date, 'date' -require 'digest/md5' - module LeapCli; module Commands desc "Manage X.509 certificates" @@ -35,37 +30,10 @@ module LeapCli; module Commands cert.desc 'Creates a Diffie-Hellman parameter file, needed for forward secret OpenVPN ciphers.' # (needed for server-side of some TLS connections) cert.command :dh do |dh| dh.action do |global_options,options,args| - long_running do - if cmd_exists?('certtool') - log 0, 'Generating DH parameters (takes a long time)...' - output = assert_run!('certtool --generate-dh-params --sec-param high') - output.sub!(/.*(-----BEGIN DH PARAMETERS-----.*-----END DH PARAMETERS-----).*/m, '\1') - output << "\n" - write_file!(:dh_params, output) - else - log 0, 'Generating DH parameters (takes a REALLY long time)...' - output = OpenSSL::PKey::DH.generate(3248).to_pem - write_file!(:dh_params, output) - end - end + generate_dh end end - # - # hints: - # - # inspect CSR: - # openssl req -noout -text -in files/cert/x.csr - # - # generate CSR with openssl to see how it compares: - # openssl req -sha256 -nodes -newkey rsa:2048 -keyout example.key -out example.csr - # - # validate a CSR: - # http://certlogik.com/decoder/ - # - # nice details about CSRs: - # http://www.redkestrel.co.uk/Articles/CSR.html - # cert.desc "Creates a CSR for use in buying a commercial X.509 certificate." cert.long_desc "Unless specified, the CSR is created for the provider's primary domain. "+ "The properties used for this CSR come from `provider.ca.server_certificates`, "+ @@ -81,67 +49,7 @@ module LeapCli; module Commands csr.flag :bits, :arg_name => 'BITS', :desc => "Override default certificate bit length" csr.flag :digest, :arg_name => 'DIGEST', :desc => "Override default signature digest" csr.action do |global_options,options,args| - assert_config! 'provider.domain' - assert_config! 'provider.name' - assert_config! 'provider.default_language' - assert_config! 'provider.ca.server_certificates.bit_size' - assert_config! 'provider.ca.server_certificates.digest' - domain = options[:domain] || provider.domain - - unless global_options[:force] - assert_files_missing! [:commercial_key, domain], [:commercial_csr, domain], - :msg => 'If you really want to create a new key and CSR, remove these files first or run with --force.' - end - - server_certificates = provider.ca.server_certificates - - # RSA key - keypair = CertificateAuthority::MemoryKeyMaterial.new - bit_size = (options[:bits] || server_certificates.bit_size).to_i - log :generating, "%s bit RSA key" % bit_size do - keypair.generate_key(bit_size) - write_file! [:commercial_key, domain], keypair.private_key.to_pem - end - - # CSR - dn = CertificateAuthority::DistinguishedName.new - dn.common_name = domain - dn.organization = options[:organization] || provider.name[provider.default_language] - dn.ou = options[:organizational_unit] # optional - dn.email_address = options[:email] # optional - dn.country = options[:country] || server_certificates['country'] # optional - dn.state = options[:state] || server_certificates['state'] # optional - dn.locality = options[:locality] || server_certificates['locality'] # optional - - digest = options[:digest] || server_certificates.digest - log :generating, "CSR with #{digest} digest and #{print_dn(dn)}" do - csr = create_csr(dn, keypair, digest) - write_file! [:commercial_csr, domain], csr.to_pem - end - - # Sign using our own CA, for use in testing but hopefully not production. - # It is not that commerical CAs are so secure, it is just that signing your own certs is - # a total drag for the user because they must click through dire warnings. - #if options[:sign] - log :generating, "self-signed x509 server certificate for testing purposes" do - cert = csr.to_cert - cert.serial_number.number = cert_serial_number(domain) - cert.not_before = yesterday - cert.not_after = yesterday.advance(:years => 1) - cert.parent = ca_root - cert.sign! domain_test_signing_profile - write_file! [:commercial_cert, domain], cert.to_pem - log "please replace this file with the real certificate you get from a CA using #{Path.relative_path([:commercial_csr, domain])}" - end - #end - - # FAKE CA - unless file_exists? :commercial_ca_cert - log :using, "generated CA in place of commercial CA for testing purposes" do - write_file! :commercial_ca_cert, read_file!(:ca_cert) - log "please also replace this file with the CA cert from the commercial authority you use." - end - end + generate_csr(global_options, options, args) end end end @@ -152,6 +60,7 @@ module LeapCli; module Commands # will generate new certificates for the specified nodes, if needed. # def update_certificates(nodes, options={}) + require 'leap_cli/x509' assert_files_exist! :ca_cert, :ca_key, :msg => 'Run `leap cert ca` to create them' assert_config! 'provider.ca.server_certificates.bit_size' assert_config! 'provider.ca.server_certificates.digest' @@ -159,382 +68,102 @@ module LeapCli; module Commands assert_config! 'common.x509.use' nodes.each_node do |node| - warn_if_commercial_cert_will_soon_expire(node) + node.warn_if_commercial_cert_will_soon_expire if !node.x509.use remove_file!([:node_x509_key, node.name]) remove_file!([:node_x509_cert, node.name]) - elsif options[:force] || cert_needs_updating?(node) - generate_cert_for_node(node) - end - end - end - - private - - def generate_new_certificate_authority(key_file, cert_file, common_name) - assert_files_missing! key_file, cert_file - assert_config! 'provider.ca.name' - assert_config! 'provider.ca.bit_size' - assert_config! 'provider.ca.life_span' - - root = CertificateAuthority::Certificate.new - - # set subject - root.subject.common_name = common_name - possible = ['country', 'state', 'locality', 'organization', 'organizational_unit', 'email_address'] - provider.ca.keys.each do |key| - if possible.include?(key) - root.subject.send(key + '=', provider.ca[key]) - end - end - - # set expiration - root.not_before = yesterday - root.not_after = yesterday_advance(provider.ca.life_span) - - # generate private key - root.serial_number.number = 1 - root.key_material.generate_key(provider.ca.bit_size) - - # sign self - root.signing_entity = true - root.parent = root - root.sign!(ca_root_signing_profile) - - # save - write_file!(key_file, root.key_material.private_key.to_pem) - write_file!(cert_file, root.to_pem) - end - - # - # returns true if the certs associated with +node+ need to be regenerated. - # - def cert_needs_updating?(node) - if !file_exists?([:node_x509_cert, node.name], [:node_x509_key, node.name]) - return true - else - cert = load_certificate_file([:node_x509_cert, node.name]) - if !created_by_authority?(cert, ca_root) - log :updating, "cert for node '#{node.name}' because it was signed by an old CA root cert." - return true - end - if cert.not_after < Time.now.advance(:months => 2) - log :updating, "cert for node '#{node.name}' because it will expire soon" - return true - end - if cert.subject.common_name != node.domain.full - log :updating, "cert for node '#{node.name}' because domain.full has changed (was #{cert.subject.common_name}, now #{node.domain.full})" - return true - end - cert.openssl_body.extensions.each do |ext| - if ext.oid == "subjectAltName" - ips = [] - dns_names = [] - ext.value.split(",").each do |value| - value.strip! - ips << $1 if value =~ /^IP Address:(.*)$/ - dns_names << $1 if value =~ /^DNS:(.*)$/ - end - dns_names.sort! - if ips.first != node.ip_address - log :updating, "cert for node '#{node.name}' because ip_address has changed (from #{ips.first} to #{node.ip_address})" - return true - elsif dns_names != dns_names_for_node(node) - log :updating, "cert for node '#{node.name}' because domain name aliases have changed\n from: #{dns_names.inspect}\n to: #{dns_names_for_node(node).inspect})" - return true - end - end + elsif options[:force] || node.cert_needs_updating? + node.generate_cert end end - return false - end - - def created_by_authority?(cert, ca) - authority_key_id = cert.extensions["authorityKeyIdentifier"].identifier.sub(/^keyid:/, '') - authority_key_id == public_key_id_for_ca(ca) - end - - # calculate the "key id" for a root CA, that matches the value - # Authority Key Identifier in the x509 extensions of a cert. - def public_key_id_for_ca(ca_cert) - @ca_key_ids ||= {} - @ca_key_ids[ca_cert.object_id] ||= begin - pubkey = ca_cert.key_material.public_key - seq = OpenSSL::ASN1::Sequence([ - OpenSSL::ASN1::Integer.new(pubkey.n), - OpenSSL::ASN1::Integer.new(pubkey.e) - ]) - Digest::SHA1.hexdigest(seq.to_der).upcase.scan(/../).join(':') - end - end - - def warn_if_commercial_cert_will_soon_expire(node) - dns_names_for_node(node).each do |domain| - if file_exists?([:commercial_cert, domain]) - cert = load_certificate_file([:commercial_cert, domain]) - path = Path.relative_path([:commercial_cert, domain]) - if cert.not_after < Time.now.utc - log :error, "the commercial certificate '#{path}' has EXPIRED! " + - "You should renew it with `leap cert csr --domain #{domain}`." - elsif cert.not_after < Time.now.advance(:months => 2) - log :warning, "the commercial certificate '#{path}' will expire soon (#{cert.not_after}). "+ - "You should renew it with `leap cert csr --domain #{domain}`." - end - end - end - end - - def generate_cert_for_node(node) - return if node.x509.use == false - - cert = CertificateAuthority::Certificate.new - - # set subject - cert.subject.common_name = node.domain.full - cert.serial_number.number = cert_serial_number(node.domain.full) - - # set expiration - cert.not_before = yesterday - cert.not_after = yesterday_advance(provider.ca.server_certificates.life_span) - - # generate key - cert.key_material.generate_key(provider.ca.server_certificates.bit_size) - - # sign - cert.parent = ca_root - cert.sign!(server_signing_profile(node)) - - # save - write_file!([:node_x509_key, node.name], cert.key_material.private_key.to_pem) - write_file!([:node_x509_cert, node.name], cert.to_pem) end # # yields client key and cert suitable for testing # def generate_test_client_cert(prefix=nil) + require 'leap_cli/x509' cert = CertificateAuthority::Certificate.new cert.serial_number.number = cert_serial_number(provider.domain) cert.subject.common_name = [prefix, random_common_name(provider.domain)].join - cert.not_before = yesterday - cert.not_after = yesterday.advance(:years => 1) + cert.not_before = X509.yesterday + cert.not_after = X509.yesterday.advance(:years => 1) cert.key_material.generate_key(1024) # just for testing, remember! cert.parent = client_ca_root cert.sign! client_test_signing_profile yield cert.key_material.private_key.to_pem, cert.to_pem end - # - # creates a CSR and returns it. - # with the correct extReq attribute so that the CA - # doens't generate certs with extensions we don't want. - # - def create_csr(dn, keypair, digest) - csr = CertificateAuthority::SigningRequest.new - csr.distinguished_name = dn - csr.key_material = keypair - csr.digest = digest - - # define extensions manually (library doesn't support setting these on CSRs) - extensions = [] - extensions << CertificateAuthority::Extensions::BasicConstraints.new.tap {|basic| - basic.ca = false - } - extensions << CertificateAuthority::Extensions::KeyUsage.new.tap {|keyusage| - keyusage.usage = ["digitalSignature", "keyEncipherment"] - } - extensions << CertificateAuthority::Extensions::ExtendedKeyUsage.new.tap {|extkeyusage| - extkeyusage.usage = [ "serverAuth"] - } - - # convert extensions to attribute 'extReq' - # aka "Requested Extensions" - factory = OpenSSL::X509::ExtensionFactory.new - attrval = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence( - extensions.map{|e| factory.create_ext(e.openssl_identifier, e.to_s, e.critical)} - )]) - attrs = [ - OpenSSL::X509::Attribute.new("extReq", attrval), - ] - csr.attributes = attrs + private - return csr - end + def generate_new_certificate_authority(key_file, cert_file, common_name) + require 'leap_cli/x509' + assert_files_missing! key_file, cert_file + assert_config! 'provider.ca.name' + assert_config! 'provider.ca.bit_size' + assert_config! 'provider.ca.life_span' - def ca_root - @ca_root ||= begin - load_certificate_file(:ca_cert, :ca_key) - end - end + root = X509.new_ca(provider.ca, common_name) - def client_ca_root - @client_ca_root ||= begin - load_certificate_file(:client_ca_cert, :client_ca_key) - end + write_file!(key_file, root.key_material.private_key.to_pem) + write_file!(cert_file, root.to_pem) end - def load_certificate_file(crt_file, key_file=nil, password=nil) - crt = read_file!(crt_file) - openssl_cert = OpenSSL::X509::Certificate.new(crt) - cert = CertificateAuthority::Certificate.from_openssl(openssl_cert) - if key_file - key = read_file!(key_file) - cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, password) + def generate_dh + require 'leap_cli/x509' + long_running do + if cmd_exists?('certtool') + log 0, 'Generating DH parameters (takes a long time)...' + output = assert_run!('certtool --generate-dh-params --sec-param high') + output.sub!(/.*(-----BEGIN DH PARAMETERS-----.*-----END DH PARAMETERS-----).*/m, '\1') + output << "\n" + write_file!(:dh_params, output) + else + log 0, 'Generating DH parameters (takes a REALLY long time)...' + output = OpenSSL::PKey::DH.generate(3248).to_pem + write_file!(:dh_params, output) + end end - return cert - end - - def ca_root_signing_profile - { - "extensions" => { - "basicConstraints" => {"ca" => true}, - "keyUsage" => { - "usage" => ["critical", "keyCertSign"] - }, - "extendedKeyUsage" => { - "usage" => [] - } - } - } - end - - # - # For keyusage, openvpn server certs can have keyEncipherment or keyAgreement. - # Web browsers seem to break without keyEncipherment. - # For now, I am using digitalSignature + keyEncipherment - # - # * digitalSignature -- for (EC)DHE cipher suites - # "The digitalSignature bit is asserted when the subject public key is used - # with a digital signature mechanism to support security services other - # than certificate signing (bit 5), or CRL signing (bit 6). Digital - # signature mechanisms are often used for entity authentication and data - # origin authentication with integrity." - # - # * keyEncipherment ==> for plain RSA cipher suites - # "The keyEncipherment bit is asserted when the subject public key is used for - # key transport. For example, when an RSA key is to be used for key management, - # then this bit is set." - # - # * keyAgreement ==> for used with DH, not RSA. - # "The keyAgreement bit is asserted when the subject public key is used for key - # agreement. For example, when a Diffie-Hellman key is to be used for key - # management, then this bit is set." - # - # digest options: SHA512, SHA256, SHA1 - # - def server_signing_profile(node) - { - "digest" => provider.ca.server_certificates.digest, - "extensions" => { - "keyUsage" => { - "usage" => ["digitalSignature", "keyEncipherment"] - }, - "extendedKeyUsage" => { - "usage" => ["serverAuth", "clientAuth"] - }, - "subjectAltName" => { - "ips" => [node.ip_address], - "dns_names" => dns_names_for_node(node) - } - } - } end # - # This is used when signing the main cert for the provider's domain - # with our own CA (for testing purposes). Typically, this cert would - # be purchased from a commercial CA, and not signed this way. + # hints: # - def domain_test_signing_profile - { - "digest" => "SHA256", - "extensions" => { - "keyUsage" => { - "usage" => ["digitalSignature", "keyEncipherment"] - }, - "extendedKeyUsage" => { - "usage" => ["serverAuth"] - } - } - } - end - + # inspect CSR: + # openssl req -noout -text -in files/cert/x.csr # - # This is used when signing a dummy client certificate that is only to be - # used for testing. + # generate CSR with openssl to see how it compares: + # openssl req -sha256 -nodes -newkey rsa:2048 -keyout example.key -out example.csr # - def client_test_signing_profile - { - "digest" => "SHA256", - "extensions" => { - "keyUsage" => { - "usage" => ["digitalSignature"] - }, - "extendedKeyUsage" => { - "usage" => ["clientAuth"] - } - } - } - end - - def dns_names_for_node(node) - names = [node.domain.internal, node.domain.full] - if node['dns'] && node.dns['aliases'] && node.dns.aliases.any? - names += node.dns.aliases - end - names.compact! - names.sort! - names.uniq! - return names - end - + # validate a CSR: + # http://certlogik.com/decoder/ # - # For cert serial numbers, we need a non-colliding number less than 160 bits. - # md5 will do nicely, since there is no need for a secure hash, just a short one. - # (md5 is 128 bits) + # nice details about CSRs: + # http://www.redkestrel.co.uk/Articles/CSR.html # - def cert_serial_number(domain_name) - Digest::MD5.hexdigest("#{domain_name} -- #{Time.now}").to_i(16) - end + def generate_csr(global_options, options, args) + require 'leap_cli/x509' + assert_config! 'provider.domain' + assert_config! 'provider.name' + assert_config! 'provider.default_language' + assert_config! 'provider.ca.server_certificates.bit_size' + assert_config! 'provider.ca.server_certificates.digest' - # - # for the random common name, we need a text string that will be unique across all certs. - # ruby 1.8 doesn't have a built-in uuid generator, or we would use SecureRandom.uuid - # - def random_common_name(domain_name) - cert_serial_number(domain_name).to_s(36) - end + server_certificates = provider.ca.server_certificates + options[:domain] ||= provider.domain + options[:organization] ||= provider.name[provider.default_language] + options[:country] ||= server_certificates['country'] + options[:state] ||= server_certificates['state'] + options[:locality] ||= server_certificates['locality'] + options[:bits] ||= server_certificates.bit_size + options[:digest] ||= server_certificates.digest - # prints CertificateAuthority::DistinguishedName fields - def print_dn(dn) - fields = {} - [:common_name, :locality, :state, :country, :organization, :organizational_unit, :email_address].each do |attr| - fields[attr] = dn.send(attr) if dn.send(attr) + unless global_options[:force] + assert_files_missing! [:commercial_key, options[:domain]], [:commercial_csr, options[:domain]], + :msg => 'If you really want to create a new key and CSR, remove these files first or run with --force.' end - fields.inspect - end - - ## - ## TIME HELPERS - ## - ## note: we use 'yesterday' instead of 'today', because times are in UTC, and some people on the planet - ## are behind UTC. - ## - - def yesterday - t = Time.now - 24*24*60 - Time.utc t.year, t.month, t.day - end - def yesterday_advance(string) - number, unit = string.split(' ') - unless ['years', 'months', 'days', 'hours', 'minutes'].include? unit - bail!("The time property '#{string}' is missing a unit (one of: years, months, days, hours, minutes).") - end - unless number.to_i.to_s == number - bail!("The time property '#{string}' is missing a number.") - end - yesterday.advance(unit.to_sym => number.to_i) + X509.create_csr_and_cert(options) end end; end diff --git a/lib/leap_cli/commands/node.rb b/lib/leap_cli/commands/node.rb index 1dce437e..9d210244 100644 --- a/lib/leap_cli/commands/node.rb +++ b/lib/leap_cli/commands/node.rb @@ -3,8 +3,6 @@ # but all other `node x` commands live here. # -autoload :IPAddr, 'ipaddr' - module LeapCli; module Commands ## @@ -23,29 +21,7 @@ module LeapCli; module Commands node.command :add do |add| add.switch :local, :desc => 'Make a local testing node (by automatically assigning the next available local IP address). Local nodes are run as virtual machines on your computer.', :negatable => false add.action do |global_options,options,args| - # argument sanity checks - name = args.first - assert_valid_node_name!(name, options[:local]) - assert_files_missing! [:node_config, name] - - # create and seed new node - node = Config::Node.new(manager.env) - if options[:local] - node['ip_address'] = pick_next_vagrant_ip_address - end - seed_node_data_from_cmd_line(node, args[1..-1]) - seed_node_data_from_template(node) - validate_ip_address(node) - begin - node['name'] = name - json = node.dump_json(:exclude => ['name']) - write_file!([:node_config, name], json + "\n") - if file_exists? :ca_cert, :ca_key - generate_cert_for_node(manager.reload_node!(node)) - end - rescue LeapCli::ConfigError - remove_node_files(name) - end + add_node(global_options, options, args) end end @@ -53,15 +29,7 @@ module LeapCli; module Commands node.arg_name 'OLD_NAME NEW_NAME' node.command :mv do |mv| mv.action do |global_options,options,args| - node = get_node_from_args(args, include_disabled: true) - new_name = args.last - assert_valid_node_name!(new_name, node.vagrant?) - ensure_dir [:node_files_dir, new_name] - Leap::Platform.node_files.each do |path| - rename_file! [path, node.name], [path, new_name] - end - remove_directory! [:node_files_dir, node.name] - rename_node_facts(node.name, new_name) + move_node(global_options, options, args) end end @@ -69,12 +37,7 @@ module LeapCli; module Commands node.arg_name 'NAME' #:optional => false #, :multiple => false node.command :rm do |rm| rm.action do |global_options,options,args| - node = get_node_from_args(args, include_disabled: true) - remove_node_files(node.name) - if node.vagrant? - vagrant_command("destroy --force", [node.name]) - end - remove_node_facts(node.name) + rm_node(global_options, options, args) end end end @@ -93,96 +56,50 @@ module LeapCli; module Commands node end - def seed_node_data_from_cmd_line(node, args) - args.each do |seed| - key, value = seed.split(':', 2) - value = format_seed_value(value) - assert! key =~ /^[0-9a-z\._]+$/, "illegal characters used in property '#{key}'" - if key =~ /\./ - key_parts = key.split('.') - final_key = key_parts.pop - current_object = node - key_parts.each do |key_part| - current_object[key_part] ||= Config::Object.new - current_object = current_object[key_part] - end - current_object[final_key] = value - else - node[key] = value - end - end - end + protected - # - # load "new node template" information into the `node`, modifying `node`. - # values in the template will not override existing node values. - # - def seed_node_data_from_template(node) - node.inherit_from!(manager.template('common')) - [node['services']].flatten.each do |service| - if service - template = manager.template(service) - if template - node.inherit_from!(template) - end - end + def add_node(global, options, args) + name = args.first + unless global[:force] + assert_files_missing! [:node_config, name] end - end - - def remove_node_files(node_name) - (Leap::Platform.node_files + [:node_files_dir]).each do |path| - remove_file! [path, node_name] + node = Config::Node.new(manager.env) + node['name'] = name + if options[:ip_address] + node['ip_address'] = options[:ip_address] + elsif options[:local] + node['ip_address'] = pick_next_vagrant_ip_address end + node.seed_from_args(args[1..-1]) + node.seed_from_template + node.validate! + node.write_configs + # reapply inheritance, since tags/services might have changed: + node = manager.reload_node!(node) + node.generate_cert end - # - # conversions: - # - # "x,y,z" => ["x","y","z"] - # - # "22" => 22 - # - # "5.1" => 5.1 - # - def format_seed_value(v) - if v =~ /,/ - v = v.split(',') - v.map! do |i| - i = i.to_i if i.to_i.to_s == i - i = i.to_f if i.to_f.to_s == i - i - end - else - v = v.to_i if v.to_i.to_s == v - v = v.to_f if v.to_f.to_s == v - end - return v - end + private - def validate_ip_address(node) - if node['ip_address'] == "REQUIRED" - bail! do - log :error, "ip_address is not set. Specify with `leap node add NAME ip_address:ADDRESS`." - end - end - IPAddr.new(node['ip_address']) - rescue ArgumentError - bail! do - if node['ip_address'] - log :invalid, "ip_address #{node['ip_address'].inspect}" - else - log :missing, "ip_address" - end + def move_node(global, options, args) + node = get_node_from_args(args, include_disabled: true) + new_name = args.last + Config::Node.validate_name!(new_name, node.vagrant?) + ensure_dir [:node_files_dir, new_name] + Leap::Platform.node_files.each do |path| + rename_file! [path, node.name], [path, new_name] end + remove_directory! [:node_files_dir, node.name] + rename_node_facts(node.name, new_name) end - def assert_valid_node_name!(name, local=false) - assert! name, 'No <node-name> specified.' - if local - assert! name =~ /^[0-9a-z]+$/, "illegal characters used in node name '#{name}' (note: Vagrant does not allow hyphens or underscores)" - else - assert! name =~ /^[0-9a-z-]+$/, "illegal characters used in node name '#{name}' (note: Linux does not allow underscores)" + def rm_node(global, options, args) + node = get_node_from_args(args, include_disabled: true) + node.remove_files + if node.vagrant? + vagrant_command("destroy --force", [node.name]) end + remove_node_facts(node.name) end end; end diff --git a/lib/leap_cli/config/node.rb b/lib/leap_cli/config/node.rb index f8ec0527..2d76b814 100644 --- a/lib/leap_cli/config/node.rb +++ b/lib/leap_cli/config/node.rb @@ -9,8 +9,11 @@ module LeapCli; module Config class Node < Object attr_accessor :file_paths - def initialize(environment=nil) + def initialize(environment=nil) #, name=nil) super(environment) + #if name + # self['name'] = name + #end @node = self @file_paths = [] end @@ -19,18 +22,20 @@ module LeapCli; module Config # returns true if this node has an ip address in the range of the vagrant network # def vagrant? + ip = self['ip_address'] + return false unless ip begin vagrant_range = IPAddr.new LeapCli.leapfile.vagrant_network - rescue ArgumentError => exc - Util::bail! { Util::log :invalid, "ip address '#{@node.ip_address}' vagrant.network" } + rescue ArgumentError + Util::bail! { Util::log :invalid, "vagrant_network in Leapfile or .leaprc" } end begin - ip_address = IPAddr.new @node.get('ip_address') - rescue ArgumentError => exc - Util::log :warning, "invalid ip address '#{@node.get('ip_address')}' for node '#{@node.name}'" + ip_addr = IPAddr.new(ip) + rescue ArgumentError + Util::log :warning, "invalid ip address '#{ip}' for node '#{@node.name}'" end - return vagrant_range.include?(ip_address) + return vagrant_range.include?(ip_addr) end # @@ -73,6 +78,163 @@ module LeapCli; module Config ) end + # + # Takes strings such as "openvpn.gateway_address:1.1.1.1" + # and converts this to data stored in this node. + # + def seed_from_args(args) + args.each do |seed| + key, value = seed.split(':', 2) + value = format_seed_value(value) + Util.assert! key =~ /^[0-9a-z\._]+$/, "illegal characters used in property '#{key}'" + if key =~ /\./ + key_parts = key.split('.') + final_key = key_parts.pop + current_object = self + key_parts.each do |key_part| + current_object[key_part] ||= Config::Object.new + current_object = current_object[key_part] + end + current_object[final_key] = value + else + self[key] = value + end + end + end + + # + # Seeds values for this node from a template, based on the services. + # Values in the template will not override existing node values. + # + def seed_from_template + inherit_from!(manager.template('common')) + [self['services']].flatten.each do |service| + if service + template = manager.template(service) + if template + inherit_from!(template) + end + end + end + end + + # + # bails if the node is not valid. + # + def validate! + # + # validate ip_address + # + if self['ip_address'] == "REQUIRED" + Util.bail! do + Util.log :error, "ip_address is not set. " + + "Specify with `leap node add NAME ip_address:ADDRESS`." + end + elsif self['ip_address'] + begin + IPAddr.new(self['ip_address']) + rescue ArgumentError + Util.bail! do + Util.log :invalid, "ip_address #{self['ip_address'].inspect}" + end + end + end + + # + # validate name + # + self.class.validate_name!(self.name, self.vagrant?) + end + + # + # create or update all the configs needed for this node, + # including x.509 certs as needed. + # + # note: this method will write to disk EVERYTHING + # in the node, which is not what you want + # if the node has inheritance applied. + # + def write_configs + json = self.dump_json(:exclude => ['name']) + Util.write_file!([:node_config, name], json + "\n") + rescue LeapCli::ConfigError + Config::Node.remove_node_files(self.name) + end + + # + # modifies the config file nodes/NAME.json for this node. + # + def update_json(new_values) + self.env.update_node_json(node, new_values) + end + + # + # returns an array of all possible dns names for this node + # + def all_dns_names + names = [@node.domain.internal, @node.domain.full] + if @node['dns'] && @node.dns['aliases'] && @node.dns.aliases.any? + names += @node.dns.aliases + end + names.compact! + names.sort! + names.uniq! + return names + end + + def remove_files + self.class.remove_node_files(self.name) + end + + ## + ## Class Methods + ## + + def self.remove_node_files(node_name) + (Leap::Platform.node_files + [:node_files_dir]).each do |path| + Util.remove_file! [path, node_name] + end + end + + def self.validate_name!(name, local=false) + Util.assert! name, 'Node is missing a name.' + if local + Util.assert! name =~ /^[0-9a-z]+$/, + "illegal characters used in node name '#{name}' " + + "(note: Vagrant does not allow hyphens or underscores)" + else + Util.assert! name =~ /^[0-9a-z-]+$/, + "illegal characters used in node name '#{name}' " + + "(note: Linux does not allow underscores)" + end + end + + private + + # + # conversions: + # + # "x,y,z" => ["x","y","z"] + # + # "22" => 22 + # + # "5.1" => 5.1 + # + def format_seed_value(v) + if v =~ /,/ + v = v.split(',') + v.map! do |i| + i = i.to_i if i.to_i.to_s == i + i = i.to_f if i.to_f.to_s == i + i + end + else + v = v.to_i if v.to_i.to_s == v + v = v.to_f if v.to_f.to_s == v + end + return v + end + end end; end diff --git a/lib/leap_cli/config/node_cert.rb b/lib/leap_cli/config/node_cert.rb new file mode 100644 index 00000000..64842ffa --- /dev/null +++ b/lib/leap_cli/config/node_cert.rb @@ -0,0 +1,124 @@ +# +# x509 related methods for Config::Node +# +module LeapCli; module Config + + class Node < Object + + # + # creates a new server certificate file for this node + # + def generate_cert + require 'leap_cli/x509' + + if self['x509.use'] == false || + !Util.file_exists?(:ca_cert, :ca_key) || + !self.cert_needs_updating? + return false + end + + cert = CertificateAuthority::Certificate.new + provider = env.provider + + # set subject + cert.subject.common_name = self.domain.full + cert.serial_number.number = X509.cert_serial_number(self.domain.full) + + # set expiration + cert.not_before = X509.yesterday + cert.not_after = X509.yesterday_advance(provider.ca.server_certificates.life_span) + + # generate key + cert.key_material.generate_key(provider.ca.server_certificates.bit_size) + + # sign + cert.parent = X509.ca_root + cert.sign!(X509.server_signing_profile(self)) + + # save + Util.write_file!([:node_x509_key, self.name], cert.key_material.private_key.to_pem) + Util.write_file!([:node_x509_cert, self.name], cert.to_pem) + end + + # + # returns true if the certs associated with +node+ need to be regenerated. + # + def cert_needs_updating?(log_comments=true) + require 'leap_cli/x509' + + if log_comments + def log(*args, &block) + Util.log(*args, &block) + end + else + def log(*args); end + end + + node = self + if !Util.file_exists?([:node_x509_cert, node.name], [:node_x509_key, node.name]) + return true + else + cert = X509.load_certificate_file([:node_x509_cert, node.name]) + if !X509.created_by_authority?(cert) + log :updating, "cert for node '#{node.name}' because it was signed by an old CA root cert." + return true + end + if cert.not_after < Time.now.advance(:months => 2) + log :updating, "cert for node '#{node.name}' because it will expire soon" + return true + end + if cert.subject.common_name != node.domain.full + log :updating, "cert for node '#{node.name}' because domain.full has changed (was #{cert.subject.common_name}, now #{node.domain.full})" + return true + end + cert.openssl_body.extensions.each do |ext| + if ext.oid == "subjectAltName" + ips = [] + dns_names = [] + ext.value.split(",").each do |value| + value.strip! + ips << $1 if value =~ /^IP Address:(.*)$/ + dns_names << $1 if value =~ /^DNS:(.*)$/ + end + dns_names.sort! + if ips.first != node.ip_address + log :updating, "cert for node '#{node.name}' because ip_address has changed (from #{ips.first} to #{node.ip_address})" + return true + elsif dns_names != node.all_dns_names + log :updating, "cert for node '#{node.name}' because domain name aliases have changed" do + log "from: #{dns_names.inspect}" + log "to: #{node.all_dns_names.inspect})" + end + return true + end + end + end + end + return false + end + + # + # check the expiration of commercial certs, if any. + # + def warn_if_commercial_cert_will_soon_expire + require 'leap_cli/x509' + + self.all_dns_names.each do |domain| + if Util.file_exists?([:commercial_cert, domain]) + cert = X509.load_certificate_file([:commercial_cert, domain]) + path = Path.relative_path([:commercial_cert, domain]) + if cert.not_after < Time.now.utc + Util.log :error, "the commercial certificate '#{path}' has EXPIRED! " + + "You should renew it with `leap cert csr --domain #{domain}`." + elsif cert.not_after < Time.now.advance(:months => 2) + Util.log :warning, "the commercial certificate '#{path}' will expire soon (#{cert.not_after}). "+ + "You should renew it with `leap cert csr --domain #{domain}`." + end + end + end + end + + end + +end; end + diff --git a/lib/leap_cli/util/x509.rb b/lib/leap_cli/util/x509.rb deleted file mode 100644 index 787fdfac..00000000 --- a/lib/leap_cli/util/x509.rb +++ /dev/null @@ -1,33 +0,0 @@ -autoload :OpenSSL, 'openssl' -autoload :CertificateAuthority, 'certificate_authority' - -require 'digest' -require 'digest/md5' -require 'digest/sha1' - -module LeapCli; module X509 - extend self - - # - # returns a fingerprint of a x509 certificate - # - def fingerprint(digest, cert_file) - if cert_file.is_a? String - cert = OpenSSL::X509::Certificate.new(Util.read_file!(cert_file)) - elsif cert_file.is_a? OpenSSL::X509::Certificate - cert = cert_file - elsif cert_file.is_a? CertificateAuthority::Certificate - cert = cert_file.openssl_body - end - digester = case digest - when "MD5" then Digest::MD5.new - when "SHA1" then Digest::SHA1.new - when "SHA256" then Digest::SHA256.new - when "SHA384" then Digest::SHA384.new - when "SHA512" then Digest::SHA512.new - end - digester.hexdigest(cert.to_der) - end - - -end; end diff --git a/lib/leap_cli/x509.rb b/lib/leap_cli/x509.rb new file mode 100644 index 00000000..68d13ddf --- /dev/null +++ b/lib/leap_cli/x509.rb @@ -0,0 +1,16 @@ +# +# optional. load if you want access to any methods in the module X509 +# + +require 'date' +require 'securerandom' +require 'openssl' +require 'digest' +require 'digest/md5' +require 'digest/sha1' + +require 'certificate_authority' + +require 'leap_cli/x509/certs' +require 'leap_cli/x509/signing_profiles' +require 'leap_cli/x509/utils' diff --git a/lib/leap_cli/x509/certs.rb b/lib/leap_cli/x509/certs.rb new file mode 100644 index 00000000..3b74d2fb --- /dev/null +++ b/lib/leap_cli/x509/certs.rb @@ -0,0 +1,232 @@ + +module LeapCli; module X509 + + # + # returns a fingerprint of a x509 certificate + # + # Note: there are different ways of computing a digest of a certificate. + # You can either take a digest of the entire cert in DER format, or you + # can take a digest of the public key. + # + # For now, we only support the DER method. + # + def self.fingerprint(digest, cert_file) + if cert_file.is_a? String + cert = OpenSSL::X509::Certificate.new(Util.read_file!(cert_file)) + elsif cert_file.is_a? OpenSSL::X509::Certificate + cert = cert_file + elsif cert_file.is_a? CertificateAuthority::Certificate + cert = cert_file.openssl_body + end + digester = case digest + when "MD5" then Digest::MD5.new + when "SHA1" then Digest::SHA1.new + when "SHA256" then Digest::SHA256.new + when "SHA384" then Digest::SHA384.new + when "SHA512" then Digest::SHA512.new + end + digester.hexdigest(cert.to_der) + end + + def self.ca_root + @ca_root ||= begin + load_certificate_file(:ca_cert, :ca_key) + end + end + + def self.client_ca_root + @client_ca_root ||= begin + load_certificate_file(:client_ca_cert, :client_ca_key) + end + end + + def self.load_certificate_file(crt_file, key_file=nil, password=nil) + crt = Util.read_file!(crt_file) + openssl_cert = OpenSSL::X509::Certificate.new(crt) + cert = CertificateAuthority::Certificate.from_openssl(openssl_cert) + if key_file + key = Util.read_file!(key_file) + cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, password) + end + return cert + end + + # + # creates a new certificate authority. + # + def self.new_ca(options, common_name) + root = CertificateAuthority::Certificate.new + + # set subject + root.subject.common_name = common_name + possible = ['country', 'state', 'locality', 'organization', 'organizational_unit', 'email_address'] + options.keys.each do |key| + if possible.include?(key) + root.subject.send(key + '=', options[key]) + end + end + + # set expiration + root.not_before = X509.yesterday + root.not_after = X509.yesterday_advance(options['life_span']) + + # generate private key + root.serial_number.number = 1 + root.key_material.generate_key(options['bit_size']) + + # sign self + root.signing_entity = true + root.parent = root + root.sign!(ca_root_signing_profile) + return root + end + + # + # creates a CSR in memory and returns it. + # with the correct extReq attribute so that the CA + # doens't generate certs with extensions we don't want. + # + def self.new_csr(dn, keypair, digest) + csr = CertificateAuthority::SigningRequest.new + csr.distinguished_name = dn + csr.key_material = keypair + csr.digest = digest + + # define extensions manually (library doesn't support setting these on CSRs) + extensions = [] + extensions << CertificateAuthority::Extensions::BasicConstraints.new.tap {|basic| + basic.ca = false + } + extensions << CertificateAuthority::Extensions::KeyUsage.new.tap {|keyusage| + keyusage.usage = ["digitalSignature", "keyEncipherment"] + } + extensions << CertificateAuthority::Extensions::ExtendedKeyUsage.new.tap {|extkeyusage| + extkeyusage.usage = [ "serverAuth"] + } + + # convert extensions to attribute 'extReq' + # aka "Requested Extensions" + factory = OpenSSL::X509::ExtensionFactory.new + attrval = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence( + extensions.map{|e| factory.create_ext(e.openssl_identifier, e.to_s, e.critical)} + )]) + attrs = [ + OpenSSL::X509::Attribute.new("extReq", attrval), + ] + csr.attributes = attrs + + return csr + end + + # + # creates new csr and cert files for a particular domain. + # + # The cert is signed with the ca_root, but should be replaced + # later with a real cert signed by a better ca + # + def self.create_csr_and_cert(options) + bit_size = options[:bits].to_i + digest = options[:digest] + + # RSA key + keypair = CertificateAuthority::MemoryKeyMaterial.new + Util.log :generating, "%s bit RSA key" % bit_size do + keypair.generate_key(bit_size) + Util.write_file! [:commercial_key, options[:domain]], keypair.private_key.to_pem + end + + # CSR + csr = nil + dn = CertificateAuthority::DistinguishedName.new + dn.common_name = options[:domain] + dn.organization = options[:organization] + dn.ou = options[:organizational_unit] + dn.email_address = options[:email] + dn.country = options[:country] + dn.state = options[:state] + dn.locality = options[:locality] + Util.log :generating, "CSR with #{digest} digest and #{print_dn(dn)}" do + csr = new_csr(dn, keypair, options[:digest]) + Util.write_file! [:commercial_csr, options[:domain]], csr.to_pem + end + + # Sign using our own CA, for use in testing but hopefully not production. + # It is not that commerical CAs are so secure, it is just that signing your own certs is + # a total drag for the user because they must click through dire warnings. + Util.log :generating, "self-signed x509 server certificate for testing purposes" do + cert = csr.to_cert + cert.serial_number.number = cert_serial_number(options[:domain]) + cert.not_before = yesterday + cert.not_after = yesterday.advance(:years => 1) + cert.parent = ca_root + cert.sign! domain_test_signing_profile + Util.write_file! [:commercial_cert, options[:domain]], cert.to_pem + Util.log "please replace this file with the real certificate you get from a CA using #{Path.relative_path([:commercial_csr, options[:domain]])}" + end + + # Fake CA + unless Util.file_exists? :commercial_ca_cert + Util.log :using, "generated CA in place of commercial CA for testing purposes" do + Util.write_file! :commercial_ca_cert, Util.read_file!(:ca_cert) + Util.log "please also replace this file with the CA cert from the commercial authority you use." + end + end + end + + # + # Return true if the given server cert has been signed by the given CA cert + # + # This does not actually validate the signature, it just checks the cert + # extensions. + # + def self.created_by_authority?(cert, ca=X509.ca_root) + authority_key_id = cert.extensions["authorityKeyIdentifier"].identifier.sub(/^keyid:/, '') + return authority_key_id == self.public_key_id_for_ca(ca) + end + + # + # For cert serial numbers, we need a non-colliding number less than 160 bits. + # md5 will do nicely, since there is no need for a secure hash, just a short one. + # (md5 is 128 bits) + # + def self.cert_serial_number(domain_name) + Digest::MD5.hexdigest("#{domain_name} -- #{Time.now}").to_i(16) + end + + # + # for the random common name, we need a text string that will be + # unique across all certs. + # + def self.random_common_name(domain_name) + #cert_serial_number(domain_name).to_s(36) + SecureRandom.uuid + end + + private + + # + # calculate the "key id" for a root CA, that matches the value + # Authority Key Identifier in the x509 extensions of a cert. + # + def self.public_key_id_for_ca(ca_cert) + @ca_key_ids ||= {} + @ca_key_ids[ca_cert.object_id] ||= begin + pubkey = ca_cert.key_material.public_key + seq = OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Integer.new(pubkey.n), + OpenSSL::ASN1::Integer.new(pubkey.e) + ]) + Digest::SHA1.hexdigest(seq.to_der).upcase.scan(/../).join(':') + end + end + + # prints CertificateAuthority::DistinguishedName fields + def self.print_dn(dn) + fields = {} + [:common_name, :locality, :state, :country, :organization, :organizational_unit, :email_address].each do |attr| + fields[attr] = dn.send(attr) if dn.send(attr) + end + fields.inspect + end + +end; end diff --git a/lib/leap_cli/x509/signing_profiles.rb b/lib/leap_cli/x509/signing_profiles.rb new file mode 100644 index 00000000..56cd29c7 --- /dev/null +++ b/lib/leap_cli/x509/signing_profiles.rb @@ -0,0 +1,104 @@ +# +# Signing profiles are used by CertificateAuthority in order to +# set the correct flags when signing certificates. +# + +module LeapCli; module X509 + + # + # For CA self-signing + # + def self.ca_root_signing_profile + { + "extensions" => { + "basicConstraints" => {"ca" => true}, + "keyUsage" => { + "usage" => ["critical", "keyCertSign"] + }, + "extendedKeyUsage" => { + "usage" => [] + } + } + } + end + + # + # For keyusage, openvpn server certs can have keyEncipherment or keyAgreement. + # Web browsers seem to break without keyEncipherment. + # For now, I am using digitalSignature + keyEncipherment + # + # * digitalSignature -- for (EC)DHE cipher suites + # "The digitalSignature bit is asserted when the subject public key is used + # with a digital signature mechanism to support security services other + # than certificate signing (bit 5), or CRL signing (bit 6). Digital + # signature mechanisms are often used for entity authentication and data + # origin authentication with integrity." + # + # * keyEncipherment ==> for plain RSA cipher suites + # "The keyEncipherment bit is asserted when the subject public key is used for + # key transport. For example, when an RSA key is to be used for key management, + # then this bit is set." + # + # * keyAgreement ==> for used with DH, not RSA. + # "The keyAgreement bit is asserted when the subject public key is used for key + # agreement. For example, when a Diffie-Hellman key is to be used for key + # management, then this bit is set." + # + # digest options: SHA512, SHA256, SHA1 + # + def self.server_signing_profile(node) + { + "digest" => node.env.provider.ca.server_certificates.digest, + "extensions" => { + "keyUsage" => { + "usage" => ["digitalSignature", "keyEncipherment"] + }, + "extendedKeyUsage" => { + "usage" => ["serverAuth", "clientAuth"] + }, + "subjectAltName" => { + "ips" => [node.ip_address], + "dns_names" => node.all_dns_names + } + } + } + end + + # + # This is used when signing the main cert for the provider's domain + # with our own CA (for testing purposes). Typically, this cert would + # be purchased from a commercial CA, and not signed this way. + # + def self.domain_test_signing_profile + { + "digest" => "SHA256", + "extensions" => { + "keyUsage" => { + "usage" => ["digitalSignature", "keyEncipherment"] + }, + "extendedKeyUsage" => { + "usage" => ["serverAuth"] + } + } + } + end + + # + # This is used when signing a dummy client certificate that is only to be + # used for testing. + # + def self.client_test_signing_profile + { + "digest" => "SHA256", + "extensions" => { + "keyUsage" => { + "usage" => ["digitalSignature"] + }, + "extendedKeyUsage" => { + "usage" => ["clientAuth"] + } + } + } + end + +end; end
\ No newline at end of file diff --git a/lib/leap_cli/x509/utils.rb b/lib/leap_cli/x509/utils.rb new file mode 100644 index 00000000..98ff9c0b --- /dev/null +++ b/lib/leap_cli/x509/utils.rb @@ -0,0 +1,26 @@ +module LeapCli; module X509 + + # + # TIME HELPERS + # + # note: we use 'yesterday' instead of 'today', because times are in UTC, and + # some people on the planet are behind UTC! + # + + def self.yesterday + t = Time.now - 24*24*60 + Time.utc t.year, t.month, t.day + end + + def self.yesterday_advance(string) + number, unit = string.split(' ') + unless ['years', 'months', 'days', 'hours', 'minutes'].include? unit + bail!("The time property '#{string}' is missing a unit (one of: years, months, days, hours, minutes).") + end + unless number.to_i.to_s == number + bail!("The time property '#{string}' is missing a number.") + end + yesterday.advance(unit.to_sym => number.to_i) + end + +end; end
\ No newline at end of file |