Commit b4194d5

mo <mo.khan@gmail.com>
2017-12-26 22:56:48
move fingerprint, certificate, xml to xml-kit.
1 parent ce35886
lib/xml/kit/certificate.rb
@@ -0,0 +1,96 @@
+module Xml
+  module Kit
+    # {include:file:spec/xml/certificate_spec.rb}
+    class Certificate
+      BEGIN_CERT=/-----BEGIN CERTIFICATE-----/
+      END_CERT=/-----END CERTIFICATE-----/
+      # The use can be `:signing` or `:encryption`
+      attr_reader :use
+
+      def initialize(value, use:)
+        @value = value
+        @use = use.downcase.to_sym
+      end
+
+      # @return [Xml::Kit::Fingerprint] the certificate fingerprint.
+      def fingerprint
+        Fingerprint.new(value)
+      end
+
+      # Returns true if this certificate is for the specified use.
+      #
+      # @param use [Symbol] `:signing` or `:encryption`.
+      # @return [Boolean] true or false.
+      def for?(use)
+        self.use == use.to_sym
+      end
+
+      # Returns true if this certificate is used for encryption.
+      #
+      # return [Boolean] true or false.
+      def encryption?
+        for?(:encryption)
+      end
+
+      # Returns true if this certificate is used for signing.
+      #
+      # return [Boolean] true or false.
+      def signing?
+        for?(:signing)
+      end
+
+      # Returns the x509 form.
+      #
+      # return [OpenSSL::X509::Certificate] the OpenSSL equivalent.
+      def x509
+        self.class.to_x509(value)
+      end
+
+      # Returns the public key.
+      #
+      # @return [OpenSSL::PKey::RSA] the RSA public key.
+      def public_key
+        x509.public_key
+      end
+
+      def ==(other)
+        self.fingerprint == other.fingerprint
+      end
+
+      def eql?(other)
+        self == other
+      end
+
+      def hash
+        value.hash
+      end
+
+      def to_s
+        value
+      end
+
+      def to_h
+        { use: @use, fingerprint: fingerprint.to_s }
+      end
+
+      def inspect
+        to_h.inspect
+      end
+
+      def stripped
+        value.to_s.gsub(BEGIN_CERT, '').gsub(END_CERT, '').gsub(/\n/, '')
+      end
+
+      def self.to_x509(value)
+        OpenSSL::X509::Certificate.new(Base64.decode64(value))
+      rescue OpenSSL::X509::CertificateError => error
+        ::Xml::Kit.logger.warn(error)
+        OpenSSL::X509::Certificate.new(value)
+      end
+
+      private
+
+      attr_reader :value
+    end
+  end
+end
lib/xml/kit/fingerprint.rb
@@ -0,0 +1,50 @@
+module Xml
+  module Kit
+    # This generates a fingerprint for an X509 Certificate.
+    #
+    #   certificate, _ = Saml::Kit::SelfSignedCertificate.new("password").create
+    #
+    #   puts Saml::Kit::Fingerprint.new(certificate).to_s
+    #   # B7:AB:DC:BD:4D:23:58:65:FD:1A:99:0C:5F:89:EA:87:AD:F1:D7:83:34:7A:E9:E4:88:12:DD:46:1F:38:05:93
+    #
+    # {include:file:spec/saml/fingerprint_spec.rb}
+    class Fingerprint
+      # The OpenSSL::X509::Certificate
+      attr_reader :x509
+
+      def initialize(raw_certificate)
+        @x509 = Certificate.to_x509(raw_certificate)
+      end
+
+      # Generates a formatted fingerprint using the specified hash algorithm.
+      #
+      # @param algorithm [OpenSSL::Digest] the openssl algorithm to use `OpenSSL::Digest::SHA256`, `OpenSSL::Digest::SHA1`.
+      # @return [String] in the format of `"BF:ED:C5:F1:6C:AB:F5:B2:15:1F:BF:BD:7D:68:1A:F9:A5:4E:4C:19:30:BC:6D:25:B1:8E:98:D4:23:FD:B4:09"`
+      def algorithm(algorithm)
+        pretty_fingerprint(algorithm.new.hexdigest(x509.to_der))
+      end
+
+      def ==(other)
+        self.to_s == other.to_s
+      end
+
+      def eql?(other)
+        self == other
+      end
+
+      def hash
+        to_s.hash
+      end
+
+      def to_s
+        algorithm(OpenSSL::Digest::SHA256)
+      end
+
+      private
+
+      def pretty_fingerprint(fingerprint)
+        fingerprint.upcase.scan(/../).join(":")
+      end
+    end
+  end
+end
lib/xml/kit/xml.rb
@@ -0,0 +1,81 @@
+module Xml
+  module Kit
+    # {include:file:spec/saml/xml_spec.rb}
+    class Xml # :nodoc:
+      include ActiveModel::Validations
+      NAMESPACES = {
+        #"NameFormat": Namespaces::ATTR_SPLAT,
+        "ds": ::Xml::Kit::Namespaces::XMLDSIG,
+        #"md": Namespaces::METADATA,
+        #"saml": Namespaces::ASSERTION,
+        #"samlp": Namespaces::PROTOCOL,
+      }.freeze
+
+      validate :validate_signatures
+      validate :validate_certificates
+
+      def initialize(raw_xml, namespaces: NAMESPACES)
+        @raw_xml = raw_xml
+        @namespaces = namespaces
+        @document = Nokogiri::XML(raw_xml)
+      end
+
+      # Returns the first XML node found by searching the document with the provided XPath.
+      #
+      # @param xpath [String] the XPath to use to search the document
+      def find_by(xpath)
+        document.at_xpath(xpath, namespaces)
+      end
+
+      # Returns all XML nodes found by searching the document with the provided XPath.
+      #
+      # @param xpath [String] the XPath to use to search the document
+      def find_all(xpath)
+        document.search(xpath, namespaces)
+      end
+
+      # Return the XML document as a [String].
+      #
+      # @param pretty [Boolean] return the XML string in a human readable format if true.
+      def to_xml(pretty: true)
+        pretty ? document.to_xml(indent: 2) : raw_xml
+      end
+
+      private
+
+      attr_reader :raw_xml, :document, :namespaces
+
+      def validate_signatures
+        invalid_signatures.flat_map(&:errors).uniq.each do |error|
+          errors.add(error, "is invalid")
+        end
+      end
+
+      def invalid_signatures
+        signed_document = Xmldsig::SignedDocument.new(document, id_attr: 'ID=$uri or @Id')
+        signed_document.signatures.find_all do |signature|
+          x509_certificates.all? do |certificate|
+            !signature.valid?(certificate)
+          end
+        end
+      end
+
+      def validate_certificates(now = Time.current)
+        return if find_by('//ds:Signature').nil?
+
+        x509_certificates.each do |certificate|
+          inactive = now < certificate.not_before
+          errors.add(:certificate, "Not valid before #{certificate.not_before}") if inactive
+
+          expired = now > certificate.not_after
+          errors.add(:certificate, "Not valid after #{certificate.not_after}") if expired
+        end
+      end
+
+      def x509_certificates
+        xpath = "//ds:KeyInfo/ds:X509Data/ds:X509Certificate"
+        find_all(xpath).map { |item| Certificate.to_x509(item.text) }
+      end
+    end
+  end
+end
lib/xml/kit/xml_decryption.rb
@@ -30,7 +30,7 @@ module Xml
             attempts -= 1
             return to_plaintext(cipher_text, private_key, encrypted_key["EncryptionMethod"]['Algorithm'])
           rescue OpenSSL::PKey::RSAError => error
-            Xml::Kit.logger.error(error)
+            ::Xml::Kit.logger.error(error)
             raise if attempts.zero?
           end
         end
lib/xml/kit.rb
@@ -1,11 +1,15 @@
+require "active_model"
 require "base64"
 require "logger"
 require "openssl"
 
+require "xml/kit/certificate"
 require "xml/kit/crypto"
+require "xml/kit/fingerprint"
 require "xml/kit/id"
 require "xml/kit/namespaces"
 require "xml/kit/version"
+require "xml/kit/xml"
 require "xml/kit/xml_decryption"
 
 module Xml
spec/xml/certificate_spec.rb
@@ -0,0 +1,13 @@
+RSpec.describe Xml::Kit::Certificate do
+  subject { described_class.new(certificate, use: :signing) }
+  let(:certificate) do
+    cert, _ = generate_key_pair('password')
+    cert
+  end
+
+  describe "#fingerprint" do
+    it 'returns a fingerprint' do
+      expect(subject.fingerprint).to be_instance_of(Xml::Kit::Fingerprint)
+    end
+  end
+end
spec/xml/fingerprint_spec.rb
@@ -0,0 +1,27 @@
+RSpec.describe Xml::Kit::Fingerprint do
+  describe "#sha" do
+    it 'returns the SHA256' do
+      certificate, _ = generate_key_pair("password")
+      x509 = OpenSSL::X509::Certificate.new(certificate)
+      sha256 = OpenSSL::Digest::SHA256.new.hexdigest(x509.to_der).upcase.scan(/../).join(":")
+
+      expect(described_class.new(certificate).algorithm(OpenSSL::Digest::SHA256)).to eql(sha256)
+    end
+
+    it 'returns the SHA1' do
+      certificate, _ = generate_key_pair("password")
+      x509 = OpenSSL::X509::Certificate.new(certificate)
+      sha1 = OpenSSL::Digest::SHA1.new.hexdigest(x509.to_der).upcase.scan(/../).join(":")
+
+      expect(described_class.new(certificate).algorithm(OpenSSL::Digest::SHA1)).to eql(sha1)
+    end
+  end
+
+  it 'produces correct hash keys' do
+    certificate, _ = generate_key_pair("password")
+    items = { }
+    items[described_class.new(certificate)] = "HI"
+    items[described_class.new(certificate)] = "BYE"
+    expect(items.keys.count).to eql(1)
+  end
+end
spec/xml/xml_spec.rb
@@ -0,0 +1,58 @@
+RSpec.describe Xml::Kit::Xml do
+  describe "#valid_signature?" do
+    let(:login_url) { "https://#{FFaker::Internet.domain_name}/login" }
+    let(:logout_url) { "https://#{FFaker::Internet.domain_name}/logout" }
+    let(:configuration) do
+      configuration = Saml::Kit::Configuration.new
+      configuration.generate_key_pair_for(use: :signing)
+      configuration
+    end
+
+    let(:signed_xml) do
+      Saml::Kit::ServiceProviderMetadata.build(configuration: configuration) do |builder|
+        builder.entity_id = FFaker::Movie.title
+        builder.add_assertion_consumer_service(login_url, binding: :http_post)
+        builder.add_assertion_consumer_service(login_url, binding: :http_redirect)
+        builder.add_single_logout_service(logout_url, binding: :http_post)
+        builder.add_single_logout_service(logout_url, binding: :http_redirect)
+      end.to_xml
+    end
+
+    it 'returns true, when the digest and signature is valid' do
+      subject = described_class.new(signed_xml)
+      expect(subject).to be_valid
+    end
+
+    it 'returns false, when the SHA1 digest is not valid' do
+      subject = described_class.new(signed_xml.gsub("EntityDescriptor", "uhoh"))
+      expect(subject).to_not be_valid
+      expect(subject.errors[:digest_value]).to be_present
+    end
+
+    it 'it is invalid when digest is incorrect' do
+      old_digest = Hash.from_xml(signed_xml)['EntityDescriptor']['Signature']['SignedInfo']['Reference']['DigestValue']
+      subject = described_class.new(signed_xml.gsub(old_digest, 'sabotage'))
+      expect(subject).to_not be_valid
+      expect(subject.errors[:digest_value]).to be_present
+    end
+
+    it 'returns false, when the signature is invalid' do
+      old_signature = Hash.from_xml(signed_xml)['EntityDescriptor']['Signature']['SignatureValue']
+      signed_xml.gsub!(old_signature, 'sabotage')
+      subject = described_class.new(signed_xml)
+      expect(subject).to_not be_valid
+      expect(subject.errors[:signature]).to be_present
+    end
+
+    it 'is valid' do
+      configuration = Saml::Kit::Configuration.new do |config|
+        5.times { config.generate_key_pair_for(use: :signing) }
+      end
+      signed_xml = Saml::Kit::Metadata.build_xml(configuration: configuration) do |builder|
+        builder.build_identity_provider
+        builder.build_service_provider
+      end
+      expect(described_class.new(signed_xml)).to be_valid
+    end
+  end
+end
xml-kit.gemspec
@@ -21,6 +21,7 @@ Gem::Specification.new do |spec|
   spec.executables   = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
 
+  spec.add_dependency "activemodel", ">= 4.2.0"
   spec.add_development_dependency "bundler", "~> 1.16"
   spec.add_development_dependency "ffaker", "~> 2.7"
   spec.add_development_dependency "rake", "~> 10.0"