require 'puppet/ssl' require 'puppet/ssl/key' require 'puppet/ssl/certificate' require 'puppet/ssl/certificate_request' require 'puppet/ssl/certificate_request_attributes' require 'puppet/ssl/state_machine' require 'puppet/rest/errors' require 'puppet/rest/routes' # 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 attr_reader :name, :device, :crl_path attr_writer :key, :certificate, :certificate_request, :crl_usage def self.localhost return @localhost if @localhost @localhost = new @localhost.generate unless @localhost.certificate @localhost.key @localhost end def self.reset @localhost = nil 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 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 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 end end def self.from_data_hash(data) instance = new(data["name"]) if data["desired_state"] instance.desired_state = data["desired_state"] end instance 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 # Our certificate request requires the key but that's all. def generate_certificate_request(options = {}) generate_key unless key # If this CSR is for the current machine... if name == Puppet[:certname].downcase # ...add our configured dns_alt_names if Puppet[:dns_alt_names] and Puppet[:dns_alt_names] != '' options[:dns_alt_names] ||= Puppet[:dns_alt_names] end end csr_attributes = Puppet::SSL::CertificateRequestAttributes.new(Puppet[:csr_attributes]) if csr_attributes.load options[:csr_attributes] = csr_attributes.custom_attributes options[:extension_requests] = csr_attributes.extension_requests end @certificate_request = CertificateRequest.new(name) @certificate_request.generate(key.content, options) begin submit_certificate_request(@certificate_request) save_certificate_request(@certificate_request) rescue @certificate_request = nil raise end true end def certificate unless @certificate generate_key unless key # get CA and optional CRL sm = Puppet::SSL::StateMachine.new(onetime: true) sm.ensure_ca_certificates cert = get_host_certificate return nil unless cert validate_certificate_with_key(cert) @certificate = cert end @certificate end # The puppet parameters for commands output by the validate_ methods depend # upon whether this is an agent or a device. def clean_params @device ? "--target #{Puppet[:certname]}" : '' end def puppet_params @device ? "device -v --target #{Puppet[:certname]}" : 'agent -t' end # Validate that our private key matches the specified certificate. # # @param [Puppet::SSL::Certificate] cert the certificate to check # @raises [Puppet::Error] if the private key does not match def validate_certificate_with_key(cert) raise Puppet::Error, _("No certificate to validate.") unless cert raise Puppet::Error, _("No private key with which to validate certificate with fingerprint: %{fingerprint}") % { fingerprint: cert.fingerprint } unless key unless cert.content.check_private_key(key.content) raise Puppet::Error, _(< detail Puppet.log_exception(detail, _("Could not request certificate: %{message}") % { message: detail.message }) 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 Puppet.log_exception(detail, _("Could not request certificate: %{message}") % { message: detail.message }) end end end # Saves the given certificate to disc, at a location determined by this # host's configuration. # @param [Puppet::SSL::Certificate] cert the cert to save def save_host_certificate(cert) file_path = certificate_location(name) Puppet::Util.replace_file(file_path, 0644) do |f| f.write(cert.to_s) end end private # Load a previously generated CSR from disk # @return [Puppet::SSL::CertificateRequest, nil] def load_certificate_request_from_file request_path = certificate_request_location(name) if Puppet::FileSystem.exist?(request_path) Puppet::SSL::CertificateRequest.from_s(Puppet::FileSystem.read(request_path)) end end # Download the CSR for this host from the CA. Returns nil if the CA # has no saved CSR for this host. # @raises [Puppet::Error] if the response from the server is not a valid # CSR or an error occurs while fetching. # @return [Puppet::SSL::CertificateRequest, nil] def download_csr_from_ca begin body = Puppet::Rest::Routes.get_certificate_request( name, Puppet::SSL::SSLContext.new(store: ssl_store)) begin Puppet::SSL::CertificateRequest.from_s(body) rescue OpenSSL::X509::RequestError => e raise Puppet::Error, _("Response from the CA did not contain a valid certificate request: %{message}") % { message: e.message } end rescue Puppet::Rest::ResponseError => e if e.response.code.to_i == 404 nil else raise Puppet::Error, _('Could not download certificate request: %{message}') % { message: e.message } end end end # Submit the CSR to the CA via an HTTP PUT request. # @param [Puppet::SSL::CertificateRequest] csr the request to submit def submit_certificate_request(csr) Puppet::Rest::Routes.put_certificate_request( csr.render, name, Puppet::SSL::SSLContext.new(store: ssl_store)) end def save_certificate_request(csr) Puppet::Util.replace_file(certificate_request_location(name), 0644) do |file| file.write(csr.render) end end # @param crl_string [String] CRLs read from disk or obtained from server # @return [Array] CRLs from chain # @raise [Puppet::Error] if the CRL chain is malformed def process_crl_string(crl_string) delimiters = /-----BEGIN X509 CRL-----.*?-----END X509 CRL-----/m crl_string.scan(delimiters).map do |crl| begin OpenSSL::X509::CRL.new(crl) rescue OpenSSL::X509::CRLError => e raise Puppet::Error.new( _("Failed attempting to load CRL from %{crl_path}! The CRL below caused the error '%{error}':\n%{crl}" % {crl_path: crl_path, error: e.message, crl: crl}), e) end end end # @param path [String] Path to CRL Chain # @return [Array] CRLs from chain # @raise [Puppet::Error] if the CRL chain is malformed def load_crls(path) crls_pems = Puppet::FileSystem.read(path, encoding: Encoding::UTF_8) process_crl_string(crls_pems) end # Fetches and saves the crl bundle from the CA server without validating # its contents. Takes an optional store to use with the http_client, # necessary for initial download of the CRL because `build_ssl_store` # calls this `download_and_save_crl_bundle`. If there is an error during # this downloading process, the file should not be replaced at all. This # streams the file directly to disk to avoid loading the entire CRL in memory. # @param [OpenSSL::X509::Store] store optional ssl_store to use with http_client # @raise [Puppet::Error] if bad response from server # @return nil def download_and_save_crl_bundle(store=nil) begin # If no SSL store was supplied, use this host's SSL store store ||= ssl_store Puppet::Util.replace_file(crl_path, 0644) do |file| result = Puppet::Rest::Routes.get_crls(CA_NAME, Puppet::SSL::SSLContext.new(store: store)) file.write(result) end rescue Puppet::Rest::ResponseError => e raise Puppet::Error, _('Could not download CRLs: %{message}') % { message: e.message } end end # Attempts to load or fetch this host's certificate. Returns nil if # no certificate could be found. # @return [Puppet::SSL::Certificate, nil] def get_host_certificate if cert = check_for_certificate_on_disk(name) return cert elsif cert = download_certificate_from_ca(name) save_host_certificate(cert) return cert else return nil end end # Checks for the requested certificate on disc, at a location # determined by this host's configuration. # @name [String] name the name of the cert to look for # @raise [Puppet::Error] if contents of certificate file is invalid # and could not be loaded # @return [Puppet::SSL::Certificate, nil] def check_for_certificate_on_disk(cert_name) file_path = certificate_location(cert_name) if Puppet::FileSystem.exist?(file_path) begin Puppet::SSL::Certificate.from_s(Puppet::FileSystem.read(file_path)) rescue OpenSSL::X509::CertificateError raise Puppet::Error, _("The certificate at %{file_path} is invalid. Could not load.") % { file_path: file_path } end end end public :check_for_certificate_on_disk # Attempts to download this host's certificate from the CA server. # Returns nil if the CA does not yet have a signed cert for this host. # @param [String] name then name of the cert to fetch # @raise [Puppet::Error] if response from the CA does not contain a valid # certificate # @return [Puppet::SSL::Certificate, nil] def download_certificate_from_ca(cert_name) begin cert = Puppet::Rest::Routes.get_certificate( cert_name, Puppet::SSL::SSLContext.new(store: ssl_store) ) begin Puppet::SSL::Certificate.from_s(cert) rescue OpenSSL::X509::CertificateError raise Puppet::Error, _("Response from the CA did not contain a valid certificate for %{cert_name}.") % { cert_name: cert_name } end rescue Puppet::Rest::ResponseError => e if e.response.code.to_i == 404 Puppet.debug _("No certificate for %{cert_name} on CA") % { cert_name: cert_name } nil else raise Puppet::Rest::ResponseError, _("Could not download host certificate: %{message}") % { message: e.message } end end end public :download_certificate_from_ca # Returns the file path for the named certificate, based on this host's # configuration. # @param [String] name the name of the cert to find # @return [String] file path to the cert's location def certificate_location(cert_name) cert_name == CA_NAME ? Puppet[:localcacert] : File.join(Puppet[:certdir], "#{cert_name}.pem") end # Returns the file path for the named CSR, based on this host's configuration. # @param [String] name the name of the CSR to find # @return [String] file path to the CSR's location def certificate_request_location(cert_name) File.join(Puppet[:requestdir], "#{cert_name}.pem") end # @param [OpenSSL::X509::PURPOSE_*] constant defining the kinds of certs # this store can verify # @return [OpenSSL::X509::Store] # @raise [OpenSSL::X509::StoreError] if localcacert is malformed or non-existant # @raise [Puppet::Error] if the CRL chain is malformed # @raise [Errno::ENOENT] if the CRL does not exist on disk but use_crl? is true def build_ssl_store(purpose=OpenSSL::X509::PURPOSE_ANY) store = OpenSSL::X509::Store.new store.purpose = purpose # Use the file path here, because we don't want to cause # a lookup in the middle of setting our ssl connection. store.add_file(Puppet.settings[:localcacert]) if use_crl? if !Puppet::FileSystem.exist?(crl_path) download_and_save_crl_bundle(store) end crls = load_crls(crl_path) flags = OpenSSL::X509::V_FLAG_CRL_CHECK if use_crl_chain? flags |= OpenSSL::X509::V_FLAG_CRL_CHECK_ALL end store.flags = flags crls.each {|crl| store.add_crl(crl) } end store end end