lib/r509/csr.rb in r509-0.8.1 vs lib/r509/csr.rb in r509-0.9

- old
+ new

@@ -1,324 +1,313 @@ require 'openssl' require 'r509/exceptions' require 'r509/io_helpers' -require 'r509/privatekey' +require 'r509/private_key' +require 'r509/ec-hack' +require 'r509/asn1' module R509 - # The primary certificate signing request object - class Csr - include R509::IOHelpers + # The primary certificate signing request object + class CSR + include R509::IOHelpers - attr_reader :san_names, :key, :subject, :req, :attributes, :message_digest - # @option opts [String,OpenSSL::X509::Request] :csr a csr - # @option opts [Symbol] :type :rsa/:dsa - # @option opts [Integer] :bit_strength - # @option opts [Array] :san_names List of domains to encode as subjectAltNames - # @option opts [R509::Subject,Array,OpenSSL::X509::Name] :subject array of subject items - # @example [['CN','langui.sh'],['ST','Illinois'],['L','Chicago'],['C','US'],['emailAddress','ca@langui.sh']] - # you can also pass OIDs (see tests) - # @option opts [String,R509::Cert,OpenSSL::X509::Certificate] :cert takes a cert (used for generating a CSR with the certificate's values) - # @option opts [R509::PrivateKey,String] :key optional private key to supply. either an unencrypted PEM/DER string or an R509::PrivateKey object (use the latter if you need password/hardware support) - def initialize(opts={}) - if not opts.kind_of?(Hash) - raise ArgumentError, 'Must provide a hash of options' - end - if (opts.has_key?(:cert) and opts.has_key?(:subject)) or - (opts.has_key?(:cert) and opts.has_key?(:csr)) or - (opts.has_key?(:subject) and opts.has_key?(:csr)) - raise ArgumentError, "Can only provide one of cert, subject, or csr" - end - @bit_strength = opts[:bit_strength] || 2048 + attr_reader :san, :key, :subject, :req, :attributes, :message_digest + # @option opts [String,OpenSSL::X509::Request] :csr a csr + # @option opts [Symbol] :type :rsa/:dsa/:ec required if not providing existing :csr. Defaults to :rsa + # @option opts [String] :curve_name ("secp384r1") Only used if :type is :ec + # @option opts [Integer] :bit_strength (2048) Only used if :type is :rsa or :dsa + # @option opts [String] :message_digest Optional digest. sha1, sha224, sha256, sha384, sha512, md5. Defaults to sha1 + # @option opts [Array] :san_names List of domains, IPs, email addresses, or URIs to encode as subjectAltNames. The type is determined from the structure of the strings via the R509::ASN1.general_name_parser method + # @option opts [R509::Subject,Array,OpenSSL::X509::Name] :subject array of subject items + # @option opts [R509::PrivateKey,String] :key optional private key to supply. either an unencrypted PEM/DER string or an R509::PrivateKey object (use the latter if you need password/hardware support) + # @example Generate a 4096-bit RSA key + CSR + # :type => :rsa, + # :bit_strength => 4096, + # :subject => [ + # ['CN','somedomain.com'], + # ['O','My Org'], + # ['L','City'], + # ['ST','State'], + # ['C','US'] + # ] + # @example Generate an ECDSA key using the secp384r1 curve parameters + CSR and sign with SHA512 + # :type => :ec, + # :curve_name => 'secp384r1', + # :message_digest => 'sha512', + # :subject => [ + # ['CN','somedomain.com'], + # ] + def initialize(opts={}) + if not opts.kind_of?(Hash) + raise ArgumentError, 'Must provide a hash of options' + end + if opts.has_key?(:subject) and opts.has_key?(:csr) + raise ArgumentError, "You must provide :subject or :csr, not both" + end + @bit_strength = opts[:bit_strength] || 2048 + @curve_name = opts[:curve_name] || "secp384r1" - if opts.has_key?(:key) - if opts[:key].kind_of?(R509::PrivateKey) - @key = opts[:key] - else - @key = R509::PrivateKey.new(:key => opts[:key]) - end - end + if opts.has_key?(:key) + if opts[:key].kind_of?(R509::PrivateKey) + @key = opts[:key] + else + @key = R509::PrivateKey.new(:key => opts[:key]) + end + end - @type = opts[:type] || :rsa - if @type != :rsa and @type != :dsa and @key.nil? - raise ArgumentError, 'Must provide :rsa or :dsa as type when key is nil' - end + @type = opts[:type] || :rsa + if not [:rsa,:dsa,:ec].include?(@type) and @key.nil? + raise ArgumentError, 'Must provide :rsa, :dsa, or :ec as type when key is nil' + end - if opts.has_key?(:cert) - domains = opts[:san_names] || [] - parsed_domains = prefix_domains(domains) - cert_data = parse_cert(opts[:cert]) - merged_domains = cert_data[:subjectAltName].concat(parsed_domains) - create_request(cert_data[:subject],merged_domains) #sets @req - elsif opts.has_key?(:subject) - domains = opts[:san_names] || [] - parsed_domains = prefix_domains(domains) - create_request(opts[:subject], parsed_domains) #sets @req - elsif opts.has_key?(:csr) - if opts.has_key?(:san_names) - raise ArgumentError, "You can't add domains to an existing CSR" - end - parse_csr(opts[:csr]) - else - raise ArgumentError, "Must provide one of cert, subject, or csr" - end - - if dsa? - #only DSS1 is acceptable for DSA signing in OpenSSL < 1.0 - #post-1.0 you can sign with anything, but let's be conservative - #see: http://www.ruby-doc.org/stdlib-1.9.3/libdoc/openssl/rdoc/OpenSSL/PKey/DSA.html - @message_digest = R509::MessageDigest.new('dss1') - elsif opts.has_key?(:message_digest) - @message_digest = R509::MessageDigest.new(opts[:message_digest]) - else - @message_digest = R509::MessageDigest.new('sha1') - end - - if not opts.has_key?(:csr) - @req.sign(@key.key, @message_digest.digest) - end - if not @key.nil? and not @req.verify(@key.public_key) then - raise R509Error, 'Key does not match request.' - end - + if opts.has_key?(:subject) + san_names = R509::ASN1.general_name_parser(opts[:san_names] || []) + create_request(opts[:subject], san_names) #sets @req + elsif opts.has_key?(:csr) + if opts.has_key?(:san_names) + raise ArgumentError, "You can't add domains to an existing CSR" end + parse_csr(opts[:csr]) + else + raise ArgumentError, "You must provide :subject or :csr" + end - # Helper method to quickly load a CSR from the filesystem - # - # @param [String] filename Path to file you want to load - # @return [R509::Csr] Csr object - def self.load_from_file( filename ) - return R509::Csr.new(:csr => IOHelpers.read_data(filename) ) - end + if dsa? + #only DSS1 is acceptable for DSA signing in OpenSSL < 1.0 + #post-1.0 you can sign with anything, but let's be conservative + #see: http://www.ruby-doc.org/stdlib-1.9.3/libdoc/openssl/rdoc/OpenSSL/PKey/DSA.html + @message_digest = R509::MessageDigest.new('dss1') + elsif opts.has_key?(:message_digest) + @message_digest = R509::MessageDigest.new(opts[:message_digest]) + else + @message_digest = R509::MessageDigest.new('sha1') + end - # @return [OpenSSL::PKey::RSA] public key - def public_key - if(@req.kind_of?(OpenSSL::X509::Request)) then - @req.public_key - end - end + if not opts.has_key?(:csr) + @req.sign(@key.key, @message_digest.digest) + end + if not @key.nil? and not @req.verify(@key.public_key) then + raise R509Error, 'Key does not match request.' + end - # Verifies the integrity of the signature on the request - # @return [Boolean] - def verify_signature - @req.verify(public_key) - end + end - # @return [Boolean] Boolean of whether the object contains a private key - def has_private_key? - if not @key.nil? - true - else - false - end - end + # Helper method to quickly load a CSR from the filesystem + # + # @param [String] filename Path to file you want to load + # @return [R509::CSR] CSR object + def self.load_from_file( filename ) + return R509::CSR.new(:csr => IOHelpers.read_data(filename) ) + end - # Converts the CSR into the PEM format - # - # @return [String] the CSR converted into PEM format. - def to_pem - @req.to_pem - end + # @return [OpenSSL::PKey::RSA,OpenSSL::PKey::DSA,OpenSSL::PKey::EC] public key + def public_key + if(@req.kind_of?(OpenSSL::X509::Request)) then + @req.public_key + end + end - alias :to_s :to_pem + # Verifies the integrity of the signature on the request + # @return [Boolean] + def verify_signature + @req.verify(public_key) + end - # Converts the CSR into the DER format - # - # @return [String] the CSR converted into DER format. - def to_der - @req.to_der - end + # @return [Boolean] Boolean of whether the object contains a private key + def has_private_key? + if not @key.nil? + true + else + false + end + end - # Writes the CSR into the PEM format - # - # @param [String, #write] filename_or_io Either a string of the path for - # the file that you'd like to write, or an IO-like object. - def write_pem(filename_or_io) - write_data(filename_or_io, @req.to_pem) - end + # Converts the CSR into the PEM format + # + # @return [String] the CSR converted into PEM format. + def to_pem + @req.to_pem + end - # Writes the CSR into the DER format - # - # @param [String, #write] filename_or_io Either a string of the path for - # the file that you'd like to write, or an IO-like object. - def write_der(filename_or_io) - write_data(filename_or_io, @req.to_der) - end + alias :to_s :to_pem - # Returns whether the public key is RSA - # - # @return [Boolean] true if the public key is RSA, false otherwise - def rsa? - @req.public_key.kind_of?(OpenSSL::PKey::RSA) - end + # Converts the CSR into the DER format + # + # @return [String] the CSR converted into DER format. + def to_der + @req.to_der + end - # Returns whether the public key is DSA - # - # @return [Boolean] true if the public key is DSA, false otherwise - def dsa? - @req.public_key.kind_of?(OpenSSL::PKey::DSA) - end + # Writes the CSR into the PEM format + # + # @param [String, #write] filename_or_io Either a string of the path for + # the file that you'd like to write, or an IO-like object. + def write_pem(filename_or_io) + write_data(filename_or_io, @req.to_pem) + end - # Returns the bit strength of the key used to create the CSR - # @return [Integer] the integer bit strength. - def bit_strength - if self.rsa? - return @req.public_key.n.num_bits - elsif self.dsa? - return @req.public_key.p.num_bits - end - end + # Writes the CSR into the DER format + # + # @param [String, #write] filename_or_io Either a string of the path for + # the file that you'd like to write, or an IO-like object. + def write_der(filename_or_io) + write_data(filename_or_io, @req.to_der) + end - # Returns subject component - # - # @return [String] value of the subject component requested - def subject_component short_name - @req.subject.to_a.each do |element| - if element[0].downcase == short_name.downcase then - return element[1] - end - end - nil - end + # Returns whether the public key is RSA + # + # @return [Boolean] true if the public key is RSA, false otherwise + def rsa? + @req.public_key.kind_of?(OpenSSL::PKey::RSA) + end - # Returns signature algorithm - # - # @return [String] value of the signature algorithm. E.g. sha1WithRSAEncryption, sha256WithRSAEncryption, md5WithRSAEncryption - def signature_algorithm - @req.signature_algorithm - end + # Returns whether the public key is DSA + # + # @return [Boolean] true if the public key is DSA, false otherwise + def dsa? + @req.public_key.kind_of?(OpenSSL::PKey::DSA) + end - # Returns key algorithm (RSA/DSA) - # - # @return [String] value of the key algorithm. RSA or DSA - def key_algorithm - if @req.public_key.kind_of? OpenSSL::PKey::RSA then - 'RSA' - elsif @req.public_key.kind_of? OpenSSL::PKey::DSA then - 'DSA' - end - end + # Returns whether the public key is EC + # + # @return [Boolean] true if the public key is EC, false otherwise + def ec? + @req.public_key.kind_of?(OpenSSL::PKey::EC) + end - # Returns a hash structure you can pass to the Ca. - # You will want to call this method if you intend to alter the values - # and then pass them to the Ca class. - # - # @return [Hash] :subject and :san_names you can pass to Ca - def to_hash - { :subject => @subject.dup , :san_names => @san_names.dup } - end + # Returns the bit strength of the key used to create the CSR + # @return [Integer] the integer bit strength. + def bit_strength + if self.rsa? + return @req.public_key.n.num_bits + elsif self.dsa? + return @req.public_key.p.num_bits + elsif self.ec? + raise R509::R509Error, 'Bit strength is not available for EC at this time.' + end + end - private + # Returns the short name of the elliptic curve used to generate the public key + # if the key is EC. If not, raises an error. + # + # @return [String] elliptic curve name + def curve_name + if self.ec? + self.public_key.group.curve_name + else + raise R509::R509Error, 'Curve name is only available with EC CSRs' + end + end - def parse_csr(csr) - begin - @req = OpenSSL::X509::Request.new csr - rescue OpenSSL::X509::RequestError - #let's try to load this thing by handling a few - #common error cases - if csr.kind_of?(String) - #normalize line endings (really just for the next replace) - csr.gsub!(/\r\n?/, "\n") - #remove extraneous newlines - csr.gsub!(/^\s*\n/,'') - #and leading/trailing whitespace - csr.gsub!(/^\s*|\s*$/,'') - if not csr.match(/-----BEGIN.+-----/) and csr.match(/MII/) - #if csr is probably PEM (MII is the beginning of every base64 - #encoded DER) then add the wrapping lines if they aren't provided. - #tools like Microsoft's xenroll do this. - csr = "-----BEGIN CERTIFICATE REQUEST-----\n"+csr+"\n-----END CERTIFICATE REQUEST-----" - end - end - #and now we try again... - @req = OpenSSL::X509::Request.new csr - end - @subject = R509::Subject.new(@req.subject) - @attributes = parse_attributes_from_csr(@req) - @san_names = @attributes['subjectAltName'] || [] + # Returns subject component + # + # @return [String] value of the subject component requested + def subject_component short_name + @req.subject.to_a.each do |element| + if element[0].downcase == short_name.downcase then + return element[1] end + end + nil + end - def create_request(subject,domains=[]) - domains.uniq! #de-duplicate the array - @req = OpenSSL::X509::Request.new - @req.version = 0 - @subject = R509::Subject.new(subject) - @req.subject = @subject.name - if @key.nil? - @key = R509::PrivateKey.new(:type => @type, - :bit_strength => @bit_strength) - end - @req.public_key = @key.public_key - add_san_extension(domains) - @attributes = parse_attributes_from_csr(@req) - @san_names = @attributes['subjectAltName'] || [] - end + # Returns signature algorithm + # + # @return [String] value of the signature algorithm. E.g. sha1WithRSAEncryption, sha256WithRSAEncryption, md5WithRSAEncryption + def signature_algorithm + @req.signature_algorithm + end - # parses an existing cert to get data to add to new CSR - def parse_cert(cert) - domains_to_add = [] - san_extension = nil - parsed_cert = OpenSSL::X509::Certificate.new(cert) - parsed_cert.extensions.each { |extension| - if (extension.oid == 'subjectAltName') then - domains_to_add = parse_san_extension(extension) - end - } - {:subject => parsed_cert.subject, :subjectAltName => domains_to_add} - end + # Returns key algorithm (RSA/DSA/EC) + # + # @return [Symbol] value of the key algorithm. :rsa, :dsa, :ec + def key_algorithm + if @req.public_key.kind_of? OpenSSL::PKey::RSA then + :rsa + elsif @req.public_key.kind_of? OpenSSL::PKey::DSA then + :dsa + elsif @req.public_key.kind_of? OpenSSL::PKey::EC then + :ec + end + end - # @return [Hash] attributes of a CSR - def parse_attributes_from_csr(req) - attributes = Hash.new - domains_from_csr = [] - set = nil - req.attributes.each { |attribute| - if attribute.oid == 'extReq' then - set = OpenSSL::ASN1.decode attribute.value - end - } - if !set.nil? then - set.value.each { |set_value| - @seq = set_value - extensions = @seq.value.collect{|asn1ext| OpenSSL::X509::Extension.new(asn1ext) } - extensions.each { |ext| - attributes[ext.oid] = {'value' => ext.value, 'critical'=> ext.critical? } - if ext.oid == 'subjectAltName' then - domains_from_csr = ext.value.gsub(/DNS:/,'').split(',') - domains_from_csr = domains_from_csr.collect {|x| x.strip } - attributes[ext.oid] = domains_from_csr - end - } - } - end - attributes - end + private - #takes OpenSSL::X509::Extension object - def parse_san_extension(extension) - san_string = extension.value - stripped = [] - san_string.split(',').each{ |name| - stripped.push name.strip - } - stripped + def parse_csr(csr) + begin + @req = OpenSSL::X509::Request.new csr + rescue OpenSSL::X509::RequestError + #let's try to load this thing by handling a few + #common error cases + if csr.kind_of?(String) + #normalize line endings (really just for the next replace) + csr.gsub!(/\r\n?/, "\n") + #remove extraneous newlines + csr.gsub!(/^\s*\n/,'') + #and leading/trailing whitespace + csr.gsub!(/^\s*|\s*$/,'') + if not csr.match(/-----BEGIN.+-----/) and csr.match(/MII/) + #if csr is probably PEM (MII is the beginning of every base64 + #encoded DER) then add the wrapping lines if they aren't provided. + #tools like Microsoft's xenroll do this. + csr = "-----BEGIN CERTIFICATE REQUEST-----\n"+csr+"\n-----END CERTIFICATE REQUEST-----" + end end + #and now we try again... + @req = OpenSSL::X509::Request.new csr + end + @subject = R509::Subject.new(@req.subject) + parse_san_attribute_from_csr(@req) + end - def add_san_extension(domains_to_add) - if(domains_to_add.size > 0) then - ef = OpenSSL::X509::ExtensionFactory.new - ex = [] - ex << ef.create_extension("subjectAltName", domains_to_add.join(', ')) - request_extension_set = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence(ex)]) - @req.add_attribute(OpenSSL::X509::Attribute.new("extReq", request_extension_set)) - @san_names = strip_prefix(domains_to_add) - end - end + def create_request(subject,san_names) + @req = OpenSSL::X509::Request.new + @req.version = 0 + @subject = R509::Subject.new(subject) + @req.subject = @subject.name + if @key.nil? + @key = R509::PrivateKey.new(:type => @type, :bit_strength => @bit_strength, :curve_name => @curve_name) + end + @req.public_key = @key.public_key + add_san_extension(san_names) + parse_san_attribute_from_csr(@req) + end - def prefix_domains(domains) - domains.map { |domain| 'DNS: '+domain } + # @return [Array] array of GeneralName objects + def parse_san_attribute_from_csr(req) + san = nil + set = nil + req.attributes.each do |attribute| + if attribute.oid == 'extReq' + set = OpenSSL::ASN1.decode attribute.value + extensions = set.value[0].value.collect{|asn1ext| OpenSSL::X509::Extension.new(asn1ext) } + r509_extensions = R509::Cert::Extensions.wrap_openssl_extensions( extensions ) + if not r509_extensions[R509::Cert::Extensions::SubjectAlternativeName].nil? + san = r509_extensions[R509::Cert::Extensions::SubjectAlternativeName].general_names + end + break end + end + @san = san + end - def strip_prefix(domains) - domains.map{ |name| name.gsub(/DNS:/,'').strip } + def add_san_extension(san_names) + if not san_names.nil? and not san_names.names.empty? + names = san_names.names.uniq + general_names = R509::ASN1::GeneralNames.new + names.each do |domain| + general_names.add_item(domain) end + ef = OpenSSL::X509::ExtensionFactory.new + serialized = general_names.serialize_names + ef.config = OpenSSL::Config.parse(serialized[:conf]) + ex = [] + ex << ef.create_extension("subjectAltName", serialized[:extension_string]) + request_extension_set = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence(ex)]) + @req.add_attribute(OpenSSL::X509::Attribute.new("extReq", request_extension_set)) + parse_san_attribute_from_csr(@req) + end end + + + end end