# frozen_string_literal: true require_relative '../../puppet/ssl/base' require_relative '../../puppet/ssl/certificate_signer' # This class creates and manages X509 certificate signing requests. # # ## CSR attributes # # CSRs may contain a set of attributes that includes supplementary information # about the CSR or information for the signed certificate. # # PKCS#9/RFC 2985 section 5.4 formally defines the "Challenge password", # "Extension request", and "Extended-certificate attributes", but this # implementation only handles the "Extension request" attribute. Other # attributes may be defined on a CSR, but the RFC doesn't define behavior for # any other attributes so we treat them as only informational. # # ## CSR Extension request attribute # # CSRs may contain an optional set of extension requests, which allow CSRs to # include additional information that may be included in the signed # certificate. Any additional information that should be copied from the CSR # to the signed certificate MUST be included in this attribute. # # This behavior is dictated by PKCS#9/RFC 2985 section 5.4.2. # # @see https://tools.ietf.org/html/rfc2985 "RFC 2985 Section 5.4.2 Extension request" # class Puppet::SSL::CertificateRequest < Puppet::SSL::Base wraps OpenSSL::X509::Request # Because of how the format handler class is included, this # can't be in the base class. def self.supported_formats [:s] end def extension_factory # rubocop:disable Naming/MemoizedInstanceVariableName @ef ||= OpenSSL::X509::ExtensionFactory.new # rubocop:enable Naming/MemoizedInstanceVariableName end # Create a certificate request with our system settings. # # @param key [OpenSSL::X509::Key] The private key associated with this CSR. # @param options [Hash] # @option options [String] :dns_alt_names A comma separated list of # Subject Alternative Names to include in the CSR extension request. # @option options [Hash>] :csr_attributes A hash # of OIDs and values that are either a string or array of strings. # @option options [Array] :extension_requests A hash of # certificate extensions to add to the CSR extReq attribute, excluding # the Subject Alternative Names extension. # # @raise [Puppet::Error] If the generated CSR signature couldn't be verified # # @return [OpenSSL::X509::Request] The generated CSR def generate(key, options = {}) Puppet.info _("Creating a new SSL certificate request for %{name}") % { name: name } # If we're a CSR for the CA, then use the real ca_name, rather than the # fake 'ca' name. This is mostly for backward compatibility with 0.24.x, # but it's also just a good idea. common_name = name == Puppet::SSL::CA_NAME ? Puppet.settings[:ca_name] : name csr = OpenSSL::X509::Request.new csr.version = 0 csr.subject = OpenSSL::X509::Name.new([["CN", common_name]]) csr.public_key = if key.is_a?(OpenSSL::PKey::EC) # EC#public_key doesn't follow the PKey API, # see https://github.com/ruby/openssl/issues/29 key else key.public_key end if options[:csr_attributes] add_csr_attributes(csr, options[:csr_attributes]) end if (ext_req_attribute = extension_request_attribute(options)) csr.add_attribute(ext_req_attribute) end signer = Puppet::SSL::CertificateSigner.new signer.sign(csr, key) raise Puppet::Error, _("CSR sign verification failed; you need to clean the certificate request for %{name} on the server") % { name: name } unless csr.verify(csr.public_key) @content = csr # we won't be able to get the digest on jruby if @content.signature_algorithm Puppet.info _("Certificate Request fingerprint (%{digest}): %{hex_digest}") % { digest: digest.name, hex_digest: digest.to_hex } end @content end def ext_value_to_ruby_value(asn1_arr) # A list of ASN1 types than can't be directly converted to a Ruby type @non_convertible ||= [OpenSSL::ASN1::EndOfContent, OpenSSL::ASN1::BitString, OpenSSL::ASN1::Null, OpenSSL::ASN1::Enumerated, OpenSSL::ASN1::UTCTime, OpenSSL::ASN1::GeneralizedTime, OpenSSL::ASN1::Sequence, OpenSSL::ASN1::Set] begin # Attempt to decode the extension's DER data located in the original OctetString asn1_val = OpenSSL::ASN1.decode(asn1_arr.last.value) rescue OpenSSL::ASN1::ASN1Error # This is to allow supporting the old-style of not DER encoding trusted facts return asn1_arr.last.value end # If the extension value can not be directly converted to an atomic Ruby # type, use the original ASN1 value. This is needed to work around a bug # in Ruby's OpenSSL library which doesn't convert the value of unknown # extension OIDs properly. See PUP-3560 if @non_convertible.include?(asn1_val.class) then # Allows OpenSSL to take the ASN1 value and turn it into something Ruby understands OpenSSL::X509::Extension.new(asn1_arr.first.value, asn1_val.to_der).value else asn1_val.value end end # Return the set of extensions requested on this CSR, in a form designed to # be useful to Ruby: an array of hashes. Which, not coincidentally, you can pass # successfully to the OpenSSL constructor later, if you want. # # @return [Array String}>] An array of two or three element # hashes, with key/value pairs for the extension's oid, its value, and # optionally its critical state. def request_extensions raise Puppet::Error, _("CSR needs content to extract fields") unless @content # Prefer the standard extReq, but accept the Microsoft specific version as # a fallback, if the standard version isn't found. attribute = @content.attributes.find { |x| x.oid == "extReq" } attribute ||= @content.attributes.find { |x| x.oid == "msExtReq" } return [] unless attribute extensions = unpack_extension_request(attribute) index = -1 extensions.map do |ext_values| index += 1 value = ext_value_to_ruby_value(ext_values) # OK, turn that into an extension, to unpack the content. Lovely that # we have to swap the order of arguments to the underlying method, or # perhaps that the ASN.1 representation chose to pack them in a # strange order where the optional component comes *earlier* than the # fixed component in the sequence. case ext_values.length when 2 { "oid" => ext_values[0].value, "value" => value } when 3 { "oid" => ext_values[0].value, "value" => value, "critical" => ext_values[1].value } else raise Puppet::Error, _("In %{attr}, expected extension record %{index} to have two or three items, but found %{count}") % { attr: attribute.oid, index: index, count: ext_values.length } end end end def subject_alt_names @subject_alt_names ||= request_extensions .select { |x| x["oid"] == "subjectAltName" } .map { |x| x["value"].split(/\s*,\s*/) } .flatten .sort .uniq end # Return all user specified attributes attached to this CSR as a hash. IF an # OID has a single value it is returned as a string, otherwise all values are # returned as an array. # # The format of CSR attributes is specified in PKCS#10/RFC 2986 # # @see https://tools.ietf.org/html/rfc2986 "RFC 2986 Certification Request Syntax Specification" # # @api public # # @return [Hash] def custom_attributes x509_attributes = @content.attributes.reject do |attr| PRIVATE_CSR_ATTRIBUTES.include? attr.oid end x509_attributes.map do |attr| { "oid" => attr.oid, "value" => attr.value.value.first.value } end end private # Exclude OIDs that may conflict with how Puppet creates CSRs. # # We only have nominal support for Microsoft extension requests, but since we # ultimately respect that field when looking for extension requests in a CSR # we need to prevent that field from being written to directly. PRIVATE_CSR_ATTRIBUTES = [ 'extReq', '1.2.840.113549.1.9.14', 'msExtReq', '1.3.6.1.4.1.311.2.1.14' ] def add_csr_attributes(csr, csr_attributes) csr_attributes.each do |oid, value| if PRIVATE_CSR_ATTRIBUTES.include? oid raise ArgumentError, _("Cannot specify CSR attribute %{oid}: conflicts with internally used CSR attribute") % { oid: oid } end encoded = OpenSSL::ASN1::PrintableString.new(value.to_s) attr_set = OpenSSL::ASN1::Set.new([encoded]) csr.add_attribute(OpenSSL::X509::Attribute.new(oid, attr_set)) Puppet.debug("Added csr attribute: #{oid} => #{attr_set.inspect}") rescue OpenSSL::X509::AttributeError => e raise Puppet::Error, _("Cannot create CSR with attribute %{oid}: %{message}") % { oid: oid, message: e.message }, e.backtrace end end PRIVATE_EXTENSIONS = [ 'subjectAltName', '2.5.29.17' ] # @api private def extension_request_attribute(options) extensions = [] if options[:extension_requests] options[:extension_requests].each_pair do |oid, value| if PRIVATE_EXTENSIONS.include? oid raise Puppet::Error, _("Cannot specify CSR extension request %{oid}: conflicts with internally used extension request") % { oid: oid } end ext = OpenSSL::X509::Extension.new(oid, OpenSSL::ASN1::UTF8String.new(value.to_s).to_der, false) extensions << ext rescue OpenSSL::X509::ExtensionError => e raise Puppet::Error, _("Cannot create CSR with extension request %{oid}: %{message}") % { oid: oid, message: e.message }, e.backtrace end end if options[:dns_alt_names] raw_names = options[:dns_alt_names].split(/\s*,\s*/).map(&:strip) + [name] parsed_names = raw_names.map do |name| if !name.start_with?("IP:") && !name.start_with?("DNS:") "DNS:#{name}" else name end end.sort.uniq.join(", ") alt_names_ext = extension_factory.create_extension("subjectAltName", parsed_names, false) extensions << alt_names_ext end unless extensions.empty? seq = OpenSSL::ASN1::Sequence(extensions) ext_req = OpenSSL::ASN1::Set([seq]) OpenSSL::X509::Attribute.new("extReq", ext_req) end end # Unpack the extReq attribute into an array of Extensions. # # The extension request attribute is structured like # `Set[Sequence[Extensions]]` where the outer Set only contains a single # sequence. # # In addition the Ruby implementation of ASN1 requires that all ASN1 values # contain a single value, so Sets and Sequence have to contain an array # that in turn holds the elements. This is why we have to unpack an array # every time we unpack a Set/Seq. # # @see https://tools.ietf.org/html/rfc2985#ref-10 5.4.2 CSR Extension Request structure # @see https://tools.ietf.org/html/rfc5280 4.1 Certificate Extension structure # # @api private # # @param attribute [OpenSSL::X509::Attribute] The X509 extension request # # @return [Array>] A array of arrays containing the extension # OID the critical state if present, and the extension value. def unpack_extension_request(attribute) unless attribute.value.is_a? OpenSSL::ASN1::Set raise Puppet::Error, _("In %{attr}, expected Set but found %{klass}") % { attr: attribute.oid, klass: attribute.value.class } end unless attribute.value.value.is_a? Array raise Puppet::Error, _("In %{attr}, expected Set[Array] but found %{klass}") % { attr: attribute.oid, klass: attribute.value.value.class } end unless attribute.value.value.size == 1 raise Puppet::Error, _("In %{attr}, expected Set[Array] with one value but found %{count} elements") % { attr: attribute.oid, count: attribute.value.value.size } end unless attribute.value.value.first.is_a? OpenSSL::ASN1::Sequence raise Puppet::Error, _("In %{attr}, expected Set[Array[Sequence[...]]], but found %{klass}") % { attr: attribute.oid, klass: extension.class } end unless attribute.value.value.first.value.is_a? Array raise Puppet::Error, _("In %{attr}, expected Set[Array[Sequence[Array[...]]]], but found %{klass}") % { attr: attribute.oid, klass: extension.value.class } end extensions = attribute.value.value.first.value extensions.map(&:value) end end