diff --git a/lib/puppet/ssl/certificate_authority.rb b/lib/puppet/ssl/certificate_authority.rb index f92166aab..36776f5c0 100644 --- a/lib/puppet/ssl/certificate_authority.rb +++ b/lib/puppet/ssl/certificate_authority.rb @@ -1,301 +1,302 @@ require 'puppet/ssl/host' require 'puppet/ssl/certificate_request' require 'puppet/util/cacher' # The class that knows how to sign certificates. It creates # a 'special' SSL::Host whose name is 'ca', thus indicating # that, well, it's the CA. There's some magic in the # indirector/ssl_file terminus base class that does that # for us. # This class mostly just signs certs for us, but # it can also be seen as a general interface into all of the # SSL stuff. class Puppet::SSL::CertificateAuthority # We will only sign extensions on this whitelist, ever. Any CSR with a # requested extension that we don't recognize is rejected, against the risk # that it will introduce some security issue through our ignorance of it. # # Adding an extension to this whitelist simply means we will consider it # further, not that we will always accept a certificate with an extension # requested on this list. RequestExtensionWhitelist = %w{} require 'puppet/ssl/certificate_factory' require 'puppet/ssl/inventory' require 'puppet/ssl/certificate_revocation_list' require 'puppet/ssl/certificate_authority/interface' require 'puppet/network/authstore' class CertificateVerificationError < RuntimeError attr_accessor :error_code def initialize(code) @error_code = code end end class << self include Puppet::Util::Cacher cached_attr(:singleton_instance) { new } end def self.ca? return false unless Puppet[:ca] return false unless Puppet.run_mode.master? true end # If this process can function as a CA, then return a singleton # instance. def self.instance return nil unless ca? singleton_instance end attr_reader :name, :host # Create and run an applicator. I wanted to build an interface where you could do # something like 'ca.apply(:generate).to(:all) but I don't think it's really possible. def apply(method, options) raise ArgumentError, "You must specify the hosts to apply to; valid values are an array or the symbol :all" unless options[:to] applier = Interface.new(method, options) applier.apply(self) end # If autosign is configured, then autosign all CSRs that match our configuration. def autosign return unless auto = autosign? store = nil store = autosign_store(auto) if auto != true Puppet::SSL::CertificateRequest.search("*").each do |csr| sign(csr.name) if auto == true or store.allowed?(csr.name, "127.1.1.1") end end # Do we autosign? This returns true, false, or a filename. def autosign? auto = Puppet[:autosign] return false if ['false', false].include?(auto) return true if ['true', true].include?(auto) raise ArgumentError, "The autosign configuration '#{auto}' must be a fully qualified file" unless auto =~ /^\// FileTest.exist?(auto) && auto end # Create an AuthStore for autosigning. def autosign_store(file) auth = Puppet::Network::AuthStore.new File.readlines(file).each do |line| next if line =~ /^\s*#/ next if line =~ /^\s*$/ auth.allow(line.chomp) end auth end # Retrieve (or create, if necessary) the certificate revocation list. def crl unless defined?(@crl) unless @crl = Puppet::SSL::CertificateRevocationList.find(Puppet::SSL::CA_NAME) @crl = Puppet::SSL::CertificateRevocationList.new(Puppet::SSL::CA_NAME) @crl.generate(host.certificate.content, host.key.content) @crl.save end end @crl end # Delegate this to our Host class. def destroy(name) Puppet::SSL::Host.destroy(name) end # Generate a new certificate. def generate(name) raise ArgumentError, "A Certificate already exists for #{name}" if Puppet::SSL::Certificate.find(name) host = Puppet::SSL::Host.new(name) host.generate_certificate_request sign(name) end # Generate our CA certificate. def generate_ca_certificate generate_password unless password? host.generate_key unless host.key # Create a new cert request. We do this # specially, because we don't want to actually # save the request anywhere. request = Puppet::SSL::CertificateRequest.new(host.name) request.generate(host.key) # Create a self-signed certificate. @certificate = sign(host.name, :ca, request) # And make sure we initialize our CRL. crl end def initialize Puppet.settings.use :main, :ssl, :ca @name = Puppet[:certname] @host = Puppet::SSL::Host.new(Puppet::SSL::Host.ca_name) setup end # Retrieve (or create, if necessary) our inventory manager. def inventory @inventory ||= Puppet::SSL::Inventory.new end # Generate a new password for the CA. def generate_password pass = "" 20.times { pass += (rand(74) + 48).chr } begin Puppet.settings.write(:capass) { |f| f.print pass } rescue Errno::EACCES => detail raise Puppet::Error, "Could not write CA password: #{detail}" end @password = pass pass end # List all signed certificates. def list Puppet::SSL::Certificate.search("*").collect { |c| c.name } end # Read the next serial from the serial file, and increment the # file so this one is considered used. def next_serial serial = nil # This is slightly odd. If the file doesn't exist, our readwritelock creates # it, but with a mode we can't actually read in some cases. So, use # a default before the lock. serial = 0x1 unless FileTest.exist?(Puppet[:serial]) Puppet.settings.readwritelock(:serial) { |f| serial ||= File.read(Puppet.settings[:serial]).chomp.hex if FileTest.exist?(Puppet[:serial]) # We store the next valid serial, not the one we just used. f << "%04X" % (serial + 1) } serial end # Does the password file exist? def password? FileTest.exist? Puppet[:capass] end # Print a given host's certificate as text. def print(name) (cert = Puppet::SSL::Certificate.find(name)) ? cert.to_text : nil end # Revoke a given certificate. def revoke(name) raise ArgumentError, "Cannot revoke certificates when the CRL is disabled" unless crl if cert = Puppet::SSL::Certificate.find(name) serial = cert.content.serial elsif ! serial = inventory.serial(name) raise ArgumentError, "Could not find a serial number for #{name}" end crl.revoke(serial, host.key.content) end # This initializes our CA so it actually works. This should be a private # method, except that you can't any-instance stub private methods, which is # *awesome*. This method only really exists to provide a stub-point during # testing. def setup generate_ca_certificate unless @host.certificate end # Sign a given certificate request. def sign(hostname, cert_type = :server, self_signing_csr = nil) # This is a self-signed certificate if self_signing_csr csr = self_signing_csr issuer = csr.content else unless csr = Puppet::SSL::CertificateRequest.find(hostname) raise ArgumentError, "Could not find certificate request for #{hostname}" end issuer = host.certificate.content # Reject unknown request extensions. unknown_req = csr.request_extensions. reject {|x| RequestExtensionWhitelist.include? x["oid"] } if unknown_req and unknown_req.count > 0 names = unknown_req.map {|x| x["oid"] }.sort.uniq.join(", ") raise ArgumentError, "CSR has unknown request extensions: #{names}" end end cert = Puppet::SSL::Certificate.new(hostname) - cert.content = Puppet::SSL::CertificateFactory.new(cert_type, csr.content, issuer, next_serial).result + cert.content = Puppet::SSL::CertificateFactory. + build(cert_type, csr, issuer, next_serial) cert.content.sign(host.key.content, OpenSSL::Digest::SHA1.new) Puppet.notice "Signed certificate request for #{hostname}" # Add the cert to the inventory before we save it, since # otherwise we could end up with it being duplicated, if # this is the first time we build the inventory file. inventory.add(cert) # Save the now-signed cert. This should get routed correctly depending # on the certificate type. cert.save # And remove the CSR if this wasn't self signed. Puppet::SSL::CertificateRequest.destroy(csr.name) unless self_signing_csr cert end # Verify a given host's certificate. def verify(name) unless cert = Puppet::SSL::Certificate.find(name) raise ArgumentError, "Could not find a certificate for #{name}" end store = OpenSSL::X509::Store.new store.add_file Puppet[:cacert] store.add_crl crl.content if self.crl store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK_ALL|OpenSSL::X509::V_FLAG_CRL_CHECK if Puppet.settings[:certificate_revocation] raise CertificateVerificationError.new(store.error), store.error_string unless store.verify(cert.content) end def fingerprint(name, md = :MD5) unless cert = Puppet::SSL::Certificate.find(name) || Puppet::SSL::CertificateRequest.find(name) raise ArgumentError, "Could not find a certificate or csr for #{name}" end cert.fingerprint(md) end # List the waiting certificate requests. def waiting? Puppet::SSL::CertificateRequest.search("*").collect { |r| r.name } end end diff --git a/lib/puppet/ssl/certificate_factory.rb b/lib/puppet/ssl/certificate_factory.rb index 73290e9cf..71c9a20fe 100644 --- a/lib/puppet/ssl/certificate_factory.rb +++ b/lib/puppet/ssl/certificate_factory.rb @@ -1,145 +1,160 @@ require 'puppet/ssl' # The tedious class that does all the manipulations to the # certificate to correctly sign it. Yay. -class Puppet::SSL::CertificateFactory +module Puppet::SSL::CertificateFactory # How we convert from various units to the required seconds. UNITMAP = { "y" => 365 * 24 * 60 * 60, "d" => 24 * 60 * 60, "h" => 60 * 60, "s" => 1 } - attr_reader :name, :cert_type, :csr, :issuer, :serial + def self.build(cert_type, csr, issuer, serial) + # Work out if we can even build the requested type of certificate. + build_extensions = "build_#{cert_type.to_s}_extensions" + respond_to?(build_extensions) or + raise ArgumentError, "#{cert_type.to_s} is an invalid certificate type!" - def initialize(cert_type, csr, issuer, serial) - @cert_type, @csr, @issuer, @serial = cert_type, csr, issuer, serial + # set up the certificate, and start building the content. + cert = OpenSSL::X509::Certificate.new - @name = @csr.subject - end - - # Actually generate our certificate. - def result - @cert = OpenSSL::X509::Certificate.new + cert.version = 2 # X509v3 + cert.subject = csr.content.subject + cert.issuer = issuer.subject + cert.public_key = csr.content.public_key + cert.serial = serial - @cert.version = 2 # X509v3 - @cert.subject = @csr.subject - @cert.issuer = @issuer.subject - @cert.public_key = @csr.public_key - @cert.serial = @serial + # Make the certificate valid as of yesterday, because so many people's + # clocks are out of sync. This gives one more day of validity than people + # might expect, but is better than making every person who has a messed up + # clock fail, and better than having every cert we generate expire a day + # before the user expected it to when they asked for "one year". + cert.not_before = Time.now - (60*60*24) + cert.not_after = Time.now + ttl - build_extensions + add_extensions_to(cert, csr, issuer, send(build_extensions)) - set_ttl - - @cert + return cert end private - # This is pretty ugly, but I'm not really sure it's even possible to do - # it any other way. - def build_extensions - @ef = OpenSSL::X509::ExtensionFactory.new - - @ef.subject_certificate = @cert + def self.add_extensions_to(cert, csr, issuer, extensions) + ef = OpenSSL::X509::ExtensionFactory. + new(cert, issuer.is_a?(OpenSSL::X509::Request) ? cert : issuer) - if @issuer.is_a?(OpenSSL::X509::Request) # It's a self-signed cert - @ef.issuer_certificate = @cert - else - @ef.issuer_certificate = @issuer + # Extract the requested extensions from the CSR. + requested_exts = csr.request_extensions.inject({}) do |hash, re| + hash[re["oid"]] = [re["value"], re["critical"]] + hash end - @subject_alt_name = [] - @key_usage = nil - @ext_key_usage = nil - @extensions = [] - - method = "add_#{@cert_type.to_s}_extensions" - - begin - send(method) - rescue NoMethodError - raise ArgumentError, "#{@cert_type} is an invalid certificate type" + # Produce our final set of extensions. We deliberately order these to + # build the way we want: + # 1. "safe" default values, like the comment, that no one cares about. + # 2. request extensions, from the CSR + # 3. extensions based on the type we are generating + # 4. overrides, which we always want to have in their form + # + # This ordering *is* security-critical, but we want to allow the user + # enough rope to shoot themselves in the foot, if they want to ignore our + # advice and externally approve a CSR that sets the basicConstraints. + # + # Swapping the order of 2 and 3 would ensure that you couldn't slip a + # certificate through where the CA constraint was true, though, if + # something went wrong up there. --daniel 2011-10-11 + defaults = { "nsComment" => "Puppet Ruby/OpenSSL Internal Certificate" } + override = { "subjectKeyIdentifier" => "hash" } + + exts = [defaults, requested_exts, extensions, override]. + inject({}) {|ret, val| ret.merge(val) } + + cert.extensions = exts.map do |oid, val| + val, crit = *val + val = val.join(', ') unless val.is_a? String + + # val can be either a string, or [string, critical], and this does the + # right thing regardless of what we get passed. + ef.create_ext(oid, val, crit) end - - @extensions << @ef.create_extension("nsComment", "Puppet Ruby/OpenSSL Generated Certificate") - @extensions << @ef.create_extension("basicConstraints", @basic_constraint, true) - @extensions << @ef.create_extension("subjectKeyIdentifier", "hash") - @extensions << @ef.create_extension("keyUsage", @key_usage.join(",")) if @key_usage - @extensions << @ef.create_extension("extendedKeyUsage", @ext_key_usage.join(",")) if @ext_key_usage - @extensions << @ef.create_extension("subjectAltName", @subject_alt_name.join(",")) if ! @subject_alt_name.empty? - - @cert.extensions = @extensions - - # for some reason this _must_ be the last extension added - @extensions << @ef.create_extension("authorityKeyIdentifier", "keyid:always,issuer:always") if @cert_type == :ca end # TTL for new certificates in seconds. If config param :ca_ttl is set, # use that, otherwise use :ca_days for backwards compatibility - def ttl + def self.ttl ttl = Puppet.settings[:ca_ttl] return ttl unless ttl.is_a?(String) raise ArgumentError, "Invalid ca_ttl #{ttl}" unless ttl =~ /^(\d+)(y|d|h|s)$/ $1.to_i * UNITMAP[$2] end - def set_ttl - # Make the certificate valid as of yesterday, because - # so many people's clocks are out of sync. - from = Time.now - (60*60*24) - @cert.not_before = from - @cert.not_after = from + ttl - end - # Woot! We're a CA. - def add_ca_extensions - @basic_constraint = "CA:TRUE" - @key_usage = %w{cRLSign keyCertSign} + def self.build_ca_extensions + { + # This was accidentally omitted in the previous version of this code: an + # effort was made to add it last, but that actually managed to avoid + # adding it to the certificate at all. + # + # We have some sort of bug, which means that when we add it we get a + # complaint that the issuer keyid can't be fetched, which breaks all + # sorts of things in our test suite and, e.g., bootstrapping the CA. + # + # http://tools.ietf.org/html/rfc5280#section-4.2.1.1 says that, to be a + # conforming CA we MAY omit the field if we are self-signed, which I + # think gives us a pass in the specific case. + # + # It also notes that we MAY derive the ID from the subject and serial + # number of the issuer, or from the key ID, and we definitely have the + # former data, should we want to restore this... + # + # Anyway, preserving this bug means we don't risk breaking anything in + # the field, even though it would be nice to have. --daniel 2011-10-11 + # + # "authorityKeyIdentifier" => "keyid:always,issuer:always", + "keyUsage" => [%w{cRLSign keyCertSign}, true], + "basicConstraints" => ["CA:TRUE", true], + } end # We're a terminal CA, probably not self-signed. - def add_terminalsubca_extensions - @basic_constraint = "CA:TRUE,pathlen:0" - @key_usage = %w{cRLSign keyCertSign} + def self.build_terminalsubca_extensions + { + "keyUsage" => [%w{cRLSign keyCertSign}, true], + "basicConstraints" => ["CA:TRUE,pathlen:0", true], + } end # We're a normal server. - def add_server_extensions - @basic_constraint = "CA:FALSE" - dnsnames = Puppet[:certdnsnames] - name = @name.to_s.sub(%r{/CN=},'') - if dnsnames != "" - dnsnames.split(':').each { |d| @subject_alt_name << 'DNS:' + d } - @subject_alt_name << 'DNS:' + name # Add the fqdn as an alias - elsif name == Facter.value(:fqdn) # we're a CA server, and thus probably the server - @subject_alt_name << 'DNS:' + "puppet" # Add 'puppet' as an alias - @subject_alt_name << 'DNS:' + name # Add the fqdn as an alias - @subject_alt_name << 'DNS:' + name.sub(/^[^.]+./, "puppet.") # add puppet.domain as an alias - end - @key_usage = %w{digitalSignature keyEncipherment} - @ext_key_usage = %w{serverAuth clientAuth emailProtection} + def self.build_server_extensions + { + "keyUsage" => [%w{digitalSignature keyEncipherment}, true], + "extendedKeyUsage" => [%w{serverAuth clientAuth emailProtection}, true], + "basicConstraints" => ["CA:FALSE", true], + } end # Um, no idea. - def add_ocsp_extensions - @basic_constraint = "CA:FALSE" - @key_usage = %w{nonRepudiation digitalSignature} - @ext_key_usage = %w{serverAuth OCSPSigning} + def self.build_ocsp_extensions + { + "keyUsage" => [%w{nonRepudiation digitalSignature}, true], + "extendedKeyUsage" => [%w{serverAuth OCSPSigning}, true], + "basicConstraints" => ["CA:FALSE", true], + } end # Normal client. - def add_client_extensions - @basic_constraint = "CA:FALSE" - @key_usage = %w{nonRepudiation digitalSignature keyEncipherment} - @ext_key_usage = %w{clientAuth emailProtection} - - @extensions << @ef.create_extension("nsCertType", "client,email") + def self.build_client_extensions + { + "keyUsage" => [%w{nonRepudiation digitalSignature keyEncipherment}, true], + "extendedKeyUsage" => [%w{clientAuth emailProtection}, true], + "basicConstraints" => ["CA:FALSE", true], + "nsCertType" => "client,email", + } end end diff --git a/spec/unit/ssl/certificate_authority_spec.rb b/spec/unit/ssl/certificate_authority_spec.rb index 3788b74c3..4219fd405 100755 --- a/spec/unit/ssl/certificate_authority_spec.rb +++ b/spec/unit/ssl/certificate_authority_spec.rb @@ -1,780 +1,779 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/ssl/certificate_authority' describe Puppet::SSL::CertificateAuthority do after do Puppet::Util::Cacher.expire Puppet.settings.clearused end def stub_ca_host @key = mock 'key' @key.stubs(:content).returns "cakey" @cacert = mock 'certificate' @cacert.stubs(:content).returns "cacertificate" @host = stub 'ssl_host', :key => @key, :certificate => @cacert, :name => Puppet::SSL::Host.ca_name end it "should have a class method for returning a singleton instance" do Puppet::SSL::CertificateAuthority.should respond_to(:instance) end describe "when finding an existing instance" do describe "and the host is a CA host and the run_mode is master" do before do Puppet.settings.stubs(:value).with(:ca).returns true Puppet.run_mode.stubs(:master?).returns true @ca = mock('ca') Puppet::SSL::CertificateAuthority.stubs(:new).returns @ca end it "should return an instance" do Puppet::SSL::CertificateAuthority.instance.should equal(@ca) end it "should always return the same instance" do Puppet::SSL::CertificateAuthority.instance.should equal(Puppet::SSL::CertificateAuthority.instance) end end describe "and the host is not a CA host" do it "should return nil" do Puppet.settings.stubs(:value).with(:ca).returns false Puppet.run_mode.stubs(:master?).returns true ca = mock('ca') Puppet::SSL::CertificateAuthority.expects(:new).never Puppet::SSL::CertificateAuthority.instance.should be_nil end end describe "and the run_mode is not master" do it "should return nil" do Puppet.settings.stubs(:value).with(:ca).returns true Puppet.run_mode.stubs(:master?).returns false ca = mock('ca') Puppet::SSL::CertificateAuthority.expects(:new).never Puppet::SSL::CertificateAuthority.instance.should be_nil end end end describe "when initializing" do before do Puppet.settings.stubs(:use) Puppet.settings.stubs(:value).returns "ca_testing" Puppet::SSL::CertificateAuthority.any_instance.stubs(:setup) end it "should always set its name to the value of :certname" do Puppet.settings.expects(:value).with(:certname).returns "ca_testing" Puppet::SSL::CertificateAuthority.new.name.should == "ca_testing" end it "should create an SSL::Host instance whose name is the 'ca_name'" do Puppet::SSL::Host.expects(:ca_name).returns "caname" host = stub 'host' Puppet::SSL::Host.expects(:new).with("caname").returns host Puppet::SSL::CertificateAuthority.new end it "should use the :main, :ca, and :ssl settings sections" do Puppet.settings.expects(:use).with(:main, :ssl, :ca) Puppet::SSL::CertificateAuthority.new end it "should create an inventory instance" do Puppet::SSL::Inventory.expects(:new).returns "inventory" Puppet::SSL::CertificateAuthority.new.inventory.should == "inventory" end it "should make sure the CA is set up" do Puppet::SSL::CertificateAuthority.any_instance.expects(:setup) Puppet::SSL::CertificateAuthority.new end end describe "when setting itself up" do it "should generate the CA certificate if it does not have one" do Puppet.settings.stubs :use host = stub 'host' Puppet::SSL::Host.stubs(:new).returns host host.expects(:certificate).returns nil Puppet::SSL::CertificateAuthority.any_instance.expects(:generate_ca_certificate) Puppet::SSL::CertificateAuthority.new end end describe "when retrieving the certificate revocation list" do before do Puppet.settings.stubs(:use) Puppet.settings.stubs(:value).returns "ca_testing" Puppet.settings.stubs(:value).with(:cacrl).returns "/my/crl" cert = stub("certificate", :content => "real_cert") key = stub("key", :content => "real_key") @host = stub 'host', :certificate => cert, :name => "hostname", :key => key Puppet::SSL::CertificateAuthority.any_instance.stubs(:setup) @ca = Puppet::SSL::CertificateAuthority.new @ca.stubs(:host).returns @host end it "should return any found CRL instance" do crl = mock 'crl' Puppet::SSL::CertificateRevocationList.expects(:find).returns crl @ca.crl.should equal(crl) end it "should create, generate, and save a new CRL instance of no CRL can be found" do crl = mock 'crl' Puppet::SSL::CertificateRevocationList.expects(:find).returns nil Puppet::SSL::CertificateRevocationList.expects(:new).returns crl crl.expects(:generate).with(@ca.host.certificate.content, @ca.host.key.content) crl.expects(:save) @ca.crl.should equal(crl) end end describe "when generating a self-signed CA certificate" do before do Puppet.settings.stubs(:use) Puppet.settings.stubs(:value).returns "ca_testing" Puppet::SSL::CertificateAuthority.any_instance.stubs(:setup) Puppet::SSL::CertificateAuthority.any_instance.stubs(:crl) @ca = Puppet::SSL::CertificateAuthority.new @host = stub 'host', :key => mock("key"), :name => "hostname", :certificate => mock('certificate') Puppet::SSL::CertificateRequest.any_instance.stubs(:generate) @ca.stubs(:host).returns @host end it "should create and store a password at :capass" do Puppet.settings.expects(:value).with(:capass).returns "/path/to/pass" FileTest.expects(:exist?).with("/path/to/pass").returns false fh = mock 'filehandle' Puppet.settings.expects(:write).with(:capass).yields fh fh.expects(:print).with { |s| s.length > 18 } @ca.stubs(:sign) @ca.generate_ca_certificate end it "should generate a key if one does not exist" do @ca.stubs :generate_password @ca.stubs :sign @ca.host.expects(:key).returns nil @ca.host.expects(:generate_key) @ca.generate_ca_certificate end it "should create and sign a self-signed cert using the CA name" do request = mock 'request' Puppet::SSL::CertificateRequest.expects(:new).with(@ca.host.name).returns request request.expects(:generate).with(@ca.host.key) request.stubs(:request_extensions => []) @ca.expects(:sign).with(@host.name, :ca, request) @ca.stubs :generate_password @ca.generate_ca_certificate end it "should generate its CRL" do @ca.stubs :generate_password @ca.stubs :sign @ca.host.expects(:key).returns nil @ca.host.expects(:generate_key) @ca.expects(:crl) @ca.generate_ca_certificate end end describe "when signing" do before do Puppet.settings.stubs(:use) Puppet::SSL::CertificateAuthority.any_instance.stubs(:password?).returns true stub_ca_host Puppet::SSL::Host.expects(:new).with(Puppet::SSL::Host.ca_name).returns @host @ca = Puppet::SSL::CertificateAuthority.new @name = "myhost" @real_cert = stub 'realcert', :sign => nil @cert = stub 'certificate', :content => @real_cert Puppet::SSL::Certificate.stubs(:new).returns @cert @cert.stubs(:content=) @cert.stubs(:save) # Stub out the factory - @factory = stub 'factory', :result => "my real cert" - Puppet::SSL::CertificateFactory.stubs(:new).returns @factory + Puppet::SSL::CertificateFactory.stubs(:build).returns "my real cert" @request = stub 'request', :content => "myrequest", :name => @name, :request_extensions => [] # And the inventory @inventory = stub 'inventory', :add => nil @ca.stubs(:inventory).returns @inventory Puppet::SSL::CertificateRequest.stubs(:destroy) end describe "and calculating the next certificate serial number" do before do @path = "/path/to/serial" Puppet.settings.stubs(:value).with(:serial).returns @path @filehandle = stub 'filehandle', :<< => @filehandle Puppet.settings.stubs(:readwritelock).with(:serial).yields @filehandle end it "should default to 0x1 for the first serial number" do @ca.next_serial.should == 0x1 end it "should return the current content of the serial file" do FileTest.stubs(:exist?).with(@path).returns true File.expects(:read).with(@path).returns "0002" @ca.next_serial.should == 2 end it "should write the next serial number to the serial file as hex" do @filehandle.expects(:<<).with("0002") @ca.next_serial end it "should lock the serial file while writing" do Puppet.settings.expects(:readwritelock).with(:serial) @ca.next_serial end end describe "its own certificate" do before do @serial = 10 @ca.stubs(:next_serial).returns @serial end it "should not look up a certificate request for the host" do Puppet::SSL::CertificateRequest.expects(:find).never @ca.sign(@name, :ca, @request) end it "should use a certificate type of :ca" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| args[0] == :ca - end.returns @factory + end.returns "my real cert" @ca.sign(@name, :ca, @request) end it "should pass the provided CSR as the CSR" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| - args[1] == "myrequest" - end.returns @factory + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| + args[1] == @request + end.returns "my real cert" @ca.sign(@name, :ca, @request) end it "should use the provided CSR's content as the issuer" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| args[2] == "myrequest" - end.returns @factory + end.returns "my real cert" @ca.sign(@name, :ca, @request) end it "should pass the next serial as the serial number" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| args[3] == @serial - end.returns @factory + end.returns "my real cert" @ca.sign(@name, :ca, @request) end it "should save the resulting certificate" do @cert.expects(:save) @ca.sign(@name, :ca, @request) end end describe "another host's certificate" do before do @serial = 10 @ca.stubs(:next_serial).returns @serial Puppet::SSL::CertificateRequest.stubs(:find).with(@name).returns @request @cert.stubs :save end it "should use a certificate type of :server" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| args[0] == :server - end.returns @factory + end.returns "my real cert" @ca.sign(@name) end it "should use look up a CSR for the host in the :ca_file terminus" do Puppet::SSL::CertificateRequest.expects(:find).with(@name).returns @request @ca.sign(@name) end it "should fail if no CSR can be found for the host" do Puppet::SSL::CertificateRequest.expects(:find).with(@name).returns nil lambda { @ca.sign(@name) }.should raise_error(ArgumentError) end it "should fail if an unknown request extension is present" do @request.stubs :request_extensions => [{ "oid" => "bananas", "value" => "delicious" }] expect { @ca.sign(@name) }. should raise_error ArgumentError, /unknown request extensions/ end it "should use the CA certificate as the issuer" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| args[2] == @cacert.content - end.returns @factory + end.returns "my real cert" @ca.sign(@name) end it "should pass the next serial as the serial number" do - Puppet::SSL::CertificateFactory.expects(:new).with do |*args| + Puppet::SSL::CertificateFactory.expects(:build).with do |*args| args[3] == @serial - end.returns @factory + end.returns "my real cert" @ca.sign(@name) end it "should sign the resulting certificate using its real key and a digest" do digest = mock 'digest' OpenSSL::Digest::SHA1.expects(:new).returns digest key = stub 'key', :content => "real_key" @ca.host.stubs(:key).returns key @cert.content.expects(:sign).with("real_key", digest) @ca.sign(@name) end it "should save the resulting certificate" do @cert.expects(:save) @ca.sign(@name) end it "should remove the host's certificate request" do Puppet::SSL::CertificateRequest.expects(:destroy).with(@name) @ca.sign(@name) end end it "should create a certificate instance with the content set to the newly signed x509 certificate" do @serial = 10 @ca.stubs(:next_serial).returns @serial Puppet::SSL::CertificateRequest.stubs(:find).with(@name).returns @request @cert.stubs :save Puppet::SSL::Certificate.expects(:new).with(@name).returns @cert @ca.sign(@name) end it "should return the certificate instance" do @ca.stubs(:next_serial).returns @serial Puppet::SSL::CertificateRequest.stubs(:find).with(@name).returns @request @cert.stubs :save @ca.sign(@name).should equal(@cert) end it "should add the certificate to its inventory" do @ca.stubs(:next_serial).returns @serial @inventory.expects(:add).with(@cert) Puppet::SSL::CertificateRequest.stubs(:find).with(@name).returns @request @cert.stubs :save @ca.sign(@name) end it "should have a method for triggering autosigning of available CSRs" do @ca.should respond_to(:autosign) end describe "when autosigning certificates" do it "should do nothing if autosign is disabled" do Puppet.settings.expects(:value).with(:autosign).returns 'false' Puppet::SSL::CertificateRequest.expects(:search).never @ca.autosign end it "should do nothing if no autosign.conf exists" do Puppet.settings.expects(:value).with(:autosign).returns '/auto/sign' FileTest.expects(:exist?).with("/auto/sign").returns false Puppet::SSL::CertificateRequest.expects(:search).never @ca.autosign end describe "and autosign is enabled and the autosign.conf file exists" do before do Puppet.settings.stubs(:value).with(:autosign).returns '/auto/sign' FileTest.stubs(:exist?).with("/auto/sign").returns true File.stubs(:readlines).with("/auto/sign").returns ["one\n", "two\n"] Puppet::SSL::CertificateRequest.stubs(:search).returns [] @store = stub 'store', :allow => nil Puppet::Network::AuthStore.stubs(:new).returns @store end describe "when creating the AuthStore instance to verify autosigning" do it "should create an AuthStore with each line in the configuration file allowed to be autosigned" do Puppet::Network::AuthStore.expects(:new).returns @store @store.expects(:allow).with("one") @store.expects(:allow).with("two") @ca.autosign end it "should reparse the autosign configuration on each call" do Puppet::Network::AuthStore.expects(:new).times(2).returns @store @ca.autosign @ca.autosign end it "should ignore comments" do File.stubs(:readlines).with("/auto/sign").returns ["one\n", "#two\n"] @store.expects(:allow).with("one") @ca.autosign end it "should ignore blank lines" do File.stubs(:readlines).with("/auto/sign").returns ["one\n", "\n"] @store.expects(:allow).with("one") @ca.autosign end end it "should sign all CSRs whose hostname matches the autosign configuration" do csr1 = mock 'csr1' csr2 = mock 'csr2' Puppet::SSL::CertificateRequest.stubs(:search).returns [csr1, csr2] end it "should not sign CSRs whose hostname does not match the autosign configuration" do csr1 = mock 'csr1' csr2 = mock 'csr2' Puppet::SSL::CertificateRequest.stubs(:search).returns [csr1, csr2] end end end end describe "when managing certificate clients" do before do Puppet.settings.stubs(:use) Puppet::SSL::CertificateAuthority.any_instance.stubs(:password?).returns true stub_ca_host Puppet::SSL::Host.expects(:new).returns @host Puppet::SSL::CertificateAuthority.any_instance.stubs(:host).returns @host @cacert = mock 'certificate' @cacert.stubs(:content).returns "cacertificate" @ca = Puppet::SSL::CertificateAuthority.new end it "should have a method for acting on the SSL files" do @ca.should respond_to(:apply) end describe "when applying a method to a set of hosts" do it "should fail if no subjects have been specified" do lambda { @ca.apply(:generate) }.should raise_error(ArgumentError) end it "should create an Interface instance with the specified method and the options" do Puppet::SSL::CertificateAuthority::Interface.expects(:new).with(:generate, :to => :host).returns(stub('applier', :apply => nil)) @ca.apply(:generate, :to => :host) end it "should apply the Interface with itself as the argument" do applier = stub('applier') applier.expects(:apply).with(@ca) Puppet::SSL::CertificateAuthority::Interface.expects(:new).returns applier @ca.apply(:generate, :to => :ca_testing) end end it "should be able to list waiting certificate requests" do req1 = stub 'req1', :name => "one" req2 = stub 'req2', :name => "two" Puppet::SSL::CertificateRequest.expects(:search).with("*").returns [req1, req2] @ca.waiting?.should == %w{one two} end it "should delegate removing hosts to the Host class" do Puppet::SSL::Host.expects(:destroy).with("myhost") @ca.destroy("myhost") end it "should be able to verify certificates" do @ca.should respond_to(:verify) end it "should list certificates as the sorted list of all existing signed certificates" do cert1 = stub 'cert1', :name => "cert1" cert2 = stub 'cert2', :name => "cert2" Puppet::SSL::Certificate.expects(:search).with("*").returns [cert1, cert2] @ca.list.should == %w{cert1 cert2} end describe "and printing certificates" do it "should return nil if the certificate cannot be found" do Puppet::SSL::Certificate.expects(:find).with("myhost").returns nil @ca.print("myhost").should be_nil end it "should print certificates by calling :to_text on the host's certificate" do cert1 = stub 'cert1', :name => "cert1", :to_text => "mytext" Puppet::SSL::Certificate.expects(:find).with("myhost").returns cert1 @ca.print("myhost").should == "mytext" end end describe "and fingerprinting certificates" do before :each do @cert = stub 'cert', :name => "cert", :fingerprint => "DIGEST" Puppet::SSL::Certificate.stubs(:find).with("myhost").returns @cert Puppet::SSL::CertificateRequest.stubs(:find).with("myhost") end it "should raise an error if the certificate or CSR cannot be found" do Puppet::SSL::Certificate.expects(:find).with("myhost").returns nil Puppet::SSL::CertificateRequest.expects(:find).with("myhost").returns nil lambda { @ca.fingerprint("myhost") }.should raise_error end it "should try to find a CSR if no certificate can be found" do Puppet::SSL::Certificate.expects(:find).with("myhost").returns nil Puppet::SSL::CertificateRequest.expects(:find).with("myhost").returns @cert @cert.expects(:fingerprint) @ca.fingerprint("myhost") end it "should delegate to the certificate fingerprinting" do @cert.expects(:fingerprint) @ca.fingerprint("myhost") end it "should propagate the digest algorithm to the certificate fingerprinting system" do @cert.expects(:fingerprint).with(:digest) @ca.fingerprint("myhost", :digest) end end describe "and verifying certificates" do before do @store = stub 'store', :verify => true, :add_file => nil, :purpose= => nil, :add_crl => true, :flags= => nil OpenSSL::X509::Store.stubs(:new).returns @store Puppet.settings.stubs(:value).returns "crtstuff" @cert = stub 'cert', :content => "mycert" Puppet::SSL::Certificate.stubs(:find).returns @cert @crl = stub('crl', :content => "mycrl") @ca.stubs(:crl).returns @crl end it "should fail if the host's certificate cannot be found" do Puppet::SSL::Certificate.expects(:find).with("me").returns(nil) lambda { @ca.verify("me") }.should raise_error(ArgumentError) end it "should create an SSL Store to verify" do OpenSSL::X509::Store.expects(:new).returns @store @ca.verify("me") end it "should add the CA Certificate to the store" do Puppet.settings.stubs(:value).with(:cacert).returns "/ca/cert" @store.expects(:add_file).with "/ca/cert" @ca.verify("me") end it "should add the CRL to the store if the crl is enabled" do @store.expects(:add_crl).with "mycrl" @ca.verify("me") end it "should set the store purpose to OpenSSL::X509::PURPOSE_SSL_CLIENT" do Puppet.settings.stubs(:value).with(:cacert).returns "/ca/cert" @store.expects(:add_file).with "/ca/cert" @ca.verify("me") end it "should set the store flags to check the crl" do @store.expects(:flags=).with OpenSSL::X509::V_FLAG_CRL_CHECK_ALL|OpenSSL::X509::V_FLAG_CRL_CHECK @ca.verify("me") end it "should use the store to verify the certificate" do @cert.expects(:content).returns "mycert" @store.expects(:verify).with("mycert").returns true @ca.verify("me") end it "should fail if the verification returns false" do @cert.expects(:content).returns "mycert" @store.expects(:verify).with("mycert").returns false lambda { @ca.verify("me") }.should raise_error end end describe "and revoking certificates" do before do @crl = mock 'crl' @ca.stubs(:crl).returns @crl @ca.stubs(:next_serial).returns 10 @real_cert = stub 'real_cert', :serial => 15 @cert = stub 'cert', :content => @real_cert Puppet::SSL::Certificate.stubs(:find).returns @cert end it "should fail if the certificate revocation list is disabled" do @ca.stubs(:crl).returns false lambda { @ca.revoke('ca_testing') }.should raise_error(ArgumentError) end it "should delegate the revocation to its CRL" do @ca.crl.expects(:revoke) @ca.revoke('host') end it "should get the serial number from the local certificate if it exists" do @ca.crl.expects(:revoke).with { |serial, key| serial == 15 } Puppet::SSL::Certificate.expects(:find).with("host").returns @cert @ca.revoke('host') end it "should get the serial number from inventory if no local certificate exists" do real_cert = stub 'real_cert', :serial => 15 cert = stub 'cert', :content => real_cert Puppet::SSL::Certificate.expects(:find).with("host").returns nil @ca.inventory.expects(:serial).with("host").returns 16 @ca.crl.expects(:revoke).with { |serial, key| serial == 16 } @ca.revoke('host') end end it "should be able to generate a complete new SSL host" do @ca.should respond_to(:generate) end describe "and generating certificates" do before do @host = stub 'host', :generate_certificate_request => nil Puppet::SSL::Host.stubs(:new).returns @host Puppet::SSL::Certificate.stubs(:find).returns nil @ca.stubs(:sign) end it "should fail if a certificate already exists for the host" do Puppet::SSL::Certificate.expects(:find).with("him").returns "something" lambda { @ca.generate("him") }.should raise_error(ArgumentError) end it "should create a new Host instance with the correct name" do Puppet::SSL::Host.expects(:new).with("him").returns @host @ca.generate("him") end it "should use the Host to generate the certificate request" do @host.expects :generate_certificate_request @ca.generate("him") end it "should sign the generated request" do @ca.expects(:sign).with("him") @ca.generate("him") end end end end diff --git a/spec/unit/ssl/certificate_factory_spec.rb b/spec/unit/ssl/certificate_factory_spec.rb index de2093810..75219ead5 100755 --- a/spec/unit/ssl/certificate_factory_spec.rb +++ b/spec/unit/ssl/certificate_factory_spec.rb @@ -1,107 +1,127 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/ssl/certificate_factory' describe Puppet::SSL::CertificateFactory do - before do - @cert_type = mock 'cert_type' - @name = mock 'name' - @csr = stub 'csr', :subject => @name - @issuer = mock 'issuer' - @serial = mock 'serial' - - @factory = Puppet::SSL::CertificateFactory.new(@cert_type, @csr, @issuer, @serial) + let :serial do OpenSSL::BN.new('12') end + let :name do "example.local" end + let :x509_name do OpenSSL::X509::Name.new([['CN', name]]) end + let :key do Puppet::SSL::Key.new(name).generate end + let :csr do + csr = Puppet::SSL::CertificateRequest.new(name) + csr.generate(key) + csr end - - describe "when initializing" do - it "should set its :cert_type to its first argument" do - @factory.cert_type.should equal(@cert_type) - end - - it "should set its :csr to its second argument" do - @factory.csr.should equal(@csr) - end - - it "should set its :issuer to its third argument" do - @factory.issuer.should equal(@issuer) - end - - it "should set its :serial to its fourth argument" do - @factory.serial.should equal(@serial) - end - - it "should set its name to the subject of the csr" do - @factory.name.should equal(@name) - end + let :issuer do + cert = OpenSSL::X509::Certificate.new + cert.subject = OpenSSL::X509::Name.new([["CN", 'issuer.local']]) + cert end describe "when generating the certificate" do - before do - @cert = mock 'cert' - - @cert.stub_everything - - @factory.stubs :build_extensions - - @factory.stubs :set_ttl - - @issuer_name = mock 'issuer_name' - @issuer.stubs(:subject).returns @issuer_name - - @public_key = mock 'public_key' - @csr.stubs(:public_key).returns @public_key - - OpenSSL::X509::Certificate.stubs(:new).returns @cert - end - it "should return a new X509 certificate" do - OpenSSL::X509::Certificate.expects(:new).returns @cert - @factory.result.should equal(@cert) + subject.build(:server, csr, issuer, serial).should_not == + subject.build(:server, csr, issuer, serial) end it "should set the certificate's version to 2" do - @cert.expects(:version=).with 2 - @factory.result + subject.build(:server, csr, issuer, serial).version.should == 2 end it "should set the certificate's subject to the CSR's subject" do - @cert.expects(:subject=).with @name - @factory.result + cert = subject.build(:server, csr, issuer, serial) + cert.subject.should eql x509_name end it "should set the certificate's issuer to the Issuer's subject" do - @cert.expects(:issuer=).with @issuer_name - @factory.result + cert = subject.build(:server, csr, issuer, serial) + cert.issuer.should eql issuer.subject end it "should set the certificate's public key to the CSR's public key" do - @cert.expects(:public_key=).with @public_key - @factory.result + cert = subject.build(:server, csr, issuer, serial) + cert.public_key.should be_public + cert.public_key.to_s.should == csr.content.public_key.to_s end it "should set the certificate's serial number to the provided serial number" do - @cert.expects(:serial=).with @serial - @factory.result + cert = subject.build(:server, csr, issuer, serial) + cert.serial.should == serial + end + + it "should have 24 hours grace on the start of the cert" do + cert = subject.build(:server, csr, issuer, serial) + cert.not_before.should be_within(1).of(Time.now - 24*60*60) + end + + it "should set the default TTL of the certificate" do + ttl = Puppet::SSL::CertificateFactory.ttl + cert = subject.build(:server, csr, issuer, serial) + cert.not_after.should be_within(1).of(Time.now + ttl) + end + + it "should respect a custom TTL for the CA" do + Puppet[:ca_ttl] = 12 + cert = subject.build(:server, csr, issuer, serial) + cert.not_after.should be_within(1).of(Time.now + 12) end it "should build extensions for the certificate" do - @factory.expects(:build_extensions) - @factory.result + cert = subject.build(:server, csr, issuer, serial) + exts = cert.extensions.map {|x| x.to_h }.group_by {|x| x["oid"] } + exts["nsComment"].should == + [{ "oid" => "nsComment", + "value" => "Puppet Ruby/OpenSSL Internal Certificate", + "critical" => false }] end - it "should set the ttl of the certificate" do - @factory.expects(:set_ttl) - @factory.result + # See #2848 for why we are doing this: we need to make sure that + # subjectAltName is set if the CSR has it, but *not* if it is set when the + # certificate is built! + it "should not add subjectAltNames when it isn't configured" do + # Force creation of the CSR, and check it has no extReq + Puppet[:certdnsnames] = '' + csr.request_extensions.should == [] + + Puppet[:certdnsnames] = 'one:two' + # Verify the CSR still has no extReq, just in case... + csr.request_extensions.should == [] + cert = subject.build(:server, csr, issuer, serial) + + cert.extensions.find {|x| x.oid == 'subjectAltName' }.should be_nil end - end - describe "when building extensions" do - it "should have tests" - end + it "should add subjectAltName when the CSR requests them" do + Puppet[:certdnsnames] = 'one:two' + csr.request_extensions.should_not be_nil + csr.subject_alt_names.should =~ %w{example.local one two}.map{|x| "DNS:#{x}"} + + Puppet[:certdnsnames] = '' + cert = subject.build(:server, csr, issuer, serial) + san = cert.extensions.find {|x| x.oid == 'subjectAltName' } + san.should_not be_nil + %w{one two example.local}.each do |name| + san.value.should =~ /DNS:#{name}\b/i + end + end - describe "when setting the ttl" do - it "should have tests" + # Can't check the CA here, since that requires way more infrastructure + # that I want to build up at this time. We can verify the critical + # values, though, which are non-CA certs. --daniel 2011-10-11 + { :ca => 'CA:TRUE', + :terminalsubca => ['CA:TRUE', 'pathlen:0'], + :server => 'CA:FALSE', + :ocsp => 'CA:FALSE', + :client => 'CA:FALSE', + }.each do |name, value| + it "should set basicConstraints for #{name} #{value.inspect}" do + cert = subject.build(name, csr, issuer, serial) + bc = cert.extensions.find {|x| x.oid == 'basicConstraints' } + bc.should be + bc.value.split(/\s*,\s*/).should =~ Array(value) + end + end end end