diff --git a/lib/puppet/ssl/base.rb b/lib/puppet/ssl/base.rb index f9bbcac64..25fd70ca2 100644 --- a/lib/puppet/ssl/base.rb +++ b/lib/puppet/ssl/base.rb @@ -1,79 +1,87 @@ require 'puppet/ssl' # The base class for wrapping SSL instances. class Puppet::SSL::Base # For now, use the YAML separator. SEPARATOR = "\n---\n" + # Only allow printing ascii characters, excluding / + VALID_CERTNAME = /\A[ -.0-~]+\Z/ + def self.from_multiple_s(text) text.split(SEPARATOR).collect { |inst| from_s(inst) } end def self.to_multiple_s(instances) instances.collect { |inst| inst.to_s }.join(SEPARATOR) end def self.wraps(klass) @wrapped_class = klass end def self.wrapped_class raise(Puppet::DevError, "#{self} has not declared what class it wraps") unless defined?(@wrapped_class) @wrapped_class end + def self.validate_certname(name) + raise "Certname #{name.inspect} must not contain unprintable or non-ASCII characters" unless name =~ VALID_CERTNAME + end + attr_accessor :name, :content # Is this file for the CA? def ca? name == Puppet::SSL::Host.ca_name end def generate raise Puppet::DevError, "#{self.class} did not override 'generate'" end def initialize(name) @name = name.to_s.downcase + self.class.validate_certname(@name) end # Read content from disk appropriately. def read(path) @content = wrapped_class.new(File.read(path)) end # Convert our thing to pem. def to_s return "" unless content content.to_pem end # Provide the full text of the thing we're dealing with. def to_text return "" unless content content.to_text end def fingerprint(md = :MD5) require 'openssl/digest' # ruby 1.8.x openssl digest constants are string # but in 1.9.x they are symbols mds = md.to_s.upcase if OpenSSL::Digest.constants.include?(mds) md = mds elsif OpenSSL::Digest.constants.include?(mds.to_sym) md = mds.to_sym else raise ArgumentError, "#{md} is not a valid digest algorithm for fingerprinting certificate #{name}" end OpenSSL::Digest.const_get(md).hexdigest(content.to_der).scan(/../).join(':').upcase end private def wrapped_class self.class.wrapped_class end end diff --git a/lib/puppet/ssl/certificate_authority.rb b/lib/puppet/ssl/certificate_authority.rb index 69c8ebb17..6cda36b22 100644 --- a/lib/puppet/ssl/certificate_authority.rb +++ b/lib/puppet/ssl/certificate_authority.rb @@ -1,373 +1,372 @@ require 'monitor' require 'puppet/ssl/host' require 'puppet/ssl/certificate_request' # 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{subjectAltName} 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' extend MonitorMixin class CertificateVerificationError < RuntimeError attr_accessor :error_code def initialize(code) @error_code = code end end def self.singleton_instance synchronize do @singleton_instance ||= new end end class CertificateSigningError < RuntimeError attr_accessor :host def initialize(host) @host = host end 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.indirection.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.indirection.find(Puppet::SSL::CA_NAME) @crl = Puppet::SSL::CertificateRevocationList.new(Puppet::SSL::CA_NAME) @crl.generate(host.certificate.content, host.key.content) Puppet::SSL::CertificateRevocationList.indirection.save(@crl) 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, options = {}) raise ArgumentError, "A Certificate already exists for #{name}" if Puppet::SSL::Certificate.indirection.find(name) host = Puppet::SSL::Host.new(name) # Pass on any requested subjectAltName field. san = options[:dns_alt_names] host = Puppet::SSL::Host.new(name) host.generate_certificate_request(:dns_alt_names => san) sign(name, !!san) 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) # We deliberately do not put any subjectAltName in here: the CA # certificate absolutely does not need them. --daniel 2011-10-13 request.generate(host.key) # Create a self-signed certificate. @certificate = sign(host.name, false, 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.indirection.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.indirection.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.indirection.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, allow_dns_alt_names = false, self_signing_csr = nil) # This is a self-signed certificate if self_signing_csr # # This is a self-signed certificate, which is for the CA. Since this # # forces the certificate to be self-signed, anyone who manages to trick # # the system into going through this path gets a certificate they could # # generate anyway. There should be no security risk from that. csr = self_signing_csr cert_type = :ca issuer = csr.content else allow_dns_alt_names = true if hostname == Puppet[:certname].downcase unless csr = Puppet::SSL::CertificateRequest.indirection.find(hostname) raise ArgumentError, "Could not find certificate request for #{hostname}" end cert_type = :server issuer = host.certificate.content # Make sure that the CSR conforms to our internal signing policies. # This will raise if the CSR doesn't conform, but just in case... check_internal_signing_policies(hostname, csr, allow_dns_alt_names) or raise CertificateSigningError.new(hostname), "CSR had an unknown failure checking internal signing policies, will not sign!" end cert = Puppet::SSL::Certificate.new(hostname) 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. Puppet::SSL::Certificate.indirection.save(cert) # And remove the CSR if this wasn't self signed. Puppet::SSL::CertificateRequest.indirection.destroy(csr.name) unless self_signing_csr cert end def check_internal_signing_policies(hostname, csr, allow_dns_alt_names) # Reject unknown request extensions. unknown_req = csr.request_extensions. reject {|x| RequestExtensionWhitelist.include? x["oid"] } if unknown_req and not unknown_req.empty? names = unknown_req.map {|x| x["oid"] }.sort.uniq.join(", ") raise CertificateSigningError.new(hostname), "CSR has request extensions that are not permitted: #{names}" end # Do not sign misleading CSRs cn = csr.content.subject.to_a.assoc("CN")[1] if hostname != cn raise CertificateSigningError.new(hostname), "CSR subject common name #{cn.inspect} does not match expected certname #{hostname.inspect}" end - # Only allow printing ascii characters, excluding / - if hostname !~ /\A[ -.0-~]+\Z/ + if hostname !~ Puppet::SSL::Base::VALID_CERTNAME raise CertificateSigningError.new(hostname), "CSR #{hostname.inspect} subject contains unprintable or non-ASCII characters" end # Wildcards: we don't allow 'em at any point. # # The stringification here makes the content visible, and saves us having # to scrobble through the content of the CSR subject field to make sure it # is what we expect where we expect it. if csr.content.subject.to_s.include? '*' raise CertificateSigningError.new(hostname), "CSR subject contains a wildcard, which is not allowed: #{csr.content.subject.to_s}" end unless csr.subject_alt_names.empty? # If you alt names are allowed, they are required. Otherwise they are # disallowed. Self-signed certs are implicitly trusted, however. unless allow_dns_alt_names raise CertificateSigningError.new(hostname), "CSR '#{csr.name}' contains subject alternative names (#{csr.subject_alt_names.join(', ')}), which are disallowed. Use `puppet cert --allow-dns-alt-names sign #{csr.name}` to sign this request." end # If subjectAltNames are present, validate that they are only for DNS # labels, not any other kind. unless csr.subject_alt_names.all? {|x| x =~ /^DNS:/ } raise CertificateSigningError.new(hostname), "CSR '#{csr.name}' contains a subjectAltName outside the DNS label space: #{csr.subject_alt_names.join(', ')}. To continue, this CSR needs to be cleaned." end # Check for wildcards in the subjectAltName fields too. if csr.subject_alt_names.any? {|x| x.include? '*' } raise CertificateSigningError.new(hostname), "CSR '#{csr.name}' subjectAltName contains a wildcard, which is not allowed: #{csr.subject_alt_names.join(', ')} To continue, this CSR needs to be cleaned." end end return true # good enough for us! end # Verify a given host's certificate. def verify(name) unless cert = Puppet::SSL::Certificate.indirection.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.indirection.find(name) || Puppet::SSL::CertificateRequest.indirection.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.indirection.search("*").collect { |r| r.name } end end diff --git a/lib/puppet/ssl/host.rb b/lib/puppet/ssl/host.rb index a4baf5b7c..240c5be39 100644 --- a/lib/puppet/ssl/host.rb +++ b/lib/puppet/ssl/host.rb @@ -1,339 +1,340 @@ require 'puppet/indirector' require 'puppet/ssl' require 'puppet/ssl/key' require 'puppet/ssl/certificate' require 'puppet/ssl/certificate_request' require 'puppet/ssl/certificate_revocation_list' # The class that manages all aspects of our SSL certificates -- # private keys, public keys, requests, etc. class Puppet::SSL::Host # Yay, ruby's strange constant lookups. Key = Puppet::SSL::Key CA_NAME = Puppet::SSL::CA_NAME Certificate = Puppet::SSL::Certificate CertificateRequest = Puppet::SSL::CertificateRequest CertificateRevocationList = Puppet::SSL::CertificateRevocationList extend Puppet::Indirector indirects :certificate_status, :terminus_class => :file attr_reader :name attr_accessor :ca attr_writer :key, :certificate, :certificate_request # This accessor is used in instances for indirector requests to hold desired state attr_accessor :desired_state def self.localhost return @localhost if @localhost @localhost = new @localhost.generate unless @localhost.certificate @localhost.key @localhost end def self.reset @localhost = nil end # This is the constant that people will use to mark that a given host is # a certificate authority. def self.ca_name CA_NAME end class << self attr_reader :ca_location end # Configure how our various classes interact with their various terminuses. def self.configure_indirection(terminus, cache = nil) Certificate.indirection.terminus_class = terminus CertificateRequest.indirection.terminus_class = terminus CertificateRevocationList.indirection.terminus_class = terminus host_map = {:ca => :file, :file => nil, :rest => :rest} if term = host_map[terminus] self.indirection.terminus_class = term else self.indirection.reset_terminus_class end if cache # This is weird; we don't actually cache our keys, we # use what would otherwise be the cache as our normal # terminus. Key.indirection.terminus_class = cache else Key.indirection.terminus_class = terminus end if cache Certificate.indirection.cache_class = cache CertificateRequest.indirection.cache_class = cache CertificateRevocationList.indirection.cache_class = cache else # Make sure we have no cache configured. puppet master # switches the configurations around a bit, so it's important # that we specify the configs for absolutely everything, every # time. Certificate.indirection.cache_class = nil CertificateRequest.indirection.cache_class = nil CertificateRevocationList.indirection.cache_class = nil end end CA_MODES = { # Our ca is local, so we use it as the ultimate source of information # And we cache files locally. :local => [:ca, :file], # We're a remote CA client. :remote => [:rest, :file], # We are the CA, so we don't have read/write access to the normal certificates. :only => [:ca], # We have no CA, so we just look in the local file store. :none => [:file] } # Specify how we expect to interact with our certificate authority. def self.ca_location=(mode) modes = CA_MODES.collect { |m, vals| m.to_s }.join(", ") raise ArgumentError, "CA Mode can only be one of: #{modes}" unless CA_MODES.include?(mode) @ca_location = mode configure_indirection(*CA_MODES[@ca_location]) end # Puppet::SSL::Host is actually indirected now so the original implementation # has been moved into the certificate_status indirector. This method is in-use # in `puppet cert -c `. def self.destroy(name) indirection.destroy(name) end def self.from_pson(pson) instance = new(pson["name"]) if pson["desired_state"] instance.desired_state = pson["desired_state"] end instance end # Puppet::SSL::Host is actually indirected now so the original implementation # has been moved into the certificate_status indirector. This method does not # appear to be in use in `puppet cert -l`. def self.search(options = {}) indirection.search("*", options) end # Is this a ca host, meaning that all of its files go in the CA location? def ca? ca end def key @key ||= Key.indirection.find(name) end # This is the private key; we can create it from scratch # with no inputs. def generate_key @key = Key.new(name) @key.generate begin Key.indirection.save(@key) rescue @key = nil raise end true end def certificate_request @certificate_request ||= CertificateRequest.indirection.find(name) end def this_csr_is_for_the_current_host name == Puppet[:certname].downcase end def this_csr_is_for_the_current_host name == Puppet[:certname].downcase end # Our certificate request requires the key but that's all. def generate_certificate_request(options = {}) generate_key unless key # If this is for the current machine... if this_csr_is_for_the_current_host # ...add our configured dns_alt_names if Puppet[:dns_alt_names] and Puppet[:dns_alt_names] != '' options[:dns_alt_names] ||= Puppet[:dns_alt_names] elsif Puppet::SSL::CertificateAuthority.ca? and fqdn = Facter.value(:fqdn) and domain = Facter.value(:domain) options[:dns_alt_names] = "puppet, #{fqdn}, puppet.#{domain}" end end @certificate_request = CertificateRequest.new(name) @certificate_request.generate(key.content, options) begin CertificateRequest.indirection.save(@certificate_request) rescue @certificate_request = nil raise end true end def certificate unless @certificate generate_key unless key # get the CA cert first, since it's required for the normal cert # to be of any use. return nil unless Certificate.indirection.find("ca") unless ca? return nil unless @certificate = Certificate.indirection.find(name) validate_certificate_with_key end @certificate end def validate_certificate_with_key raise Puppet::Error, "No certificate to validate." unless certificate raise Puppet::Error, "No private key with which to validate certificate with fingerprint: #{certificate.fingerprint}" unless key unless certificate.content.check_private_key(key.content) raise Puppet::Error, < name } my_state = state pson_hash[:state] = my_state pson_hash[:desired_state] = desired_state if desired_state if my_state == 'requested' pson_hash[:fingerprint] = certificate_request.fingerprint else pson_hash[:fingerprint] = my_cert.fingerprint end pson_hash.to_pson(*args) end # Attempt to retrieve a cert, if we don't already have one. def wait_for_cert(time) begin return if certificate generate return if certificate rescue SystemExit,NoMemoryError raise rescue Exception => detail puts detail.backtrace if Puppet[:trace] Puppet.err "Could not request certificate: #{detail}" if time < 1 puts "Exiting; failed to retrieve certificate and waitforcert is disabled" exit(1) else sleep(time) end retry end if time < 1 puts "Exiting; no certificate found and waitforcert is disabled" exit(1) end while true sleep time begin break if certificate Puppet.notice "Did not receive certificate" rescue StandardError => detail puts detail.backtrace if Puppet[:trace] Puppet.err "Could not request certificate: #{detail}" end end end def state my_cert = Puppet::SSL::Certificate.indirection.find(name) if certificate_request return 'requested' end begin Puppet::SSL::CertificateAuthority.new.verify(my_cert) return 'signed' rescue Puppet::SSL::CertificateAuthority::CertificateVerificationError return 'revoked' end end end require 'puppet/ssl/certificate_authority' diff --git a/spec/unit/network/handler/ca_spec.rb b/spec/unit/network/handler/ca_spec.rb index 43aa5a721..855743017 100644 --- a/spec/unit/network/handler/ca_spec.rb +++ b/spec/unit/network/handler/ca_spec.rb @@ -1,86 +1,87 @@ require 'spec_helper' require 'puppet/network/handler/ca' describe Puppet::Network::Handler::CA, :unless => Puppet.features.microsoft_windows? do include PuppetSpec::Files describe "#getcert" do let(:host) { "testhost" } let(:x509_name) { OpenSSL::X509::Name.new [['CN', host]] } let(:key) { Puppet::SSL::Key.new(host).generate } let(:csr) do csr = OpenSSL::X509::Request.new csr.subject = x509_name csr.public_key = key.public_key csr end let(:ca) { Puppet::SSL::CertificateAuthority.new } let(:cacert) { ca.instance_variable_get(:@certificate) } before :each do Puppet[:confdir] = tmpdir('conf') Puppet::SSL::CertificateAuthority.stubs(:ca?).returns true Puppet::SSL::CertificateAuthority.stubs(:singleton_instance).returns ca end it "should do nothing if the master is not a CA" do Puppet::SSL::CertificateAuthority.stubs(:ca?).returns false csr = OpenSSL::X509::Request.new + csr.subject = OpenSSL::X509::Name.new([["CN", "anything"]]) subject.getcert(csr.to_pem).should == '' end describe "when a certificate already exists for the host" do let!(:cert) { ca.generate(host) } it "should return the existing cert if it matches the public key of the CSR" do csr.public_key = cert.content.public_key subject.getcert(csr.to_pem).should == [cert.to_s, cacert.to_s] end it "should fail if the public key of the CSR does not match the existing cert" do expect do subject.getcert(csr.to_pem) end.to raise_error(Puppet::Error, /Certificate request does not match existing certificate/) end end describe "when autosign is enabled" do before :each do Puppet[:autosign] = true end it "should return the new cert and the CA cert" do cert_str, cacert_str = subject.getcert(csr.to_pem) returned_cert = Puppet::SSL::Certificate.from_s(cert_str) returned_cacert = Puppet::SSL::Certificate.from_s(cacert_str) returned_cert.name.should == host returned_cacert.content.subject.cmp(cacert.content.subject).should == 0 end end describe "when autosign is disabled" do before :each do Puppet[:autosign] = false end it "should save the CSR without signing it" do subject.getcert(csr.to_pem) Puppet::SSL::Certificate.indirection.find(host).should be_nil Puppet::SSL::CertificateRequest.indirection.find(host).should be_a(Puppet::SSL::CertificateRequest) end it "should not return a cert" do subject.getcert(csr.to_pem).should be_nil end end end end