Commit b4194d5
Changed files (9)
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"