diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e285db585..87c1fcde6 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -204,12 +204,12 @@ Metrics/CyclomaticComplexity: # Offense count: 58 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 63 + Max: 80 # Offense count: 1 # Configuration parameters: CountComments, CountAsOne. Metrics/ModuleLength: - Max: 244 + Max: 300 # Offense count: 2 # Configuration parameters: Max, CountKeywordArgs. diff --git a/README.md b/README.md index 4d5eb8baa..779980452 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ In the above there are a few assumptions, one being that `response.nameid` is an This is all handled with how you specify the settings that are in play via the `saml_settings` method. That could be implemented along the lines of this: -``` +```ruby response = RubySaml::Response.new(params[:SAMLResponse]) response.settings = saml_settings ``` @@ -759,6 +759,11 @@ Note the following: inactive/expired certificates. This avoids validation errors when the IdP reads the SP metadata. +#### Key Algorithm Support + +Ruby SAML supports RSA, DSA, and ECDSA keys for both SP and IdP certificates. +JRuby cannot support ECDSA due to a [known issue](https://github.com/jruby/jruby-openssl/issues/257). + #### Audience Validation A service provider should only consider a SAML response valid if the IdP includes an diff --git a/UPGRADING.md b/UPGRADING.md index ca0b2854b..e4406e441 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,7 +8,15 @@ Before attempting to upgrade to `2.0.0`: - Upgrade your project to minimum Ruby 3.0, JRuby 9.4, or TruffleRuby 22. -- Upgrade RubySaml to `1.17.x`. Note that RubySaml `1.17.x` is compatible with up to Ruby 3.3. +- Upgrade RubySaml to `1.17.x`. +- In RubySaml `1.17.x`, if you were using the SHA-1 default behavior, change your settings to use SHA-256 as per below: + +```ruby +# Set this in RubySaml 1.17.x, can be removed when upgrading to 2.0.0 +settings.idp_cert_fingerprint_algorithm = XMLSecurity::Document::SHA256 +settings.security[:signature_method] = XMLSecurity::Document::RSA_SHA256 +settings.security[:digest_method] = XMLSecurity::Document::SHA256 +``` ### Root "OneLogin" namespace changed to "RubySaml" @@ -38,16 +46,17 @@ For security reasons, RubySaml version `2.0.0` uses SHA-256 as its default hashi instead of the now-obsolete SHA-1. This affects: - The default signature and digest algorithms used when generating SP metadata. - The default signature algorithm used when generating SP messages such as AuthnRequests. -- The default fingerprint of IdP metadata (`:idp_cert_fingerprint` as generated by `RubySaml::IdpMetadataParser`) +- The `:idp_cert_fingerprint` of IdP metadata as generated by `RubySaml::IdpMetadataParser`. -To preserve the old insecure SHA-1 behavior *(not recommended)*, you may set `RubySaml::Settings` as follows: +If you see any signature or fingerprint mismatch errors after upgrading to RubySaml `2.0.0`, +this change is likely the reason. To preserve the old insecure SHA-1 behavior *(not recommended)*, +you may set `RubySaml::Settings` as follows: ```ruby # Preserve RubySaml 1.x insecure SHA-1 behavior -settings = RubySaml::Settings.new -settings.idp_cert_fingerprint_algorithm = RubySaml::XML::Document::SHA1 -settings.security[:digest_method] = RubySaml::XML::Document::SHA1 -settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 +settings.idp_cert_fingerprint_algorithm = RubySaml::XML::Crypto::SHA1 +settings.security[:digest_method] = RubySaml::XML::Crypto::SHA1 +settings.security[:signature_method] = RubySaml::XML::Crypto::RSA_SHA1 ``` ### Removal of embed_sign setting @@ -94,13 +103,13 @@ The following parameters in `RubySaml::Settings` are deprecated and will be remo ### Minor changes to Util#format_cert and #format_private_key -Version 2.0.0 standardizes how RubySaml reads and formats certificate and private key -PEM strings. In general, version 2.0.0 is more permissive than 1.x, and the changes +Version `2.0.0` standardizes how RubySaml reads and formats certificate and private key +PEM strings. In general, version `2.0.0` is more permissive than `1.x`, and the changes are not anticipated to affect most users. Please note the change affects parameters such `#idp_cert` and `#certificate`, as well as the `RubySaml::Util#format_cert` and `#format_private_key` methods. Specifically: -| # | Input value | RubySaml 2.0.0 | RubySaml 1.x | +| # | Input value | RubySaml 2.0.0 | RubySaml 1.17.x | |---|------------------------------------------------------|---------------------------------------------------------|---------------------------| | 1 | Input contains a bad (e.g. non-base64) PEM | Skip PEM formatting | Return a bad PEM | | 2 | Input contains `\r` character(s) | Strip out all `\r` character(s) and format as PEM | Skip PEM formatting | @@ -113,7 +122,7 @@ and `#format_private_key` methods. Specifically: **Notes** - Case 3: For example, `-----BEGIN TRUSTED X509 CERTIFICATE-----` is now considered a valid header as an input, but it will be formatted to - `-----BEGIN CERTIFICATE-----` in the output. As a special case, in both 2.0.0 + `-----BEGIN CERTIFICATE-----` in the output. As a special case, in both `2.0.0` and 1.x, if `RSA PRIVATE KEY` is present in the input string, the `RSA` prefix will be preserved in the output. - Case 5: When formatting multiple certificates in one string (i.e. a certificate chain), diff --git a/lib/ruby_saml/authrequest.rb b/lib/ruby_saml/authrequest.rb index 95a4433a9..6aeccc107 100644 --- a/lib/ruby_saml/authrequest.rb +++ b/lib/ruby_saml/authrequest.rb @@ -77,14 +77,14 @@ def create_params(settings, params={}) sp_signing_key = settings.get_sp_signing_key if binding_redirect && settings.security[:authn_requests_signed] && sp_signing_key - params['SigAlg'] = settings.security[:signature_method] + params['SigAlg'] = settings.get_sp_signature_method url_string = RubySaml::Utils.build_query( type: 'SAMLRequest', data: base64_request, relay_state: relay_state, sig_alg: params['SigAlg'] ) - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) + sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method) signature = sp_signing_key.sign(sign_algorithm.new, url_string) params['Signature'] = encode(signature) end @@ -185,7 +185,7 @@ def create_xml_document(settings) def sign_document(document, settings) cert, private_key = settings.get_sp_signing_pair if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && private_key && cert - document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) + document.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) end document diff --git a/lib/ruby_saml/idp_metadata_parser.rb b/lib/ruby_saml/idp_metadata_parser.rb index 7bf1cf939..f54314fb6 100644 --- a/lib/ruby_saml/idp_metadata_parser.rb +++ b/lib/ruby_saml/idp_metadata_parser.rb @@ -398,7 +398,7 @@ def fingerprint(certificate, fingerprint_algorithm = RubySaml::XML::Document::SH cert = OpenSSL::X509::Certificate.new(Base64.decode64(certificate)) - fingerprint_alg = RubySaml::XML::BaseDocument.new.algorithm(fingerprint_algorithm).new + fingerprint_alg = RubySaml::XML::Crypto.hash_algorithm(fingerprint_algorithm).new fingerprint_alg.hexdigest(cert.to_der).upcase.scan(/../).join(":") end end diff --git a/lib/ruby_saml/logoutrequest.rb b/lib/ruby_saml/logoutrequest.rb index a82b93939..9ebf325c0 100644 --- a/lib/ruby_saml/logoutrequest.rb +++ b/lib/ruby_saml/logoutrequest.rb @@ -75,14 +75,14 @@ def create_params(settings, params={}) sp_signing_key = settings.get_sp_signing_key if binding_redirect && settings.security[:logout_requests_signed] && sp_signing_key - params['SigAlg'] = settings.security[:signature_method] + params['SigAlg'] = settings.get_sp_signature_method url_string = RubySaml::Utils.build_query( type: 'SAMLRequest', data: base64_request, relay_state: relay_state, sig_alg: params['SigAlg'] ) - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) + sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method) signature = settings.get_sp_signing_key.sign(sign_algorithm.new, url_string) params['Signature'] = encode(signature) end @@ -144,7 +144,7 @@ def sign_document(document, settings) # embed signature cert, private_key = settings.get_sp_signing_pair if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && private_key && cert - document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) + document.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) end document diff --git a/lib/ruby_saml/metadata.rb b/lib/ruby_saml/metadata.rb index b76624d19..a39850d7c 100644 --- a/lib/ruby_saml/metadata.rb +++ b/lib/ruby_saml/metadata.rb @@ -142,7 +142,7 @@ def embed_signature(meta_doc, settings) cert, private_key = settings.get_sp_signing_pair return unless private_key && cert - meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) + meta_doc.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) end def output_xml(meta_doc, pretty_print) diff --git a/lib/ruby_saml/response.rb b/lib/ruby_saml/response.rb index a12007ea7..b2286d681 100644 --- a/lib/ruby_saml/response.rb +++ b/lib/ruby_saml/response.rb @@ -861,8 +861,8 @@ def validate_signature if fingerprint && doc.validate_document(fingerprint, @soft, opts) if settings.security[:check_idp_cert_expiration] && RubySaml::Utils.is_cert_expired(idp_cert) - error_msg = "IdP x509 certificate expired" - return append_error(error_msg) + error_msg = "IdP x509 certificate expired" + return append_error(error_msg) end else return append_error(error_msg) diff --git a/lib/ruby_saml/saml_message.rb b/lib/ruby_saml/saml_message.rb index b9dc997ff..bad8c6efc 100644 --- a/lib/ruby_saml/saml_message.rb +++ b/lib/ruby_saml/saml_message.rb @@ -133,11 +133,7 @@ def decode(string) # @return [String] The encoded string # def encode(string) - if Base64.respond_to?(:strict_encode64) - Base64.strict_encode64(string) - else - Base64.encode64(string).gsub(/\n/, "") - end + Base64.strict_encode64(string) end # Check if a string is base64 encoded diff --git a/lib/ruby_saml/settings.rb b/lib/ruby_saml/settings.rb index 36badd697..1d90e887e 100644 --- a/lib/ruby_saml/settings.rb +++ b/lib/ruby_saml/settings.rb @@ -126,7 +126,7 @@ def get_fingerprint idp_cert_fingerprint || begin idp_cert = get_idp_cert if idp_cert - fingerprint_alg = RubySaml::XML::BaseDocument.new.algorithm(idp_cert_fingerprint_algorithm).new + fingerprint_alg = RubySaml::XML::Crypto.hash_algorithm(idp_cert_fingerprint_algorithm).new fingerprint_alg.hexdigest(idp_cert.to_der).upcase.scan(/../).join(":") end end @@ -159,7 +159,7 @@ def get_idp_cert_multi certs end - # @return [Hash>>] + # @return [Hash>>] # Build the SP certificates and private keys from the settings. If # check_sp_cert_expiration is true, only returns certificates and private keys # that are not expired. @@ -179,7 +179,7 @@ def get_sp_certs active_certs.freeze end - # @return [Array] + # @return [Array] # The SP signing certificate and private key. def get_sp_signing_pair get_sp_certs[:signing].first @@ -267,6 +267,43 @@ def get_binding(value) end end + # @return [String] The XML Signature Algorithm attribute. + # + # This method is intentionally hacky for backwards compatibility of the + # settings.security[:signature_method] parameter. Previously, this parameter + # could have a value such as "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + # which assumes the public key type RSA. To add support for DSA and ECDSA, we will now + # ignore the "rsa-" prefix and only use the "sha256" hash algorithm component. + def get_sp_signature_method + sig_alg = security[:signature_method] || 'sha256' + key_alg_fallback, hash_alg = sig_alg.to_s.match(/(?:\A|(rsa|ecdsa|ec|dsa)?[# _-])(sha\d+)\z/i)&.[](1..2) + key_alg_real = case get_sp_signing_key + when OpenSSL::PKey::RSA then 'RSA' + when OpenSSL::PKey::DSA then 'DSA' + when OpenSSL::PKey::EC then 'ECDSA' + end + key_alg = key_alg_real || key_alg_fallback || 'RSA' + key_alg = 'ECDSA' if key_alg.casecmp('EC') == 0 + + begin + RubySaml::XML::Crypto.const_get("#{key_alg}_#{hash_alg}".upcase) + rescue NameError + raise ArgumentError.new("Unsupported signature method#{" for #{key_alg_real} key" if key_alg_real}: #{sig_alg}") + end + end + + # @return [String] The XML Signature Digest attribute. + def get_sp_digest_method + digest_alg = security[:digest_method] || 'sha1' # TODO: change to sha256 by default + alg = digest_alg.to_s.match(/(?:\A|#)(sha\d+)\z/i)[1] + + begin + RubySaml::XML::Crypto.const_get(alg.upcase) + rescue NameError + raise ArgumentError.new("Unsupported digest method: #{digest_alg}") + end + end + # @deprecated Will be removed in v2.1.0 def certificate_new certificate_new_deprecation diff --git a/lib/ruby_saml/slo_logoutresponse.rb b/lib/ruby_saml/slo_logoutresponse.rb index b17d81f72..3cd5a2158 100644 --- a/lib/ruby_saml/slo_logoutresponse.rb +++ b/lib/ruby_saml/slo_logoutresponse.rb @@ -84,14 +84,14 @@ def create_params(settings, request_id = nil, logout_message = nil, params = {}, sp_signing_key = settings.get_sp_signing_key if binding_redirect && settings.security[:logout_responses_signed] && sp_signing_key - params['SigAlg'] = settings.security[:signature_method] + params['SigAlg'] = settings.get_sp_signature_method url_string = RubySaml::Utils.build_query( type: 'SAMLResponse', data: base64_response, relay_state: relay_state, sig_alg: params['SigAlg'] ) - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) + sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method) signature = sp_signing_key.sign(sign_algorithm.new, url_string) params['Signature'] = encode(signature) end @@ -155,7 +155,7 @@ def sign_document(document, settings) # embed signature cert, private_key = settings.get_sp_signing_pair if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && private_key && cert - document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) + document.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) end document diff --git a/lib/ruby_saml/utils.rb b/lib/ruby_saml/utils.rb index e055925e7..7b2f81adf 100644 --- a/lib/ruby_saml/utils.rb +++ b/lib/ruby_saml/utils.rb @@ -124,14 +124,21 @@ def build_cert_object(pem) OpenSSL::X509::Certificate.new(pem) end - # Given a private key string, return an OpenSSL::PKey::RSA object. + # Given a private key string, return an OpenSSL::PKey::PKey object. # # @param pem [String] The original private key. - # @return [OpenSSL::PKey::RSA] The private key object. + # @return [OpenSSL::PKey::PKey] The private key object. def build_private_key_object(pem) return unless (pem = PemFormatter.format_private_key(pem, multi: false)) - OpenSSL::PKey::RSA.new(pem) + error = nil + private_key_classes(pem).each do |key_class| + return key_class.new(pem) + rescue OpenSSL::PKey::PKeyError => e + error ||= e + end + + raise error end # Build the Query String signature that will be used in the HTTP-Redirect binding @@ -212,8 +219,8 @@ def escape_request_param(param, lowercase_url_encoding) # @return [Boolean] True if the Signature is valid, False otherwise # def verify_signature(params) - cert, sig_alg, signature, query_string = %i[cert sig_alg signature query_string].map { |k| params[k]} - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(sig_alg) + cert, sig_alg, signature, query_string = params.values_at(:cert, :sig_alg, :signature, :query_string) + signature_algorithm = RubySaml::XML::Crypto.hash_algorithm(sig_alg) cert.public_key.verify(signature_algorithm.new, Base64.decode64(signature), query_string) end @@ -243,7 +250,7 @@ def status_error_msg(error_msg, raw_status_code = nil, status_message = nil) # Obtains the decrypted string from an Encrypted node element in XML, # given multiple private keys to try. # @param encrypted_node [REXML::Element] The Encrypted element - # @param private_keys [Array] The Service provider private key + # @param private_keys [Array] The Service provider private key # @return [String] The decrypted data def decrypt_multi(encrypted_node, private_keys) raise ArgumentError.new('private_keys must be specified') if !private_keys || private_keys.empty? @@ -260,7 +267,7 @@ def decrypt_multi(encrypted_node, private_keys) # Obtains the decrypted string from an Encrypted node element in XML # @param encrypted_node [REXML::Element] The Encrypted element - # @param private_key [OpenSSL::PKey::RSA] The Service provider private key + # @param private_key [OpenSSL::PKey::PKey] The Service provider private key # @return [String] The decrypted data def decrypt_data(encrypted_node, private_key) encrypt_data = REXML::XPath.first( @@ -286,7 +293,7 @@ def decrypt_data(encrypted_node, private_key) # Obtains the symmetric key from the EncryptedData element # @param encrypt_data [REXML::Element] The EncryptedData element - # @param private_key [OpenSSL::PKey::RSA] The Service provider private key + # @param private_key [OpenSSL::PKey::PKey] The Service provider private key # @return [String] The symmetric key def retrieve_symmetric_key(encrypt_data, private_key) encrypted_key = REXML::XPath.first( @@ -410,5 +417,16 @@ def original_uri_match?(destination_url, settings_url) def element_text(element) element.texts.map(&:value).join if element end + + # Given a private key PEM string, return an array of OpenSSL::PKey::PKey classes + # that can be used to parse it, with the most likely match first. + def private_key_classes(pem) + priority = case pem.match(/(RSA|ECDSA|EC|DSA) PRIVATE KEY/)&.[](1) + when 'RSA' then OpenSSL::PKey::RSA + when 'DSA' then OpenSSL::PKey::DSA + when 'ECDSA', 'EC' then OpenSSL::PKey::EC + end + Array(priority) | [OpenSSL::PKey::RSA, OpenSSL::PKey::DSA, OpenSSL::PKey::EC] + end end end diff --git a/lib/ruby_saml/xml.rb b/lib/ruby_saml/xml.rb index b6ab1b628..2a85a665b 100644 --- a/lib/ruby_saml/xml.rb +++ b/lib/ruby_saml/xml.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'ruby_saml/xml/crypto' require 'ruby_saml/xml/base_document' require 'ruby_saml/xml/document' require 'ruby_saml/xml/signed_document' diff --git a/lib/ruby_saml/xml/base_document.rb b/lib/ruby_saml/xml/base_document.rb index 8cfcce261..bb6a122bf 100644 --- a/lib/ruby_saml/xml/base_document.rb +++ b/lib/ruby_saml/xml/base_document.rb @@ -1,55 +1,35 @@ # frozen_string_literal: true require 'rexml/document' +require 'rexml/security' require 'rexml/xpath' require 'nokogiri' require 'openssl' require 'digest/sha1' require 'digest/sha2' +require 'ruby_saml/xml/crypto' module RubySaml module XML class BaseDocument < REXML::Document + # TODO: This affects the global state REXML::Security.entity_expansion_limit = 0 - C14N = 'http://www.w3.org/2001/10/xml-exc-c14n#' - DSIG = 'http://www.w3.org/2000/09/xmldsig#' + # @deprecated Constants moved to Crypto module + C14N = RubySaml::XML::Crypto::C14N + DSIG = RubySaml::XML::Crypto::DSIG + NOKOGIRI_OPTIONS = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET - def canon_algorithm(element) - algorithm = element - if algorithm.is_a?(REXML::Element) - algorithm = element.attribute('Algorithm').value - end - - case algorithm - when 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315', - 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments' - Nokogiri::XML::XML_C14N_1_0 - when 'http://www.w3.org/2006/12/xml-c14n11', - 'http://www.w3.org/2006/12/xml-c14n11#WithComments' - Nokogiri::XML::XML_C14N_1_1 - else - Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 - end + # @deprecated Remove in v2.1.0 + def canon_algorithm(algorithm) + RubySaml::XML::Crypto.canon_algorithm(algorithm) end - def algorithm(element) - algorithm = element - if algorithm.is_a?(REXML::Element) - algorithm = element.attribute('Algorithm').value - end - - algorithm = algorithm && algorithm =~ /(rsa-)?sha(.*?)$/i && ::Regexp.last_match(2).to_i - - case algorithm - when 1 then OpenSSL::Digest::SHA1 - when 384 then OpenSSL::Digest::SHA384 - when 512 then OpenSSL::Digest::SHA512 - else - OpenSSL::Digest::SHA256 - end + # @deprecated Remove in v2.1.0 + def algorithm(algorithm) + RubySaml::XML::Crypto.hash_algorithm(algorithm) end end end diff --git a/lib/ruby_saml/xml/crypto.rb b/lib/ruby_saml/xml/crypto.rb new file mode 100644 index 000000000..2ab613be8 --- /dev/null +++ b/lib/ruby_saml/xml/crypto.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'rexml/element' +require 'openssl' +require 'nokogiri' +require 'digest/sha1' +require 'digest/sha2' + +module RubySaml + module XML + # XML Signature and Canonicalization algorithms + # + # @api private + module Crypto + extend self + + C14N = 'http://www.w3.org/2001/10/xml-exc-c14n#' + DSIG = 'http://www.w3.org/2000/09/xmldsig#' + RSA_SHA1 = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' + RSA_SHA224 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha224' + RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + RSA_SHA384 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384' + RSA_SHA512 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512' + DSA_SHA1 = 'http://www.w3.org/2000/09/xmldsig#dsa-sha1' + DSA_SHA256 = 'http://www.w3.org/2009/xmldsig11#dsa-sha256' + ECDSA_SHA1 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1' + ECDSA_SHA224 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224' + ECDSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256' + ECDSA_SHA384 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384' + ECDSA_SHA512 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512' + SHA1 = 'http://www.w3.org/2000/09/xmldsig#sha1' + SHA224 = 'http://www.w3.org/2001/04/xmldsig-more#sha224' + SHA256 = 'http://www.w3.org/2001/04/xmlenc#sha256' + SHA384 = 'http://www.w3.org/2001/04/xmldsig-more#sha384' + SHA512 = 'http://www.w3.org/2001/04/xmlenc#sha512' + ENVELOPED_SIG = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature' + + def canon_algorithm(element, default: true) + case get_algorithm_attr(element) + when %r{\Ahttp://www\.w3\.org/TR/2001/REC-xml-c14n-20010315#?(?:WithComments)?\z}i + Nokogiri::XML::XML_C14N_1_0 + when %r{\Ahttp://www\.w3\.org/2006/12/xml-c14n11#?(?:WithComments)?\z}i + Nokogiri::XML::XML_C14N_1_1 + when %r{\Ahttp://www\.w3\.org/2001/10/xml-exc-c14n#?(?:WithComments)?\z}i + Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 + else + Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 if default + end + end + + def signature_algorithm(element) + alg = get_algorithm_attr(element) + match_data = alg&.downcase&.match(/(?:\A|#)(rsa|dsa|ecdsa)-(sha\d+)\z/i) || {} + key_alg = match_data[1] + hash_alg = match_data[2] + + key = case key_alg + when 'rsa' then OpenSSL::PKey::RSA + when 'dsa' then OpenSSL::PKey::DSA + when 'ecdsa' then OpenSSL::PKey::EC + else # rubocop:disable Lint/DuplicateBranch + # TODO: raise ArgumentError.new("Invalid key algorithm: #{alg}") + OpenSSL::PKey::RSA + end + + [key, hash_algorithm(hash_alg)] + end + + def hash_algorithm(element) + alg = get_algorithm_attr(element) + hash_alg = alg&.downcase&.match(/(?:\A|[#-])(sha\d+)\z/i)&.[](1) + + case hash_alg + when 'sha1' then OpenSSL::Digest::SHA1 + when 'sha224' then OpenSSL::Digest::SHA224 + when 'sha256' then OpenSSL::Digest::SHA256 + when 'sha384' then OpenSSL::Digest::SHA384 + when 'sha512' then OpenSSL::Digest::SHA512 + else # rubocop:disable Lint/DuplicateBranch + # TODO: raise ArgumentError.new("Invalid hash algorithm: #{alg}") + OpenSSL::Digest::SHA256 + end + end + + private + + def get_algorithm_attr(element) + if element.is_a?(REXML::Element) + element.attribute('Algorithm').value + elsif element + element + end + end + end + end +end diff --git a/lib/ruby_saml/xml/document.rb b/lib/ruby_saml/xml/document.rb index a37ab09f3..4a5438010 100644 --- a/lib/ruby_saml/xml/document.rb +++ b/lib/ruby_saml/xml/document.rb @@ -5,17 +5,28 @@ module RubySaml module XML class Document < BaseDocument - RSA_SHA1 = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' - RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' - RSA_SHA384 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384' - RSA_SHA512 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512' - SHA1 = 'http://www.w3.org/2000/09/xmldsig#sha1' - SHA256 = 'http://www.w3.org/2001/04/xmlenc#sha256' - SHA384 = 'http://www.w3.org/2001/04/xmldsig-more#sha384' - SHA512 = 'http://www.w3.org/2001/04/xmlenc#sha512' - ENVELOPED_SIG = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature' INC_PREFIX_LIST = '#default samlp saml ds xs xsi md' + # @deprecated Constants moved to Crypto module + RSA_SHA1 = RubySaml::XML::Crypto::RSA_SHA1 + RSA_SHA224 = RubySaml::XML::Crypto::RSA_SHA224 + RSA_SHA256 = RubySaml::XML::Crypto::RSA_SHA256 + RSA_SHA384 = RubySaml::XML::Crypto::RSA_SHA384 + RSA_SHA512 = RubySaml::XML::Crypto::RSA_SHA512 + DSA_SHA1 = RubySaml::XML::Crypto::DSA_SHA1 + DSA_SHA256 = RubySaml::XML::Crypto::DSA_SHA256 + ECDSA_SHA1 = RubySaml::XML::Crypto::ECDSA_SHA1 + ECDSA_SHA224 = RubySaml::XML::Crypto::ECDSA_SHA224 + ECDSA_SHA256 = RubySaml::XML::Crypto::ECDSA_SHA256 + ECDSA_SHA384 = RubySaml::XML::Crypto::ECDSA_SHA384 + ECDSA_SHA512 = RubySaml::XML::Crypto::ECDSA_SHA512 + SHA1 = RubySaml::XML::Crypto::SHA1 + SHA224 = RubySaml::XML::Crypto::SHA224 + SHA256 = RubySaml::XML::Crypto::SHA256 + SHA384 = RubySaml::XML::Crypto::SHA384 + SHA512 = RubySaml::XML::Crypto::SHA512 + ENVELOPED_SIG = RubySaml::XML::Crypto::ENVELOPED_SIG + attr_writer :uuid def uuid @@ -42,9 +53,9 @@ def sign_document(private_key, certificate, signature_method = RSA_SHA256, diges config.options = RubySaml::XML::BaseDocument::NOKOGIRI_OPTIONS end - signature_element = REXML::Element.new('ds:Signature').add_namespace('ds', DSIG) + signature_element = REXML::Element.new('ds:Signature').add_namespace('ds', RubySaml::XML::Crypto::DSIG) signed_info_element = signature_element.add_element('ds:SignedInfo') - signed_info_element.add_element('ds:CanonicalizationMethod', {'Algorithm' => C14N}) + signed_info_element.add_element('ds:CanonicalizationMethod', {'Algorithm' => RubySaml::XML::Crypto::C14N}) signed_info_element.add_element('ds:SignatureMethod', {'Algorithm'=>signature_method}) # Add Reference @@ -52,30 +63,30 @@ def sign_document(private_key, certificate, signature_method = RSA_SHA256, diges # Add Transforms transforms_element = reference_element.add_element('ds:Transforms') - transforms_element.add_element('ds:Transform', {'Algorithm' => ENVELOPED_SIG}) - c14element = transforms_element.add_element('ds:Transform', {'Algorithm' => C14N}) - c14element.add_element('ec:InclusiveNamespaces', {'xmlns:ec' => C14N, 'PrefixList' => INC_PREFIX_LIST}) + transforms_element.add_element('ds:Transform', {'Algorithm' => RubySaml::XML::Crypto::ENVELOPED_SIG}) + c14element = transforms_element.add_element('ds:Transform', {'Algorithm' => RubySaml::XML::Crypto::C14N}) + c14element.add_element('ec:InclusiveNamespaces', {'xmlns:ec' => RubySaml::XML::Crypto::C14N, 'PrefixList' => INC_PREFIX_LIST}) digest_method_element = reference_element.add_element('ds:DigestMethod', {'Algorithm' => digest_method}) inclusive_namespaces = INC_PREFIX_LIST.split - canon_doc = noko.canonicalize(canon_algorithm(C14N), inclusive_namespaces) - reference_element.add_element('ds:DigestValue').text = compute_digest(canon_doc, algorithm(digest_method_element)) + canon_doc = noko.canonicalize(RubySaml::XML::Crypto.canon_algorithm(RubySaml::XML::Crypto::C14N), inclusive_namespaces) + reference_element.add_element('ds:DigestValue').text = compute_digest(canon_doc, RubySaml::XML::Crypto.hash_algorithm(digest_method_element)) # add SignatureValue noko_sig_element = Nokogiri::XML(signature_element.to_s) do |config| config.options = RubySaml::XML::BaseDocument::NOKOGIRI_OPTIONS end - noko_signed_info_element = noko_sig_element.at_xpath('//ds:Signature/ds:SignedInfo', 'ds' => DSIG) - canon_string = noko_signed_info_element.canonicalize(canon_algorithm(C14N)) + noko_signed_info_element = noko_sig_element.at_xpath('//ds:Signature/ds:SignedInfo', 'ds' => RubySaml::XML::Crypto::DSIG) + canon_string = noko_signed_info_element.canonicalize(RubySaml::XML::Crypto.canon_algorithm(RubySaml::XML::Crypto::C14N)) - signature = compute_signature(private_key, algorithm(signature_method).new, canon_string) + signature = compute_signature(private_key, RubySaml::XML::Crypto.hash_algorithm(signature_method).new, canon_string) signature_element.add_element('ds:SignatureValue').text = signature # add KeyInfo - key_info_element = signature_element.add_element('ds:KeyInfo') - x509_element = key_info_element.add_element('ds:X509Data') - x509_cert_element = x509_element.add_element('ds:X509Certificate') + key_info_element = signature_element.add_element('ds:KeyInfo') + x509_element = key_info_element.add_element('ds:X509Data') + x509_cert_element = x509_element.add_element('ds:X509Certificate') if certificate.is_a?(String) certificate = OpenSSL::X509::Certificate.new(certificate) end @@ -92,10 +103,10 @@ def sign_document(private_key, certificate, signature_method = RSA_SHA256, diges end end - protected + private - def compute_signature(private_key, signature_algorithm, document) - Base64.encode64(private_key.sign(signature_algorithm, document)).gsub(/\n/, '') + def compute_signature(private_key, signature_hash_algorithm, document) + Base64.encode64(private_key.sign(signature_hash_algorithm, document)).gsub(/\n/, '') end def compute_digest(document, digest_algorithm) diff --git a/lib/ruby_saml/xml/signed_document.rb b/lib/ruby_saml/xml/signed_document.rb index 444a062ad..ff6b500f6 100644 --- a/lib/ruby_saml/xml/signed_document.rb +++ b/lib/ruby_saml/xml/signed_document.rb @@ -24,8 +24,8 @@ def validate_document(idp_cert_fingerprint, soft = true, options = {}) # get cert from response cert_element = REXML::XPath.first( self, - "//ds:X509Certificate", - { "ds"=>DSIG } + '//ds:X509Certificate', + { 'ds' => RubySaml::XML::Crypto::DSIG } ) if cert_element @@ -38,7 +38,7 @@ def validate_document(idp_cert_fingerprint, soft = true, options = {}) end if options[:fingerprint_alg] - fingerprint_alg = RubySaml::XML::BaseDocument.new.algorithm(options[:fingerprint_alg]).new + fingerprint_alg = RubySaml::XML::Crypto.hash_algorithm(options[:fingerprint_alg]).new else fingerprint_alg = OpenSSL::Digest.new('SHA256') end @@ -63,7 +63,7 @@ def validate_document_with_cert(idp_cert, soft = true) cert_element = REXML::XPath.first( self, '//ds:X509Certificate', - { 'ds'=>DSIG } + { 'ds' => RubySaml::XML::Crypto::DSIG } ) if cert_element @@ -97,34 +97,34 @@ def validate_signature(base64_cert, soft = true) sig_element = REXML::XPath.first( @working_copy, '//ds:Signature', - {'ds'=>DSIG} + { 'ds' => RubySaml::XML::Crypto::DSIG } ) # signature method sig_alg_value = REXML::XPath.first( sig_element, './ds:SignedInfo/ds:SignatureMethod', - {'ds'=>DSIG} + { 'ds' => RubySaml::XML::Crypto::DSIG } ) - signature_algorithm = algorithm(sig_alg_value) + signature_hash_algorithm = RubySaml::XML::Crypto.hash_algorithm(sig_alg_value) # get signature base64_signature = REXML::XPath.first( sig_element, './ds:SignatureValue', - {'ds' => DSIG} + { 'ds' => RubySaml::XML::Crypto::DSIG} ) signature = Base64.decode64(RubySaml::Utils.element_text(base64_signature)) # canonicalization method - canon_algorithm = canon_algorithm REXML::XPath.first( + canon_algorithm = RubySaml::XML::Crypto.canon_algorithm(REXML::XPath.first( sig_element, './ds:SignedInfo/ds:CanonicalizationMethod', - 'ds' => DSIG - ) + 'ds' => RubySaml::XML::Crypto::DSIG + )) - noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => DSIG) - noko_signed_info_element = noko_sig_element.at_xpath('./ds:SignedInfo', 'ds' => DSIG) + noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => RubySaml::XML::Crypto::DSIG) + noko_signed_info_element = noko_sig_element.at_xpath('./ds:SignedInfo', 'ds' => RubySaml::XML::Crypto::DSIG) canon_string = noko_signed_info_element.canonicalize(canon_algorithm) noko_sig_element.remove @@ -133,30 +133,30 @@ def validate_signature(base64_cert, soft = true) inclusive_namespaces = extract_inclusive_namespaces # check digests - ref = REXML::XPath.first(sig_element, '//ds:Reference', {'ds'=>DSIG}) + ref = REXML::XPath.first(sig_element, '//ds:Reference', { 'ds' => RubySaml::XML::Crypto::DSIG }) hashed_element = document.at_xpath('//*[@ID=$id]', nil, { 'id' => extract_signed_element_id }) - canon_algorithm = canon_algorithm REXML::XPath.first( + canon_algorithm = RubySaml::XML::Crypto.canon_algorithm(REXML::XPath.first( ref, '//ds:CanonicalizationMethod', - { 'ds' => DSIG } - ) + { 'ds' => RubySaml::XML::Crypto::DSIG } + )) canon_algorithm = process_transforms(ref, canon_algorithm) canon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces) - digest_algorithm = algorithm(REXML::XPath.first( + digest_algorithm = RubySaml::XML::Crypto.hash_algorithm(REXML::XPath.first( ref, '//ds:DigestMethod', - { 'ds' => DSIG } + { 'ds' => RubySaml::XML::Crypto::DSIG } )) hash = digest_algorithm.digest(canon_hashed_element) encoded_digest_value = REXML::XPath.first( ref, '//ds:DigestValue', - { 'ds' => DSIG } + { 'ds' => RubySaml::XML::Crypto::DSIG } ) digest_value = Base64.decode64(RubySaml::Utils.element_text(encoded_digest_value)) @@ -169,9 +169,12 @@ def validate_signature(base64_cert, soft = true) cert = OpenSSL::X509::Certificate.new(cert_text) # verify signature - unless cert.public_key.verify(signature_algorithm.new, signature, canon_string) - return append_error('Key validation error', soft) + signature_verified = false + begin + signature_verified = cert.public_key.verify(signature_hash_algorithm.new, signature, canon_string) + rescue OpenSSL::PKey::PKeyError # rubocop:disable Lint/SuppressedException end + return append_error('Key validation error', soft) unless signature_verified true end @@ -182,24 +185,13 @@ def process_transforms(ref, canon_algorithm) transforms = REXML::XPath.match( ref, '//ds:Transforms/ds:Transform', - { 'ds' => DSIG } + { 'ds' => RubySaml::XML::Crypto::DSIG } ) transforms.each do |transform_element| - next unless transform_element.attributes && transform_element.attributes['Algorithm'] - - algorithm = transform_element.attributes['Algorithm'] - case algorithm - when 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315', - 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments' - canon_algorithm = Nokogiri::XML::XML_C14N_1_0 - when 'http://www.w3.org/2006/12/xml-c14n11', - 'http://www.w3.org/2006/12/xml-c14n11#WithComments' - canon_algorithm = Nokogiri::XML::XML_C14N_1_1 - when 'http://www.w3.org/2001/10/xml-exc-c14n#', - 'http://www.w3.org/2001/10/xml-exc-c14n#WithComments' - canon_algorithm = Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 - end + next unless transform_element.attributes&.[]('Algorithm') + + canon_algorithm = RubySaml::XML::Crypto.canon_algorithm(transform_element, default: false) end canon_algorithm @@ -213,7 +205,7 @@ def extract_signed_element_id reference_element = REXML::XPath.first( self, '//ds:Signature/ds:SignedInfo/ds:Reference', - {'ds'=>DSIG} + { 'ds' => RubySaml::XML::Crypto::DSIG } ) return nil if reference_element.nil? @@ -226,7 +218,7 @@ def extract_inclusive_namespaces element = REXML::XPath.first( self, '//ec:InclusiveNamespaces', - { 'ec' => C14N } + { 'ec' => RubySaml::XML::Crypto::C14N } ) return unless element diff --git a/test/request_test.rb b/test/authrequest_test.rb similarity index 57% rename from test/request_test.rb rename to test/authrequest_test.rb index bf47b54e2..454685111 100644 --- a/test/request_test.rb +++ b/test/authrequest_test.rb @@ -3,7 +3,7 @@ require 'ruby_saml/authrequest' require 'ruby_saml/setting_error' -class RequestTest < Minitest::Test +class AuthrequestTest < Minitest::Test describe "Authrequest" do let(:settings) { RubySaml::Settings.new } @@ -27,8 +27,6 @@ class RequestTest < Minitest::Test end it "create the deflated SAMLRequest URL parameter including the Destination" do - skip "This test fails on this specific JRuby version" if defined?(JRUBY_VERSION) && JRUBY_VERSION == "9.2.17.0" - auth_url = RubySaml::Authrequest.new.create(settings) payload = CGI.unescape(auth_url.split("=").last) decoded = Base64.decode64(payload) @@ -240,159 +238,6 @@ class RequestTest < Minitest::Test assert_match(/urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport<\/saml:AuthnContextDeclRef>/, auth_doc.to_s) end - describe "#create_params signing with HTTP-POST binding" do - before do - settings.idp_sso_service_url = "http://example.com?field=value" - settings.idp_sso_service_binding = :post - settings.security[:authn_requests_signed] = true - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - end - - it "create a signed request" do - params = RubySaml::Authrequest.new.create_params(settings) - request_xml = Base64.decode64(params["SAMLRequest"]) - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - end - - it "create a signed request with 256 digest and signature methods" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - settings.security[:digest_method] = RubySaml::XML::Document::SHA512 - - params = RubySaml::Authrequest.new.create_params(settings) - - request_xml = Base64.decode64(params["SAMLRequest"]) - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - assert_match %r[], request_xml - end - - it "creates a signed request using the first certificate and key" do - settings.certificate = nil - settings.private_key = nil - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::Authrequest.new.create_params(settings) - - request_xml = Base64.decode64(params["SAMLRequest"]) - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - end - - it "creates a signed request using the first valid certificate and key when :check_sp_cert_expiration is true" do - settings.certificate = nil - settings.private_key = nil - settings.security[:check_sp_cert_expiration] = true - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::Authrequest.new.create_params(settings) - - request_xml = Base64.decode64(params["SAMLRequest"]) - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - end - - it "raises error when no valid certs and :check_sp_cert_expiration is true" do - settings.security[:check_sp_cert_expiration] = true - - assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::Authrequest.new.create_params(settings) - end - end - end - - describe "#create_params signing with HTTP-Redirect binding" do - let(:cert) { OpenSSL::X509::Certificate.new(ruby_saml_cert_text) } - - before do - settings.idp_sso_service_url = "http://example.com?field=value" - settings.idp_sso_service_binding = :redirect - settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign" - settings.security[:authn_requests_signed] = true - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - end - - it "create a signature parameter with RSA_SHA1 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['SAMLRequest'] - assert params[:RelayState] - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA1 - - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA1 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end - - it "create a signature parameter with RSA_SHA256 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA256 - - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA256 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end - - it "create a signature parameter using the first certificate and key" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.certificate = nil - settings.private_key = nil - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['SAMLRequest'] - assert params[:RelayState] - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA1 - - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA1 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end - - it "raises error when no valid certs and :check_sp_cert_expiration is true" do - settings.security[:check_sp_cert_expiration] = true - - assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') - end - end - end - it "create the saml:AuthnContextClassRef element correctly" do settings.authn_context = 'secure/name/password/uri' auth_doc = RubySaml::Authrequest.new.create_authentication_xml_doc(settings) @@ -437,5 +282,197 @@ class RequestTest < Minitest::Test assert_equal "new_uuid", authnrequest.request_id end end + + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe "#create_params signing with HTTP-POST binding" do + before do + settings.idp_sso_service_url = "http://example.com?field=value" + settings.idp_sso_service_binding = :post + settings.security[:authn_requests_signed] = true + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo) + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end + + it "create a signed request" do + params = RubySaml::Authrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end + + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + params = RubySaml::Authrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, :sha256), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end + + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + params = RubySaml::Authrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(:sha256), request_xml) + end + end + + it "creates a signed request using the first certificate and key" do + settings.certificate = nil + settings.private_key = nil + settings.sp_cert_multi = { + signing: [ + CertificateHelper.generate_pem_hash(sp_key_algo), + CertificateHelper.generate_pem_hash + ] + } + params = RubySaml::Authrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end + + it "creates a signed request using the first valid certificate and key when :check_sp_cert_expiration is true" do + settings.certificate = nil + settings.private_key = nil + settings.security[:check_sp_cert_expiration] = true + settings.sp_cert_multi = { + signing: [ + CertificateHelper.generate_pem_hash(sp_key_algo), + CertificateHelper.generate_pem_hash + ] + } + params = RubySaml::Authrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end + + it "raises error when no valid certs and :check_sp_cert_expiration is true" do + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo, not_after: Time.now - 60) + settings.security[:check_sp_cert_expiration] = true + + assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do + RubySaml::Authrequest.new.create_params(settings) + end + end + end + end + + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe "#create_params signing with HTTP-Redirect binding" do + let(:cert) { OpenSSL::X509::Certificate.new(ruby_saml_cert_text) } + + before do + settings.idp_sso_service_url = "http://example.com?field=value" + settings.idp_sso_service_binding = :redirect + settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign" + settings.security[:authn_requests_signed] = true + @cert, @pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.certificate, settings.private_key = [@cert, @pkey].map(&:to_pem) + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end + + it "create a signature parameter and validate it" do + params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) + + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end + + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha256) + + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end + + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) + + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end + end + + it "create a signature parameter using the first certificate and key" do + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 + settings.certificate = nil + settings.private_key = nil + cert, pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.sp_cert_multi = { + signing: [ + { certificate: cert.to_pem, private_key: pkey.to_pem }, + CertificateHelper.generate_pem_hash + ] + } + + params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha1) + + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + signature_algorithm = RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']) + assert_equal signature_algorithm, OpenSSL::Digest::SHA1 + assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) + end + + it "raises error when no valid certs and :check_sp_cert_expiration is true" do + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo, not_after: Time.now - 60) + settings.security[:check_sp_cert_expiration] = true + + assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do + RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + end + end + end + end end end diff --git a/test/helpers/certificate_helper.rb b/test/helpers/certificate_helper.rb index e826fbfb5..b212c0534 100644 --- a/test/helpers/certificate_helper.rb +++ b/test/helpers/certificate_helper.rb @@ -3,28 +3,29 @@ module CertificateHelper extend self - def generate_pair(not_before: nil, not_after: nil) - key = generate_key - cert = generate_cert(key, not_before: not_before, not_after: not_after) + def generate_pair(algorithm = :rsa, digest: nil, not_before: nil, not_after: nil) + key = generate_private_key(algorithm) + cert = generate_cert(key, digest: digest, not_before: not_before, not_after: not_after) [cert, key] end - def generate_pair_hash(not_before: nil, not_after: nil) - cert, key = generate_pair(not_before: not_before, not_after: not_after) - { certificate: cert.to_pem, private_key: key.to_pem } + def generate_pem_array(algorithm = :rsa, not_before: nil, not_after: nil) + generate_pair(algorithm, not_before: not_before, not_after: not_after).map(&:to_pem) end - def generate_key - OpenSSL::PKey::RSA.new(1024) + def generate_pem_hash(algorithm = :rsa, not_before: nil, not_after: nil) + cert, key = generate_pem_array(algorithm, not_before: not_before, not_after: not_after) + { certificate: cert, private_key: key } end - def generate_cert(key = generate_key, not_before: nil, not_after: nil) + def generate_cert(algorithm = :rsa, digest: nil, not_before: nil, not_after: nil) + key = generate_private_key(algorithm) cert = OpenSSL::X509::Certificate.new cert.version = 2 cert.serial = 0 cert.not_before = not_before || Time.now - one_year cert.not_after = not_after || Time.now + one_year - cert.public_key = key.public_key + cert.public_key = generate_public_key(key) cert.subject = OpenSSL::X509::Name.parse "/DC=org/DC=ruby-saml/CN=Ruby SAML CA" cert.issuer = cert.subject # self-signed factory = OpenSSL::X509::ExtensionFactory.new @@ -32,10 +33,35 @@ def generate_cert(key = generate_key, not_before: nil, not_after: nil) factory.issuer_certificate = cert cert.add_extension factory.create_extension("basicConstraints","CA:TRUE", true) cert.add_extension factory.create_extension("keyUsage","keyCertSign, cRLSign", true) - cert.sign(key, OpenSSL::Digest::SHA1.new) + cert.sign(key, generate_digest(digest)) cert end + def generate_private_key(algorithm = :rsa) + case algorithm + when OpenSSL::PKey::PKey + algorithm + when :dsa + OpenSSL::PKey::DSA.new(2048) + when :ec, :ecdsa + OpenSSL::PKey::EC.generate('prime256v1') + else + OpenSSL::PKey::RSA.new(2048) + end + end + + def generate_public_key(private_key) + private_key.is_a?(OpenSSL::PKey::EC) ? private_key : private_key.public_key + end + + def generate_digest(digest) + case digest + when OpenSSL::Digest then digest + when NilClass then OpenSSL::Digest.new('SHA256') + else OpenSSL::Digest.new(digest.to_s.upcase) + end + end + private def one_year diff --git a/test/idp_metadata_parser_test.rb b/test/idp_metadata_parser_test.rb index 1e9067ff8..caad52a9a 100644 --- a/test/idp_metadata_parser_test.rb +++ b/test/idp_metadata_parser_test.rb @@ -163,8 +163,8 @@ def initialize; end } }) assert_equal "C4:C6:BD:41:EC:AD:57:97:CE:7B:7D:80:06:C3:E4:30:53:29:02:0B:DD:2D:47:02:9E:BD:85:AD:93:02:45:21", settings.idp_cert_fingerprint - assert_equal RubySaml::XML::Document::SHA256, settings.security[:digest_method] - assert_equal RubySaml::XML::Document::RSA_SHA256, settings.security[:signature_method] + assert_equal RubySaml::XML::Document::SHA256, settings.get_sp_digest_method + assert_equal RubySaml::XML::Document::RSA_SHA256, settings.get_sp_signature_method end it "merges results into given settings object" do @@ -176,8 +176,8 @@ def initialize; end RubySaml::IdpMetadataParser.new.parse(idp_metadata_descriptor, :settings => settings) assert_equal "C4:C6:BD:41:EC:AD:57:97:CE:7B:7D:80:06:C3:E4:30:53:29:02:0B:DD:2D:47:02:9E:BD:85:AD:93:02:45:21", settings.idp_cert_fingerprint - assert_equal RubySaml::XML::Document::SHA256, settings.security[:digest_method] - assert_equal RubySaml::XML::Document::RSA_SHA256, settings.security[:signature_method] + assert_equal RubySaml::XML::Document::SHA256, settings.get_sp_digest_method + assert_equal RubySaml::XML::Document::RSA_SHA256, settings.get_sp_signature_method end end diff --git a/test/logoutrequest_test.rb b/test/logoutrequest_test.rb index a1368249d..828289647 100644 --- a/test/logoutrequest_test.rb +++ b/test/logoutrequest_test.rb @@ -12,7 +12,7 @@ class RequestTest < Minitest::Test settings.name_identifier_value = "f00f00" end - it "create the deflated SAMLRequest URL parameter" do + it "creates the deflated SAMLRequest URL parameter" do unauth_url = RubySaml::Logoutrequest.new.create(settings) assert_match(/^http:\/\/unauth\.com\/logout\?SAMLRequest=/, unauth_url) @@ -67,14 +67,14 @@ class RequestTest < Minitest::Test end describe "when the target url doesn't contain a query string" do - it "create the SAMLRequest parameter correctly" do + it "creates the SAMLRequest parameter correctly" do unauth_url = RubySaml::Logoutrequest.new.create(settings) assert_match(/^http:\/\/unauth.com\/logout\?SAMLRequest/, unauth_url) end end describe "when the target url contains a query string" do - it "create the SAMLRequest parameter correctly" do + it "creates the SAMLRequest parameter correctly" do settings.idp_slo_service_url = "http://example.com?field=value" unauth_url = RubySaml::Logoutrequest.new.create(settings) @@ -109,249 +109,235 @@ class RequestTest < Minitest::Test end end - describe "signing with HTTP-POST binding" do - before do - settings.security[:logout_requests_signed] = true - settings.idp_slo_service_binding = :post - settings.idp_sso_service_binding = :redirect - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - end - - it "doesn't sign through create_xml_document" do - unauth_req = RubySaml::Logoutrequest.new - inflated = unauth_req.create_xml_document(settings).to_s - - refute_match %r[([a-zA-Z0-9/+=]+)], inflated - refute_match %r[], inflated - refute_match %r[], inflated + describe "#manipulate request_id" do + it "be able to modify the request id" do + logoutrequest = RubySaml::Logoutrequest.new + request_id = logoutrequest.request_id + assert_equal request_id, logoutrequest.uuid + logoutrequest.uuid = "new_uuid" + assert_equal logoutrequest.request_id, logoutrequest.uuid + assert_equal "new_uuid", logoutrequest.request_id end + end - it "sign unsigned request" do - unauth_req = RubySaml::Logoutrequest.new - unauth_req_doc = unauth_req.create_xml_document(settings) - inflated = unauth_req_doc.to_s - - refute_match %r[([a-zA-Z0-9/+=]+)], inflated - refute_match %r[], inflated - refute_match %r[], inflated + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe 'signing with HTTP-POST binding' do + before do + settings.idp_slo_service_binding = :post + settings.idp_sso_service_binding = :redirect + settings.security[:logout_requests_signed] = true + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo) + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end - inflated = unauth_req.sign_document(unauth_req_doc, settings).to_s + it "doesn't sign through create_xml_document" do + unauth_req = RubySaml::Logoutrequest.new + inflated = unauth_req.create_xml_document(settings).to_s - assert_match %r[([a-zA-Z0-9/+=]+)], inflated - assert_match %r[], inflated - assert_match %r[], inflated - end + refute_match(/([a-zA-Z0-9/+=]+)], inflated - assert_match %r[], inflated - assert_match %r[], inflated - end + refute_match(/([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - assert_match %r[], request_xml - end + assert_match(signature_value_matcher, inflated) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), inflated) + assert_match(digest_method_matcher(sp_hash_algo), inflated) + end - it "create a signed logout request with 256 digest and signature method" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + it "signs through create_logout_request_xml_doc" do + unauth_req = RubySaml::Logoutrequest.new + inflated = unauth_req.create_logout_request_xml_doc(settings).to_s - params = RubySaml::Logoutrequest.new.create_params(settings) - request_xml = Base64.decode64(params["SAMLRequest"]) - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - assert_match %r[], request_xml - end + assert_match(signature_value_matcher, inflated) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), inflated) + assert_match(digest_method_matcher(sp_hash_algo), inflated) + end - it "create a signed logout request with 512 digest and signature method RSA_SHA384" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA384 - settings.security[:digest_method] = RubySaml::XML::Document::SHA512 + it "creates a signed logout request" do + params = RubySaml::Logoutrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) - params = RubySaml::Logoutrequest.new.create_params(settings) - request_xml = Base64.decode64(params["SAMLRequest"]) + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - assert_match %r[], request_xml - end + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + params = RubySaml::Logoutrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, :sha256), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end + + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + params = RubySaml::Logoutrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(:sha256), request_xml) + end + end - it "create a signed logout request using the first certificate and key" do - settings.certificate = nil - settings.private_key = nil - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::Logoutrequest.new.create_params(settings) - request_xml = Base64.decode64(params["SAMLRequest"]) - - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - assert_match %r[], request_xml - end + it "creates a signed logout request using the first certificate and key" do + settings.certificate = nil + settings.private_key = nil + settings.sp_cert_multi = { + signing: [ + CertificateHelper.generate_pem_hash(sp_key_algo), + CertificateHelper.generate_pem_hash + ] + } + params = RubySaml::Logoutrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end - it "create a signed logout request using the first valid certificate and key when :check_sp_cert_expiration is true" do - settings.certificate = nil - settings.private_key = nil - settings.security[:check_sp_cert_expiration] = true - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::Logoutrequest.new.create_params(settings) - request_xml = Base64.decode64(params["SAMLRequest"]) - - assert_match %r[([a-zA-Z0-9/+=]+)], request_xml - assert_match %r[], request_xml - assert_match %r[], request_xml - end + it "creates a signed logout request using the first valid certificate and key when :check_sp_cert_expiration is true" do + settings.certificate = nil + settings.private_key = nil + settings.security[:check_sp_cert_expiration] = true + settings.sp_cert_multi = { + signing: [ + CertificateHelper.generate_pem_hash(sp_key_algo), + CertificateHelper.generate_pem_hash + ] + } + params = RubySaml::Logoutrequest.new.create_params(settings) + request_xml = Base64.decode64(params["SAMLRequest"]) + + assert_match(signature_value_matcher, request_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), request_xml) + assert_match(digest_method_matcher(sp_hash_algo), request_xml) + end - it "raises error when no valid certs and :check_sp_cert_expiration is true" do - settings.security[:check_sp_cert_expiration] = true + it "raises error when no valid certs and :check_sp_cert_expiration is true" do + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo, not_after: Time.now - 60) + settings.security[:check_sp_cert_expiration] = true - assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::Logoutrequest.new.create_params(settings) + assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do + RubySaml::Logoutrequest.new.create_params(settings) + end end end end - describe "signing with HTTP-Redirect binding" do - - let(:cert) { OpenSSL::X509::Certificate.new(ruby_saml_cert_text) } - - before do - settings.security[:logout_requests_signed] = true - settings.idp_slo_service_binding = :redirect - settings.idp_sso_service_binding = :post - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - end - - it "create a signature parameter with RSA_SHA1 / SHA1 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['SAMLRequest'] - assert params[:RelayState] - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA1 - - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA1 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe 'signing with HTTP-Redirect binding' do + before do + settings.idp_slo_service_binding = :redirect + settings.idp_sso_service_binding = :post + settings.security[:logout_requests_signed] = true + @cert, @pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.certificate, settings.private_key = [@cert, @pkey].map(&:to_pem) + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end - it "create a signature parameter with RSA_SHA256 / SHA256 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + it "creates a signature parameter and validate it" do + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA256 + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA256 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end - it "create a signature parameter with RSA_SHA384 / SHA384 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA384 + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA384 + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha256) - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA384 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end - it "create a signature parameter with RSA_SHA512 / SHA512 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA512 + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA512 + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA512 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end + end - it "create a signature parameter using the first certificate and key" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.certificate = nil - settings.private_key = nil - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - assert params['SAMLRequest'] - assert params[:RelayState] - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA1 - - query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA1 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + it "creates a signature parameter using the first certificate and key" do + settings.certificate = nil + settings.private_key = nil + cert, pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.sp_cert_multi = { + signing: [ + { certificate: cert.to_pem, private_key: pkey.to_pem }, + CertificateHelper.generate_pem_hash + ] + } + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + + assert params['SAMLRequest'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) + + query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end - it "raises error when no valid certs and :check_sp_cert_expiration is true" do - settings.security[:check_sp_cert_expiration] = true + it "raises error when no valid certs and :check_sp_cert_expiration is true" do + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo, not_after: Time.now - 60) + settings.security[:check_sp_cert_expiration] = true - assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do + RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + end end end end - - describe "#manipulate request_id" do - it "be able to modify the request id" do - logoutrequest = RubySaml::Logoutrequest.new - request_id = logoutrequest.request_id - assert_equal request_id, logoutrequest.uuid - logoutrequest.uuid = "new_uuid" - assert_equal logoutrequest.request_id, logoutrequest.uuid - assert_equal "new_uuid", logoutrequest.request_id - end - end end end diff --git a/test/logoutresponse_test.rb b/test/logoutresponse_test.rb index 55f419014..7185a0446 100644 --- a/test/logoutresponse_test.rb +++ b/test/logoutresponse_test.rb @@ -6,7 +6,6 @@ class RubySamlTest < Minitest::Test describe "Logoutresponse" do - let(:valid_logout_response_without_settings) { RubySaml::Logoutresponse.new(valid_logout_response_document) } let(:valid_logout_response) { RubySaml::Logoutresponse.new(valid_logout_response_document, settings) } @@ -14,16 +13,20 @@ class RubySamlTest < Minitest::Test it "raise an exception when response is initialized with nil" do assert_raises(ArgumentError) { RubySaml::Logoutresponse.new(nil) } end + it "default to empty settings" do assert_nil valid_logout_response_without_settings.settings end + it "accept constructor-injected settings" do refute_nil valid_logout_response.settings end + it "accept constructor-injected options" do logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document, nil, { :foo => :bar} ) - assert !logoutresponse.options.empty? + refute logoutresponse.options.empty? end + it "support base64 encoded responses" do generated_logout_response = valid_logout_response_document logoutresponse = RubySaml::Logoutresponse.new(Base64.encode64(generated_logout_response), settings) @@ -32,20 +35,20 @@ class RubySamlTest < Minitest::Test end describe "#validate_structure" do - it "invalidates when the logout response has an invalid xml" do - settings.soft = true - logoutresponse = RubySaml::Logoutresponse.new(invalid_xml_logout_response_document, settings) - assert !logoutresponse.send(:validate_structure) - assert_includes logoutresponse.errors, "Invalid SAML Logout Response. Not match the saml-schema-protocol-2.0.xsd" - end + it "invalidates when the logout response has an invalid xml" do + settings.soft = true + logoutresponse = RubySaml::Logoutresponse.new(invalid_xml_logout_response_document, settings) + refute logoutresponse.send(:validate_structure) + assert_includes logoutresponse.errors, "Invalid SAML Logout Response. Not match the saml-schema-protocol-2.0.xsd" + end - it "raise when the logout response has an invalid xml" do - settings.soft = false - logoutresponse = RubySaml::Logoutresponse.new(invalid_xml_logout_response_document, settings) - assert_raises RubySaml::ValidationError do - logoutresponse.send(:validate_structure) - end + it "raise when the logout response has an invalid xml" do + settings.soft = false + logoutresponse = RubySaml::Logoutresponse.new(invalid_xml_logout_response_document, settings) + assert_raises RubySaml::ValidationError do + logoutresponse.send(:validate_structure) end + end end describe "#validate" do @@ -57,14 +60,11 @@ class RubySamlTest < Minitest::Test it "validate the logout response" do in_relation_to_request_id = random_id opts = { :matches_request_id => in_relation_to_request_id} - logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document({:uuid => in_relation_to_request_id}), settings, opts) assert logoutresponse.validate - assert_equal settings.sp_entity_id, logoutresponse.issuer assert_equal in_relation_to_request_id, logoutresponse.in_response_to - assert logoutresponse.success? assert_empty logoutresponse.errors end @@ -73,8 +73,8 @@ class RubySamlTest < Minitest::Test in_relation_to_request_id = random_id settings.idp_entity_id = 'http://app.muda.no' opts = { :matches_request_id => in_relation_to_request_id} - logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document({:uuid => in_relation_to_request_id}), settings, opts) + assert logoutresponse.validate assert_equal in_relation_to_request_id, logoutresponse.in_response_to assert logoutresponse.success? @@ -83,7 +83,8 @@ class RubySamlTest < Minitest::Test it "invalidate logout response when initiated with blank" do logoutresponse = RubySaml::Logoutresponse.new("", settings) - assert !logoutresponse.validate + + refute logoutresponse.validate assert_includes logoutresponse.errors, "Blank logout response" end @@ -92,17 +93,17 @@ class RubySamlTest < Minitest::Test settings.idp_cert = nil settings.idp_cert_multi = nil logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document, settings) - assert !logoutresponse.validate + + refute logoutresponse.validate assert_includes logoutresponse.errors, "No fingerprint or certificate on settings of the logout response" end it "invalidate logout response with wrong id when given option :matches_request_id" do expected_request_id = "_some_other_expected_uuid" opts = { :matches_request_id => expected_request_id} - logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document, settings, opts) - assert !logoutresponse.validate + refute logoutresponse.validate refute_equal expected_request_id, logoutresponse.in_response_to assert_includes logoutresponse.errors, "The InResponseTo of the Logout Response: #{logoutresponse.in_response_to}, does not match the ID of the Logout Request sent by the SP: #{expected_request_id}" end @@ -110,16 +111,16 @@ class RubySamlTest < Minitest::Test it "invalidate logout response with unexpected request status" do logoutresponse = RubySaml::Logoutresponse.new(unsuccessful_logout_response_document, settings) - assert !logoutresponse.success? - assert !logoutresponse.validate + refute logoutresponse.success? + refute logoutresponse.validate assert_includes logoutresponse.errors, "The status code of the Logout Response was not Success, was Requester" end it "invalidate logout response with unexpected request status and status message" do logoutresponse = RubySaml::Logoutresponse.new(unsuccessful_logout_response_with_message_document, settings) - assert !logoutresponse.success? - assert !logoutresponse.validate + refute logoutresponse.success? + refute logoutresponse.validate assert_includes logoutresponse.errors, "The status code of the Logout Response was not Success, was Requester -> Logoutrequest expired" end @@ -128,7 +129,7 @@ class RubySamlTest < Minitest::Test bad_settings.issuer = nil bad_settings.sp_entity_id = nil logoutresponse = RubySaml::Logoutresponse.new(unsuccessful_logout_response_document, bad_settings) - assert !logoutresponse.validate + refute logoutresponse.validate assert_includes logoutresponse.errors, "No sp_entity_id in settings of the logout response" end @@ -136,7 +137,7 @@ class RubySamlTest < Minitest::Test in_relation_to_request_id = random_id settings.idp_entity_id = 'http://invalid.issuer.example.com/' logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document({:uuid => in_relation_to_request_id}), settings) - assert !logoutresponse.validate + refute logoutresponse.validate assert_includes logoutresponse.errors, "Doesn't match the issuer, expected: <#{logoutresponse.settings.idp_entity_id}>, but was: " end @@ -144,7 +145,7 @@ class RubySamlTest < Minitest::Test settings.idp_entity_id = 'http://invalid.issuer.example.com/' logoutresponse = RubySaml::Logoutresponse.new(unsuccessful_logout_response_document, settings) collect_errors = true - assert !logoutresponse.validate(collect_errors) + refute logoutresponse.validate(collect_errors) assert_includes logoutresponse.errors, "The status code of the Logout Response was not Success, was Requester" assert_includes logoutresponse.errors, "Doesn't match the issuer, expected: <#{logoutresponse.settings.idp_entity_id}>, but was: " end @@ -158,8 +159,8 @@ class RubySamlTest < Minitest::Test it "validates good logout response" do in_relation_to_request_id = random_id - logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document({:uuid => in_relation_to_request_id}), settings) + assert logoutresponse.validate assert_empty logoutresponse.errors end @@ -175,16 +176,16 @@ class RubySamlTest < Minitest::Test settings.idp_cert_fingerprint = nil settings.idp_cert = nil logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document, settings) + assert_raises(RubySaml::ValidationError) { logoutresponse.validate } assert_includes logoutresponse.errors, "No fingerprint or certificate on settings of the logout response" end it "raises validation error when matching for wrong request id" do - expected_request_id = "_some_other_expected_id" opts = { :matches_request_id => expected_request_id} - logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document, settings, opts) + assert_raises(RubySaml::ValidationError) { logoutresponse.validate } assert_includes logoutresponse.errors, "The InResponseTo of the Logout Response: #{logoutresponse.in_response_to}, does not match the ID of the Logout Request sent by the SP: #{expected_request_id}" end @@ -199,6 +200,7 @@ class RubySamlTest < Minitest::Test it "raise validation error when in bad state" do # no settings logoutresponse = RubySaml::Logoutresponse.new(unsuccessful_logout_response_document, settings) + assert_raises(RubySaml::ValidationError) { logoutresponse.validate } assert_includes logoutresponse.errors, "The status code of the Logout Response was not Success, was Requester" end @@ -207,6 +209,7 @@ class RubySamlTest < Minitest::Test settings.issuer = nil settings.sp_entity_id = nil logoutresponse = RubySaml::Logoutresponse.new(unsuccessful_logout_response_document, settings) + assert_raises(RubySaml::ValidationError) { logoutresponse.validate } assert_includes logoutresponse.errors, "No sp_entity_id in settings of the logout response" end @@ -215,209 +218,206 @@ class RubySamlTest < Minitest::Test in_relation_to_request_id = random_id settings.idp_entity_id = 'http://invalid.issuer.example.com/' logoutresponse = RubySaml::Logoutresponse.new(valid_logout_response_document({:uuid => in_relation_to_request_id}), settings) + assert_raises(RubySaml::ValidationError) { logoutresponse.validate } assert_includes logoutresponse.errors, "Doesn't match the issuer, expected: <#{logoutresponse.settings.idp_entity_id}>, but was: " end end - describe "#validate_signature" do - let (:params) { RubySaml::SloLogoutresponse.new.create_params(settings, random_id, "Custom Logout Message", :RelayState => 'http://example.com') } - - before do - settings.soft = true - settings.idp_slo_service_url = "http://example.com?field=value" - settings.security[:logout_responses_signed] = true - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - settings.idp_cert = ruby_saml_cert_text - end + each_signature_algorithm do |idp_key_algo, idp_hash_algo| + describe "#validate_signature" do + let (:params) { RubySaml::SloLogoutresponse.new.create_params(settings, random_id, "Custom Logout Message", :RelayState => 'http://example.com') } + + before do + settings.soft = true + settings.idp_slo_service_url = "http://example.com?field=value" + @cert, @pkey = CertificateHelper.generate_pair(idp_key_algo) + settings.idp_cert = @cert.to_pem + + # These SP settings are added in order to create dummy params which + # have the correct IdP signature. They do NOT normally affect IdP logic. + settings.certificate = @cert.to_pem + settings.private_key = @pkey.to_pem + settings.security[:logout_responses_signed] = true + settings.security[:signature_method] = signature_method(idp_key_algo, idp_hash_algo) + end - it "return true when no idp_cert is provided and option :relax_signature_validation is present" do - settings.idp_cert = nil - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - options[:relax_signature_validation] = true - logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert logoutresponse_sign_test.send(:validate_signature) - end + it "return true when no idp_cert is provided and option :relax_signature_validation is present" do + settings.idp_cert = nil + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + options[:relax_signature_validation] = true + logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - it "return false when no idp_cert is provided and no option :relax_signature_validation is present" do - settings.idp_cert = nil - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert !logoutresponse_sign_test.send(:validate_signature) - end + assert logoutresponse_sign_test.send(:validate_signature) + end - it "return true when valid RSA_SHA1 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert logoutresponse_sign_test.send(:validate_signature) - end + it "return false when no idp_cert is provided and no option :relax_signature_validation is present" do + settings.idp_cert = nil + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - it "return true when valid RSA_SHA256 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert logoutresponse.send(:validate_signature) - end + refute logoutresponse_sign_test.send(:validate_signature) + end - it "return false when invalid RSA_SHA1 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params['RelayState'] = 'http://invalid.example.com' - options = {} - options[:get_params] = params - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert !logoutresponse.send(:validate_signature) - end + it "return true when valid signature" do + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - it "raise when invalid RSA_SHA1 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.soft = false - params['RelayState'] = 'http://invalid.example.com' - options = {} - options[:get_params] = params - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) + assert logoutresponse_sign_test.send(:validate_signature) + end - assert_raises(RubySaml::ValidationError) { logoutresponse.send(:validate_signature) } - assert logoutresponse.errors.include? "Invalid Signature on Logout Response" - end + it "return false when invalid signature" do + params['RelayState'] = 'http://invalid.example.com' + options = {} + options[:get_params] = params + logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - it "raise when get_params encoding differs from what this library generates" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.soft = false - options = {} - options[:get_params] = params - options[:get_params]['RelayState'] = 'http://example.com' - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - # Assemble query string. - query = RubySaml::Utils.build_query( - :type => 'SAMLResponse', - :data => params['SAMLResponse'], - :relay_state => params['RelayState'], - :sig_alg => params['SigAlg'] - ) - # Modify the query string so that it encodes the same values, - # but with different percent-encoding. Sanity-check that they - # really are equialent before moving on. - original_query = query.dup - query.gsub!("example", "ex%61mple") - refute_equal(query, original_query) - assert_equal(CGI.unescape(query), CGI.unescape(original_query)) - # Make normalised signature based on our modified params. - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) - signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) - params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") - # Re-create the Logoutresponse based on these modified parameters, - # and ask it to validate the signature. It will do it incorrectly, - # because it will compute it based on re-encoded query parameters, - # rather than their original encodings. - options[:get_params] = params - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do - logoutresponse.send(:validate_signature) + refute logoutresponse.send(:validate_signature) end - end - it "return true even if raw_get_params encoding differs from what this library generates" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.soft = false - options = {} - options[:get_params] = params - options[:get_params]['RelayState'] = 'http://example.com' - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - # Assemble query string. - query = RubySaml::Utils.build_query( - :type => 'SAMLResponse', - :data => params['SAMLResponse'], - :relay_state => params['RelayState'], - :sig_alg => params['SigAlg'] - ) - # Modify the query string so that it encodes the same values, - # but with different percent-encoding. Sanity-check that they - # really are equialent before moving on. - original_query = query.dup - query.gsub!("example", "ex%61mple") - refute_equal(query, original_query) - assert_equal(CGI.unescape(query), CGI.unescape(original_query)) - # Make normalised signature based on our modified params. - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) - signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) - params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") - # Re-create the Logoutresponse based on these modified parameters, - # and ask it to validate the signature. Provide the altered parameter - # in its raw URI-encoded form, so that we don't have to guess the value - # that contributed to the signature. - options[:get_params] = params - options[:get_params].delete("RelayState") - options[:raw_get_params] = { - "RelayState" => "http%3A%2F%2Fex%61mple.com", - } - logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert logoutresponse.send(:validate_signature) - end - end - - describe "#validate_signature" do - let (:params) { RubySaml::SloLogoutresponse.new.create_params(settings, random_id, "Custom Logout Message", :RelayState => 'http://example.com') } + it "raise when invalid signature" do + settings.soft = false + params['RelayState'] = 'http://invalid.example.com' + options = {} + options[:get_params] = params + logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - before do - settings.soft = true - settings.idp_slo_service_url = "http://example.com?field=value" - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.security[:logout_responses_signed] = true - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - settings.idp_cert = nil - end + assert_raises(RubySaml::ValidationError) { logoutresponse.send(:validate_signature) } + assert logoutresponse.errors.include? "Invalid Signature on Logout Response" + end - it "return true when at least a idp_cert is valid" do - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - settings.idp_cert_multi = { - :signing => [ruby_saml_cert_text2, ruby_saml_cert_text], - :encryption => [] - } - logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert logoutresponse_sign_test.send(:validate_signature) - end + it "raise when get_params encoding differs from what this library generates" do + settings.soft = false + options = {} + options[:get_params] = params + options[:get_params]['RelayState'] = 'http://example.com' + query = RubySaml::Utils.build_query( + :type => 'SAMLResponse', + :data => params['SAMLResponse'], + :relay_state => params['RelayState'], + :sig_alg => params['SigAlg'] + ) + # Modify the query string so that it encodes the same values, + # but with different percent-encoding. Sanity-check that they + # really are equialent before moving on. + original_query = query.dup + query.gsub!("example", "ex%61mple") + + refute_equal(query, original_query) + assert_equal(CGI.unescape(query), CGI.unescape(original_query)) + + # Make normalised signature based on our modified params. + hash_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.security[:signature_method]) + signature = settings.get_sp_signing_key.sign(hash_algorithm.new, query) + params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") + # Re-create the Logoutresponse based on these modified parameters, + # and ask it to validate the signature. It will do it incorrectly, + # because it will compute it based on re-encoded query parameters, + # rather than their original encodings. + options[:get_params] = params + logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) + + assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do + logoutresponse.send(:validate_signature) + end + end - it "return false when cert expired and check_idp_cert_expiration expired" do - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - settings.security[:check_idp_cert_expiration] = true - settings.idp_cert = nil - settings.idp_cert_multi = { - :signing => [ruby_saml_cert_text], - :encryption => [] - } - logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert !logoutresponse_sign_test.send(:validate_signature) - assert_includes logoutresponse_sign_test.errors, "IdP x509 certificate expired" - end + it "return true even if raw_get_params encoding differs from what this library generates" do + settings.soft = false + options = {} + options[:get_params] = params + options[:get_params]['RelayState'] = 'http://example.com' + query = RubySaml::Utils.build_query( + :type => 'SAMLResponse', + :data => params['SAMLResponse'], + :relay_state => params['RelayState'], + :sig_alg => params['SigAlg'] + ) + # Modify the query string so that it encodes the same values, + # but with different percent-encoding. Sanity-check that they + # really are equialent before moving on. + original_query = query.dup + query.gsub!("example", "ex%61mple") + + refute_equal(query, original_query) + assert_equal(CGI.unescape(query), CGI.unescape(original_query)) + + # Make normalised signature based on our modified params. + hash_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.security[:signature_method]) + signature = settings.get_sp_signing_key.sign(hash_algorithm.new, query) + params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") + + # Re-create the Logoutresponse based on these modified parameters, + # and ask it to validate the signature. Provide the altered parameter + # in its raw URI-encoded form, so that we don't have to guess the value + # that contributed to the signature. + options[:get_params] = params + options[:get_params].delete("RelayState") + options[:raw_get_params] = { "RelayState" => "http%3A%2F%2Fex%61mple.com" } + logoutresponse = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) + + assert logoutresponse.send(:validate_signature) + end - it "return false when none cert on idp_cert_multi is valid" do - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - settings.idp_cert_multi = { - :signing => [ruby_saml_cert_text2, ruby_saml_cert_text2], - :encryption => [] - } - logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) - assert !logoutresponse_sign_test.send(:validate_signature) - assert_includes logoutresponse_sign_test.errors, "Invalid Signature on Logout Response" + describe "with multitple idp certs" do + before do + settings.idp_cert = nil + end + + it "return true when at least a idp_cert is valid" do + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + settings.idp_cert_multi = { + :signing => [@cert.to_pem, ruby_saml_cert_text], + :encryption => [] + } + logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) + + assert logoutresponse_sign_test.send(:validate_signature) + end + + it "return false when cert expired and check_idp_cert_expiration expired" do + settings.security[:check_idp_cert_expiration] = true + settings.idp_cert = nil + settings.idp_cert_multi = { + :signing => [ruby_saml_cert_text], + :encryption => [] + } + + # These SP settings are for dummy params generation. + settings.certificate = ruby_saml_cert_text + settings.private_key = ruby_saml_key_text + + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) + + refute logoutresponse_sign_test.send(:validate_signature) + assert_includes logoutresponse_sign_test.errors, "IdP x509 certificate expired" + end + + it "return false when none cert on idp_cert_multi is valid" do + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + settings.idp_cert_multi = { + :signing => [ruby_saml_cert_text2, ruby_saml_cert_text2], + :encryption => [] + } + logoutresponse_sign_test = RubySaml::Logoutresponse.new(params['SAMLResponse'], settings, options) + + refute logoutresponse_sign_test.send(:validate_signature) + assert_includes logoutresponse_sign_test.errors, "Invalid Signature on Logout Response" + end + end end end end diff --git a/test/metadata_test.rb b/test/metadata_test.rb index b97b8b382..d82f8489e 100644 --- a/test/metadata_test.rb +++ b/test/metadata_test.rb @@ -232,9 +232,9 @@ class MetadataTest < Minitest::Test before do settings.security[:want_assertions_encrypted] = true settings.security[:check_sp_cert_expiration] = true - valid_pair = CertificateHelper.generate_pair_hash - early_pair = CertificateHelper.generate_pair_hash(not_before: Time.now + 60) - expired_pair = CertificateHelper.generate_pair_hash(not_after: Time.now - 60) + valid_pair = CertificateHelper.generate_pem_hash + early_pair = CertificateHelper.generate_pem_hash(not_before: Time.now + 60) + expired_pair = CertificateHelper.generate_pem_hash(not_after: Time.now - 60) settings.certificate = nil settings.certificate_new = nil settings.private_key = nil @@ -331,79 +331,108 @@ class MetadataTest < Minitest::Test describe "when the settings indicate to sign (embedded) metadata" do before do settings.security[:metadata_signed] = true - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text end - it "creates a signed metadata" do - assert_match %r[([a-zA-Z0-9/+=]+)]m, xml_text - assert_match %r[], xml_text - assert_match %r[], xml_text - + it "uses RSA SHA256 by default" do + @cert, @pkey = CertificateHelper.generate_pair(:rsa) + settings.certificate, settings.private_key = [@cert, @pkey].map(&:to_pem) + @fingerprint = OpenSSL::Digest.new('SHA256', @cert.to_der).to_s signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) - assert signed_metadata.validate_document(ruby_saml_cert_fingerprint, false) - assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd") + assert_match(signature_value_matcher, xml_text) + assert_match(signature_method_matcher(:rsa, :sha256), xml_text) + assert_match(digest_method_matcher(:sha256), xml_text) + assert(signed_metadata.validate_document(@fingerprint, false)) + assert(validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd")) end - describe "when digest and signature methods are specified" do - before do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - settings.security[:digest_method] = RubySaml::XML::Document::SHA512 - end + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe "specifying algo" do + before do + @cert, @pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.certificate, settings.private_key = [@cert, @pkey].map(&:to_pem) + @fingerprint = OpenSSL::Digest.new('SHA256', @cert.to_der).to_s + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end - it "creates a signed metadata with specified digest and signature methods" do - assert_match %r[([a-zA-Z0-9/+=]+)]m, xml_text - assert_match %r[], xml_text - assert_match %r[], xml_text + it "creates a signed metadata" do + signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) - signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) - assert signed_metadata.validate_document(ruby_saml_cert_fingerprint, false) + assert_match(signature_value_matcher, xml_text) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), xml_text) + assert_match(digest_method_matcher(sp_hash_algo), xml_text) - assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd") - end - end + assert signed_metadata.validate_document(@fingerprint, false) + assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd") + end - describe "when custom metadata elements have been inserted" do - let(:xml_text) { subclass.new.generate(settings, false) } - let(:subclass) do - Class.new(RubySaml::Metadata) do - def add_extras(root, _settings) - idp = REXML::Element.new("md:IDPSSODescriptor") - idp.attributes['protocolSupportEnumeration'] = 'urn:oasis:names:tc:SAML:2.0:protocol' - - nid = REXML::Element.new("md:NameIDFormat") - nid.text = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' - idp.add_element(nid) - - sso = REXML::Element.new("md:SingleSignOnService") - sso.attributes['Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' - sso.attributes['Location'] = 'https://foobar.com/sso' - idp.add_element(sso) - root.insert_before(root.children[0], idp) - - org = REXML::Element.new("md:Organization") - org.add_element("md:OrganizationName", 'xml:lang' => "en-US").text = 'ACME Inc.' - org.add_element("md:OrganizationDisplayName", 'xml:lang' => "en-US").text = 'ACME' - org.add_element("md:OrganizationURL", 'xml:lang' => "en-US").text = 'https://www.acme.com' - root.insert_after(root.children[3], org) + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) + + assert_match(signature_value_matcher, xml_text) + assert_match(signature_method_matcher(sp_key_algo, :sha256), xml_text) + assert_match(digest_method_matcher(sp_hash_algo), xml_text) + assert(signed_metadata.validate_document(@fingerprint, false)) + assert(validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd")) end - end - end - it "inserts signature as the first child of root element" do - first_child = xml_doc.root.children[0] - assert_equal first_child.prefix, 'ds' - assert_equal first_child.name, 'Signature' + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) - assert_match %r[([a-zA-Z0-9/+=]+)]m, xml_text - assert_match %r[], xml_text - assert_match %r[], xml_text + assert_match(signature_value_matcher, xml_text) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), xml_text) + assert_match(digest_method_matcher(:sha256), xml_text) + assert(signed_metadata.validate_document(@fingerprint, false)) + assert(validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd")) + end + end - signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) - assert signed_metadata.validate_document(ruby_saml_cert_fingerprint, false) + describe "when custom metadata elements have been inserted" do + let(:xml_text) { subclass.new.generate(settings, false) } + let(:subclass) do + Class.new(RubySaml::Metadata) do + def add_extras(root, _settings) + idp = REXML::Element.new("md:IDPSSODescriptor") + idp.attributes['protocolSupportEnumeration'] = 'urn:oasis:names:tc:SAML:2.0:protocol' + + nid = REXML::Element.new("md:NameIDFormat") + nid.text = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + idp.add_element(nid) + + sso = REXML::Element.new("md:SingleSignOnService") + sso.attributes['Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + sso.attributes['Location'] = 'https://foobar.com/sso' + idp.add_element(sso) + root.insert_before(root.children[0], idp) + + org = REXML::Element.new("md:Organization") + org.add_element("md:OrganizationName", 'xml:lang' => "en-US").text = 'ACME Inc.' + org.add_element("md:OrganizationDisplayName", 'xml:lang' => "en-US").text = 'ACME' + org.add_element("md:OrganizationURL", 'xml:lang' => "en-US").text = 'https://www.acme.com' + root.insert_after(root.children[3], org) + end + end + end - assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd") + it "inserts signature as the first child of root element" do + xml_text = subclass.new.generate(settings, false) + first_child = xml_doc.root.children[0] + signed_metadata = RubySaml::XML::SignedDocument.new(xml_text) + + assert_equal first_child.prefix, 'ds' + assert_equal first_child.name, 'Signature' + assert_match(signature_value_matcher, xml_text) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), xml_text) + assert_match(digest_method_matcher(sp_hash_algo), xml_text) + assert signed_metadata.validate_document(@fingerprint, false) + assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd") + end + end end end end diff --git a/test/response_test.rb b/test/response_test.rb index 2eef84645..0aa7ea7d0 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -5,7 +5,6 @@ class RubySamlTest < Minitest::Test describe "Response" do - let(:settings) { RubySaml::Settings.new } let(:response) { RubySaml::Response.new(response_document_without_recipient) } let(:response_without_attributes) { RubySaml::Response.new(response_document_without_attributes) } @@ -1500,7 +1499,6 @@ def generate_audience_error(expected, actual) end describe "retrieve nameID and attributes from encrypted assertion" do - before do settings.idp_cert_fingerprint = '55:FD:5F:3F:43:5A:AC:E6:79:89:BF:25:48:81:A1:C4:F3:37:3B:CB:1B:4D:68:A0:3E:A5:C9:FF:61:48:01:3F' settings.sp_entity_id = 'http://rubysaml.com:3000/saml/metadata' @@ -1602,7 +1600,7 @@ def generate_audience_error(expected, actual) settings.private_key = nil settings.sp_cert_multi = { encryption: [ - CertificateHelper.generate_pair_hash, + CertificateHelper.generate_pem_hash, { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text } ] } @@ -1725,8 +1723,8 @@ def generate_audience_error(expected, actual) assert_equal response_double_statuscode.status_code, 'urn:oasis:names:tc:SAML:2.0:status:Requester | urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding' end end - describe "test qualified name id in attributes" do + describe "test qualified name id in attributes" do it "parsed the nameid" do response = RubySaml::Response.new(read_response("signed_nameid_in_atts.xml"), :settings => settings) response.settings.idp_cert_fingerprint = 'c51985d947f1be57082025050846eb27f6cab783' @@ -1737,7 +1735,6 @@ def generate_audience_error(expected, actual) end describe "test unqualified name id in attributes" do - it "parsed the nameid" do response = RubySaml::Response.new(read_response("signed_unqual_nameid_in_atts.xml"), :settings => settings) response.settings.idp_cert_fingerprint = 'c51985d947f1be57082025050846eb27f6cab783' @@ -1783,5 +1780,88 @@ def generate_audience_error(expected, actual) assert_includes response_wrapped.errors, "SAML Response must contain 1 assertion" end end + + each_signature_algorithm do |idp_key_algo, idp_hash_algo| + describe "#validate_signature" do + let(:xml_signed) do + RubySaml::XML::Document.new(read_response('response_unsigned2.xml')) + .sign_document(@pkey, @cert, signature_method(idp_key_algo, idp_hash_algo), digest_method(idp_hash_algo)) + .to_s + end + + before do + settings.soft = true + settings.idp_sso_service_url = "http://example.com?field=value" + @cert, @pkey = CertificateHelper.generate_pair(idp_key_algo) + settings.idp_cert = @cert.to_pem + end + + it "return true when valid signature" do + options = {} + options[:settings] = settings + response_sign_test = RubySaml::Response.new(xml_signed, options) + + assert response_sign_test.send(:validate_signature) + end + + it "return false when no idp_cert is provided and no option :relax_signature_validation is present" do + settings.idp_cert = nil + options = {} + options[:settings] = settings + response_sign_test = RubySaml::Response.new(xml_signed, options) + + refute response_sign_test.send(:validate_signature) + end + + it "return false when invalid signature" do + options = {} + options[:settings] = settings + response = RubySaml::Response.new(xml_signed.gsub('SignatureValue>', 'SignatureValue>Foobar'), options) + + refute response.send(:validate_signature) + end + + it "raise when invalid signature" do + settings.soft = false + options = {} + options[:settings] = settings + response = RubySaml::Response.new(xml_signed.gsub('SignatureValue>', 'SignatureValue>Foobar'), options) + + assert_raises(RubySaml::ValidationError) { response.send(:validate_signature) } + assert response.errors.include? "Key validation error" + end + + describe "with multitple idp certs" do + before do + settings.idp_cert = nil + end + + it "return true when at least a idp_cert is valid" do + options = {} + options[:settings] = settings + settings.idp_cert_multi = { + :signing => [@cert.to_pem, ruby_saml_cert_text], + :encryption => [] + } + response_sign_test = RubySaml::Response.new(xml_signed, options) + + assert response_sign_test.send(:validate_signature) + end + + it "return false when none cert on idp_cert_multi is valid" do + options = {} + options[:settings] = settings + settings.idp_cert_multi = { + :signing => [ruby_saml_cert_text2, ruby_saml_cert_text2], + :encryption => [] + } + response_sign_test = RubySaml::Response.new(xml_signed, options) + + refute response_sign_test.send(:validate_signature) + assert_includes response_sign_test.errors, 'Invalid Signature on SAML Response' + end + end + end + end end end diff --git a/test/responses/response_unsigned2.xml b/test/responses/response_unsigned2.xml new file mode 100644 index 000000000..c510c8ba6 --- /dev/null +++ b/test/responses/response_unsigned2.xml @@ -0,0 +1,26 @@ + + + idp.example.com + + + + + idp.myexample.org + + someone@example.org + + + + + + + example.com + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + diff --git a/test/saml_message_test.rb b/test/saml_message_test.rb index ef0563e30..62f481382 100644 --- a/test/saml_message_test.rb +++ b/test/saml_message_test.rb @@ -3,7 +3,6 @@ class RubySamlTest < Minitest::Test describe "SamlMessage" do - let(:settings) { RubySaml::Settings.new } let(:saml_message) { RubySaml::SamlMessage.new } let(:response_document) { read_response("response_unsigned_xml_base64") } diff --git a/test/settings_test.rb b/test/settings_test.rb index 21f4b6f88..bd64a2742 100644 --- a/test/settings_test.rb +++ b/test/settings_test.rb @@ -100,8 +100,8 @@ class SettingsTest < Minitest::Test new_settings = RubySaml::Settings.new assert_equal new_settings.security[:authn_requests_signed], false - assert_equal new_settings.security[:digest_method], RubySaml::XML::Document::SHA256 - assert_equal new_settings.security[:signature_method], RubySaml::XML::Document::RSA_SHA256 + assert_equal new_settings.get_sp_digest_method, RubySaml::XML::Document::SHA256 + assert_equal new_settings.get_sp_signature_method, RubySaml::XML::Document::RSA_SHA256 end it "overrides only provided security attributes passing a second parameter" do @@ -420,7 +420,7 @@ class SettingsTest < Minitest::Test let(:cert_text2) { ruby_saml_cert2.to_pem } let(:cert_text3) { CertificateHelper.generate_cert.to_pem } let(:key_text1) { ruby_saml_key_text } - let(:key_text2) { CertificateHelper.generate_key.to_pem } + let(:key_text2) { CertificateHelper.generate_private_key.to_pem } it "returns certs for single case" do @settings.certificate = cert_text1 @@ -570,9 +570,9 @@ class SettingsTest < Minitest::Test end describe "#get_sp_certs" do - let(:valid_pair) { CertificateHelper.generate_pair_hash } - let(:early_pair) { CertificateHelper.generate_pair_hash(not_before: Time.now + 60) } - let(:expired_pair) { CertificateHelper.generate_pair_hash(not_after: Time.now - 60) } + let(:valid_pair) { CertificateHelper.generate_pem_hash } + let(:early_pair) { CertificateHelper.generate_pem_hash(not_before: Time.now + 60) } + let(:expired_pair) { CertificateHelper.generate_pem_hash(not_after: Time.now - 60) } it "returns all certs when check_sp_cert_expiration is false" do @settings.security = { check_sp_cert_expiration: false } @@ -623,9 +623,9 @@ class SettingsTest < Minitest::Test end describe "#get_sp_signing_pair and #get_sp_signing_key" do - let(:valid_pair) { CertificateHelper.generate_pair_hash } - let(:early_pair) { CertificateHelper.generate_pair_hash(not_before: Time.now + 60) } - let(:expired) { CertificateHelper.generate_pair_hash(not_after: Time.now - 60) } + let(:valid_pair) { CertificateHelper.generate_pem_hash } + let(:early_pair) { CertificateHelper.generate_pem_hash(not_before: Time.now + 60) } + let(:expired) { CertificateHelper.generate_pem_hash(not_after: Time.now - 60) } it "returns nil when no signing pairs are present" do @settings.sp_cert_multi = { signing: [] } @@ -665,9 +665,9 @@ class SettingsTest < Minitest::Test end describe "#get_sp_decryption_keys" do - let(:valid_pair) { CertificateHelper.generate_pair_hash } - let(:early_pair) { CertificateHelper.generate_pair_hash(not_before: Time.now + 60) } - let(:expired_pair) { CertificateHelper.generate_pair_hash(not_after: Time.now - 60) } + let(:valid_pair) { CertificateHelper.generate_pem_hash } + let(:early_pair) { CertificateHelper.generate_pem_hash(not_before: Time.now + 60) } + let(:expired_pair) { CertificateHelper.generate_pem_hash(not_after: Time.now - 60) } it "returns an empty array when no decryption pairs are present" do @settings.sp_cert_multi = { encryption: [] } @@ -711,5 +711,160 @@ class SettingsTest < Minitest::Test assert_equal expected_keys, actual_keys end end + + describe '#get_sp_signature_method' do + describe 'assumes RSA when sp_cert is nil' do + before do + @settings.certificate = nil + @settings.private_key = nil + end + + it 'uses RSA SHA256 by default' do + assert_equal RubySaml::XML::Document::SHA256, @settings.get_sp_digest_method + end + + it 'can be set as a full string' do + @settings.security[:signature_method] = RubySaml::XML::Document::DSA_SHA1 + + assert_equal RubySaml::XML::Document::DSA_SHA1, @settings.get_sp_signature_method + end + + it 'can be set as a short string' do + @settings.security[:signature_method] = 'EC SHA512' + + assert_equal RubySaml::XML::Crypto::ECDSA_SHA512, @settings.get_sp_signature_method + end + + it 'can be set as a symbol' do + @settings.security[:signature_method] = :ecdsa_sha384 + + assert_equal RubySaml::XML::Crypto::ECDSA_SHA384, @settings.get_sp_signature_method + end + + it 'can be set as a hash algo full string' do + @settings.security[:signature_method] = RubySaml::XML::Crypto::SHA1 + + assert_equal RubySaml::XML::Crypto::RSA_SHA1, @settings.get_sp_signature_method + end + + it 'can be set as a hash algo short string' do + @settings.security[:signature_method] = 'SHA512' + + assert_equal RubySaml::XML::Crypto::RSA_SHA512, @settings.get_sp_signature_method + end + + it 'can be set as a hash algo symbol' do + @settings.security[:signature_method] = :sha384 + + assert_equal RubySaml::XML::Crypto::RSA_SHA384, @settings.get_sp_signature_method + end + + it 'raises error when digest method is invalid' do + @settings.security[:signature_method] = 'RSA_SHA999' + + assert_raises(ArgumentError, 'Unsupported signature method: RSA_SHA999') do + @settings.get_sp_signature_method + end + end + end + + each_key_algorithm do |sp_key_algo| + describe 'when sp_cert is set' do + before { @settings.certificate, @settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo) } + + it "uses #{sp_key_algo} SHA256 by default" do + assert_equal signature_method(sp_key_algo, :sha256), @settings.get_sp_signature_method + end + + it 'can be set as a full string' do + @settings.security[:signature_method] = RubySaml::XML::Document::SHA1 + + assert_equal signature_method(sp_key_algo, :sha1), @settings.get_sp_signature_method + end + + it 'can be set as a short string' do + @settings.security[:signature_method] = 'EC SHA512' + + if sp_key_algo == :dsa + assert_raises(ArgumentError, 'Unsupported signature method for DSA key: SHA512') { @settings.get_sp_signature_method } + else + assert_equal signature_method(sp_key_algo, :sha512), @settings.get_sp_signature_method + end + end + + it 'can be set as a symbol' do + @settings.security[:signature_method] = :ecdsa_sha384 + + if sp_key_algo == :dsa + assert_raises(ArgumentError, 'Unsupported signature method for DSA key: SHA512') { @settings.get_sp_signature_method } + else + assert_equal signature_method(sp_key_algo, :sha384), @settings.get_sp_signature_method + end + end + + it 'can be set as a hash algo full string' do + @settings.security[:signature_method] = RubySaml::XML::Crypto::DSA_SHA1 + + assert_equal signature_method(sp_key_algo, :sha1), @settings.get_sp_signature_method + end + + it 'can be set as a hash algo short string' do + @settings.security[:signature_method] = 'SHA512' + + if sp_key_algo == :dsa + assert_raises(ArgumentError, 'Unsupported signature method for DSA key: SHA512') { @settings.get_sp_signature_method } + else + assert_equal signature_method(sp_key_algo, :sha512), @settings.get_sp_signature_method + end + end + + it 'can be set as a hash algo symbol' do + @settings.security[:signature_method] = :sha1 + + assert_equal signature_method(sp_key_algo, :sha1), @settings.get_sp_signature_method + end + + it 'raises error when digest method is invalid' do + @settings.security[:signature_method] = 'RSA_SHA999' + + assert_raises(ArgumentError, "Unsupported signature method for #{sp_key_algo.to_s.upcase} key: RSA_SHA999") do + @settings.get_sp_signature_method + end + end + end + end + end + + describe '#get_sp_digest_method' do + it 'uses SHA256 by default' do + assert_equal RubySaml::XML::Crypto::SHA256, @settings.get_sp_digest_method + end + + it 'can be set as full string' do + @settings.security[:digest_method] = RubySaml::XML::Document::SHA224 + + assert_equal RubySaml::XML::Crypto::SHA224, @settings.get_sp_digest_method + end + + it 'can be set as short string' do + @settings.security[:digest_method] = 'SHA512' + + assert_equal RubySaml::XML::Crypto::SHA512, @settings.get_sp_digest_method + end + + it 'can be set as symbol' do + @settings.security[:digest_method] = :sha384 + + assert_equal RubySaml::XML::Crypto::SHA384, @settings.get_sp_digest_method + end + + it 'raises error when digest method is invalid' do + @settings.security[:digest_method] = 'SHA999' + + assert_raises(ArgumentError, 'Unsupported digest method: SHA999') do + @settings.get_sp_digest_method + end + end + end end end diff --git a/test/slo_logoutrequest_test.rb b/test/slo_logoutrequest_test.rb index f5777270e..391a843fc 100644 --- a/test/slo_logoutrequest_test.rb +++ b/test/slo_logoutrequest_test.rb @@ -29,7 +29,7 @@ class RubySamlTest < Minitest::Test describe "#is_valid?" do it "return false when logout request is initialized with blank data" do logout_request_blank = RubySaml::SloLogoutrequest.new('') - assert !logout_request_blank.is_valid? + refute logout_request_blank.is_valid? assert_includes logout_request_blank.errors, 'Blank logout request' end @@ -40,9 +40,9 @@ class RubySamlTest < Minitest::Test end it "should be idempotent when the logout request is initialized with invalid data" do - assert !invalid_logout_request.is_valid? + refute invalid_logout_request.is_valid? assert_equal ['Invalid SAML Logout Request. Not match the saml-schema-protocol-2.0.xsd'], invalid_logout_request.errors - assert !invalid_logout_request.is_valid? + refute invalid_logout_request.is_valid? assert_equal ['Invalid SAML Logout Request. Not match the saml-schema-protocol-2.0.xsd'], invalid_logout_request.errors end @@ -72,7 +72,7 @@ class RubySamlTest < Minitest::Test logout_request_sign_test.settings = settings collect_errors = true - assert !logout_request_sign_test.is_valid?(collect_errors) + refute logout_request_sign_test.is_valid?(collect_errors) assert_includes logout_request_sign_test.errors, "Invalid Signature on Logout Request" assert_includes logout_request_sign_test.errors, "Doesn't match the issuer, expected: , but was: " end @@ -162,7 +162,7 @@ class RubySamlTest < Minitest::Test it "return false when there is an invalid ID in the logout request" do logout_request_blank = RubySaml::SloLogoutrequest.new('') - assert !logout_request_blank.send(:validate_id) + refute logout_request_blank.send(:validate_id) assert_includes logout_request_blank.errors, "Missing ID attribute on Logout Request" end end @@ -174,7 +174,7 @@ class RubySamlTest < Minitest::Test it "return false when the logout request is not SAML 2.0 Version" do logout_request_blank = RubySaml::SloLogoutrequest.new('') - assert !logout_request_blank.send(:validate_version) + refute logout_request_blank.send(:validate_version) assert_includes logout_request_blank.errors, "Unsupported SAML version" end end @@ -194,7 +194,7 @@ class RubySamlTest < Minitest::Test it "return false when the logout request has an invalid NotOnOrAfter" do Timecop.freeze Time.parse('2014-07-17T01:01:49Z') do logout_request.document.root.attributes['NotOnOrAfter'] = '2014-07-17T01:01:48Z' - assert !logout_request.send(:validate_not_on_or_after) + refute logout_request.send(:validate_not_on_or_after) assert_match(/Current time is on or after NotOnOrAfter/, logout_request.errors[0]) end end @@ -219,13 +219,13 @@ class RubySamlTest < Minitest::Test # The NotBefore condition in the document is 2011-06-1418:31:01.516Z Timecop.freeze(Time.parse("2011-06-14T18:31:02Z")) do logout_request.options[:allowed_clock_drift] = 0.483 - assert !logout_request.send(:validate_not_on_or_after) + refute logout_request.send(:validate_not_on_or_after) logout_request.options[:allowed_clock_drift] = java ? 0.485 : 0.484 assert logout_request.send(:validate_not_on_or_after) logout_request.options[:allowed_clock_drift] = '0.483' - assert !logout_request.send(:validate_not_on_or_after) + refute logout_request.send(:validate_not_on_or_after) logout_request.options[:allowed_clock_drift] = java ? '0.485' : '0.484' assert logout_request.send(:validate_not_on_or_after) @@ -244,7 +244,7 @@ class RubySamlTest < Minitest::Test it "return false when invalid logout request xml" do logout_request_blank = RubySaml::SloLogoutrequest.new('') logout_request_blank.soft = true - assert !logout_request_blank.send(:validate_request_state) + refute logout_request_blank.send(:validate_request_state) assert_includes logout_request_blank.errors, "Blank logout request" end @@ -264,7 +264,7 @@ class RubySamlTest < Minitest::Test end it "return false when encountering a Logout Request bad formatted" do - assert !invalid_logout_request.send(:validate_structure) + refute invalid_logout_request.send(:validate_structure) assert_includes invalid_logout_request.errors, "Invalid SAML Logout Request. Not match the saml-schema-protocol-2.0.xsd" end @@ -284,7 +284,7 @@ class RubySamlTest < Minitest::Test it "return false when the issuer of the Logout Request does not match the IdP entityId" do logout_request.settings.idp_entity_id = 'http://idp.example.com/invalid' - assert !logout_request.send(:validate_issuer) + refute logout_request.send(:validate_issuer) assert_includes logout_request.errors, "Doesn't match the issuer, expected: <#{logout_request.settings.idp_entity_id}>, but was: " end @@ -297,263 +297,259 @@ class RubySamlTest < Minitest::Test end end - describe "#validate_signature" do - before do - settings.security[:logout_requests_signed] = true - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - settings.idp_cert = ruby_saml_cert_text - end + each_signature_algorithm do |idp_key_algo, idp_hash_algo| + describe "#validate_signature" do + before do + @cert, @pkey = CertificateHelper.generate_pair(idp_key_algo) + settings.idp_cert = @cert.to_pem + + # These SP settings are added in order to create dummy params which + # have the correct IdP signature. They do NOT normally affect IdP logic. + settings.certificate = @cert.to_pem + settings.private_key = @pkey.to_pem + settings.security[:logout_requests_signed] = true + settings.security[:signature_method] = signature_method(idp_key_algo, idp_hash_algo) + end - it "return true when no idp_cert is provided and option :relax_signature_validation is present" do - settings.idp_cert = nil - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - options[:relax_signature_validation] = true - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - logout_request_sign_test.settings = settings - assert logout_request_sign_test.send(:validate_signature) - end + it "return true when no idp_cert is provided and option :relax_signature_validation is present" do + settings.idp_cert = nil + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + options[:relax_signature_validation] = true + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings + + assert logout_request_sign_test.send(:validate_signature) + end - it "return false when no idp_cert is provided and no option :relax_signature_validation is present" do - settings.idp_cert = nil - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - logout_request_sign_test.settings = settings - assert !logout_request_sign_test.send(:validate_signature) - end + it "return false when no idp_cert is provided and no option :relax_signature_validation is present" do + settings.idp_cert = nil + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings - it "return true when valid RSA_SHA1 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - logout_request_sign_test.settings = settings - assert logout_request_sign_test.send(:validate_signature) - end + refute logout_request_sign_test.send(:validate_signature) + end - it "return true when valid RSA_SHA256 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - options = {} - options[:get_params] = params - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - params['RelayState'] = params[:RelayState] - logout_request_sign_test.settings = settings - assert logout_request_sign_test.send(:validate_signature) - end + it "return true when valid RSA_SHA1 Signature" do + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings - it "return false when invalid RSA_SHA1 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = 'http://invalid.example.com' - params[:RelayState] = params['RelayState'] - options = {} - options[:get_params] = params + assert logout_request_sign_test.send(:validate_signature) + end - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - logout_request_sign_test.settings = settings - assert !logout_request_sign_test.send(:validate_signature) - end + it "return false when invalid signature" do + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = 'http://invalid.example.com' + params[:RelayState] = params['RelayState'] + options = {} + options[:get_params] = params + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings - it "raise when invalid RSA_SHA1 Signature" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.soft = false - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = 'http://invalid.example.com' - params[:RelayState] = params['RelayState'] - options = {} - options[:get_params] = params - options[:settings] = settings + refute logout_request_sign_test.send(:validate_signature) + end - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do - logout_request_sign_test.send(:validate_signature) + it "raise when invalid signature" do + settings.soft = false + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = 'http://invalid.example.com' + params[:RelayState] = params['RelayState'] + options = {} + options[:get_params] = params + options[:settings] = settings + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + + assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do + logout_request_sign_test.send(:validate_signature) + end end - end - it "raise when get_params encoding differs from what this library generates" do - # Use Logoutrequest only to build the SAMLRequest parameter. - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.soft = false - params = RubySaml::Logoutrequest.new.create_params(settings, "RelayState" => "http://example.com") - # Assemble query string. - query = RubySaml::Utils.build_query( - :type => 'SAMLRequest', - :data => params['SAMLRequest'], - :relay_state => params['RelayState'], - :sig_alg => params['SigAlg'] - ) - # Modify the query string so that it encodes the same values, - # but with different percent-encoding. Sanity-check that they - # really are equialent before moving on. - original_query = query.dup - query.gsub!("example", "ex%61mple") - refute_equal(query, original_query) - assert_equal(CGI.unescape(query), CGI.unescape(original_query)) - # Make normalised signature based on our modified params. - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) - signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) - params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") - # Construct SloLogoutrequest and ask it to validate the signature. - # It will do it incorrectly, because it will compute it based on re-encoded - # query parameters, rather than their original encodings. - options = {} - options[:get_params] = params - options[:settings] = settings - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do - logout_request_sign_test.send(:validate_signature) + it "raise when get_params encoding differs from what this library generates" do + settings.soft = false + params = RubySaml::Logoutrequest.new.create_params(settings, "RelayState" => "http://example.com") + query = RubySaml::Utils.build_query( + :type => 'SAMLRequest', + :data => params['SAMLRequest'], + :relay_state => params['RelayState'], + :sig_alg => params['SigAlg'] + ) + + # Modify the query string so that it encodes the same values, + # but with different percent-encoding. Sanity-check that they + # really are equialent before moving on. + original_query = query.dup + query.gsub!("example", "ex%61mple") + + refute_equal(query, original_query) + assert_equal(CGI.unescape(query), CGI.unescape(original_query)) + + # Make normalised signature based on our modified params. + sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method) + signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) + + params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") + # Construct SloLogoutrequest and ask it to validate the signature. + # It will do it incorrectly, because it will compute it based on re-encoded + # query parameters, rather than their original encodings. + options = {} + options[:get_params] = params + options[:settings] = settings + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + + assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do + logout_request_sign_test.send(:validate_signature) + end end - end - it "return true even if raw_get_params encoding differs from what this library generates" do - # Use Logoutrequest only to build the SAMLRequest parameter. - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.soft = false - params = RubySaml::Logoutrequest.new.create_params(settings, "RelayState" => "http://example.com") - # Assemble query string. - query = RubySaml::Utils.build_query( - :type => 'SAMLRequest', - :data => params['SAMLRequest'], - :relay_state => params['RelayState'], - :sig_alg => params['SigAlg'] - ) - # Modify the query string so that it encodes the same values, - # but with different percent-encoding. Sanity-check that they - # really are equialent before moving on. - original_query = query.dup - query.gsub!("example", "ex%61mple") - refute_equal(query, original_query) - assert_equal(CGI.unescape(query), CGI.unescape(original_query)) - # Make normalised signature based on our modified params. - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method]) - signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) - params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") - # Construct SloLogoutrequest and ask it to validate the signature. - # Provide the altered parameter in its raw URI-encoded form, - # so that we don't have to guess the value that contributed to the signature. - options = {} - options[:get_params] = params - options[:get_params].delete("RelayState") - options[:raw_get_params] = { - "RelayState" => "http%3A%2F%2Fex%61mple.com", - } - options[:settings] = settings - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - assert logout_request_sign_test.send(:validate_signature) - end - - it "handles Azure AD downcased request encoding" do - # Use Logoutrequest only to build the SAMLRequest parameter. - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - settings.soft = false - - # Creating the query manually to tweak it later instead of using - # RubySaml::Utils.build_query - request_doc = RubySaml::Logoutrequest.new.create_logout_request_xml_doc(settings) - request = Zlib::Deflate.deflate(request_doc.to_s, 9)[2..-5] - base64_request = Base64.encode64(request).gsub(/\n/, "") - # The original request received from Azure AD comes with downcased - # encoded characters, like %2f instead of %2F, and the signature they - # send is based on this base64 request. - params = { - 'SAMLRequest' => downcased_escape(base64_request), - 'SigAlg' => downcased_escape(settings.security[:signature_method]), - } - # Assemble query string. - query = "SAMLRequest=#{params['SAMLRequest']}&SigAlg=#{params['SigAlg']}" - # Make normalised signature based on our modified params. - sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm( - settings.security[:signature_method] - ) - signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) - params['Signature'] = downcased_escape(Base64.encode64(signature).gsub(/\n/, "")) - - # Then parameters are usually unescaped, like we manage them in rails - params = params.map { |k, v| [k, CGI.unescape(v)] }.to_h - # Construct SloLogoutrequest and ask it to validate the signature. - # It will fail because the signature is based on the downcased request - logout_request_downcased_test = RubySaml::SloLogoutrequest.new( - params['SAMLRequest'], get_params: params, settings: settings, - ) - assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do - logout_request_downcased_test.send(:validate_signature) + it "return true even if raw_get_params encoding differs from what this library generates" do + settings.soft = false + params = RubySaml::Logoutrequest.new.create_params(settings, "RelayState" => "http://example.com") + query = RubySaml::Utils.build_query( + :type => 'SAMLRequest', + :data => params['SAMLRequest'], + :relay_state => params['RelayState'], + :sig_alg => params['SigAlg'] + ) + + # Modify the query string so that it encodes the same values, + # but with different percent-encoding. Sanity-check that they + # really are equialent before moving on. + original_query = query.dup + query.gsub!("example", "ex%61mple") + + refute_equal(query, original_query) + assert_equal(CGI.unescape(query), CGI.unescape(original_query)) + + # Make normalised signature based on our modified params. + sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method) + signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) + params['Signature'] = Base64.encode64(signature).gsub(/\n/, "") + + # Construct SloLogoutrequest and ask it to validate the signature. + # Provide the altered parameter in its raw URI-encoded form, + # so that we don't have to guess the value that contributed to the signature. + options = {} + options[:get_params] = params + options[:get_params].delete("RelayState") + options[:raw_get_params] = { "RelayState" => "http%3A%2F%2Fex%61mple.com" } + options[:settings] = settings + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + + assert logout_request_sign_test.send(:validate_signature) end - # For this case, the parameters will be forced to be downcased after - # being escaped with :lowercase_url_encoding security option - settings.security[:lowercase_url_encoding] = true - logout_request_force_downcasing_test = RubySaml::SloLogoutrequest.new( - params['SAMLRequest'], get_params: params, settings: settings - ) - assert logout_request_force_downcasing_test.send(:validate_signature) - end - end + it "handles Azure AD downcased request encoding" do + settings.soft = false + + # Creating the query manually to tweak it later instead of using + # RubySaml::Utils.build_query + request_doc = RubySaml::Logoutrequest.new.create_logout_request_xml_doc(settings) + request = Zlib::Deflate.deflate(request_doc.to_s, 9)[2..-5] + base64_request = Base64.encode64(request).gsub(/\n/, "") + # The original request received from Azure AD comes with downcased + # encoded characters, like %2f instead of %2F, and the signature they + # send is based on this base64 request. + params = { + 'SAMLRequest' => downcased_escape(base64_request), + 'SigAlg' => downcased_escape(settings.get_sp_signature_method), + } + query = "SAMLRequest=#{params['SAMLRequest']}&SigAlg=#{params['SigAlg']}" + # Make normalised signature based on our modified params. + sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method) + signature = settings.get_sp_signing_key.sign(sign_algorithm.new, query) + params['Signature'] = downcased_escape(Base64.encode64(signature).gsub(/\n/, "")) + + # Then parameters are usually unescaped, like we manage them in rails + params = params.map { |k, v| [k, CGI.unescape(v)] }.to_h + # Construct SloLogoutrequest and ask it to validate the signature. + # It will fail because the signature is based on the downcased request + logout_request_downcased_test = RubySaml::SloLogoutrequest.new( + params['SAMLRequest'], get_params: params, settings: settings, + ) + assert_raises(RubySaml::ValidationError, "Invalid Signature on Logout Request") do + logout_request_downcased_test.send(:validate_signature) + end - describe "#validate_signature with multiple idp certs" do - before do - settings.certificate = ruby_saml_cert_text - settings.private_key = ruby_saml_key_text - settings.idp_cert = nil - settings.security[:logout_requests_signed] = true - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - end + # For this case, the parameters will be forced to be downcased after + # being escaped with :lowercase_url_encoding security option + settings.security[:lowercase_url_encoding] = true + logout_request_force_downcasing_test = RubySaml::SloLogoutrequest.new( + params['SAMLRequest'], get_params: params, settings: settings + ) + assert logout_request_force_downcasing_test.send(:validate_signature) + end - it "return true when at least a idp_cert is valid" do - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - settings.idp_cert_multi = { - :signing => [ruby_saml_cert_text2, ruby_saml_cert_text], - :encryption => [] - } - logout_request_sign_test.settings = settings - assert logout_request_sign_test.send(:validate_signature) - end + describe "with multiple idp certs" do + before do + settings.idp_cert = nil + end - it "return false when cert expired and check_idp_cert_expiration expired" do - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - settings.security[:check_idp_cert_expiration] = true - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - settings.idp_cert = nil - settings.idp_cert_multi = { - :signing => [ruby_saml_cert_text], - :encryption => [] - } - logout_request_sign_test.settings = settings - assert !logout_request_sign_test.send(:validate_signature) - assert_includes logout_request_sign_test.errors, "IdP x509 certificate expired" - end + it "return true when at least a idp_cert is valid" do + settings.idp_cert_multi = { + :signing => [@cert.to_pem, ruby_saml_cert_text], + :encryption => [] + } + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings + + assert logout_request_sign_test.send(:validate_signature) + end - it "return false when none cert on idp_cert_multi is valid" do - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') - params['RelayState'] = params[:RelayState] - options = {} - options[:get_params] = params - logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) - settings.idp_cert_fingerprint = ruby_saml_cert_fingerprint - settings.idp_cert_multi = { - :signing => [ruby_saml_cert_text2, ruby_saml_cert_text2], - :encryption => [] - } - logout_request_sign_test.settings = settings - assert !logout_request_sign_test.send(:validate_signature) - assert_includes logout_request_sign_test.errors, "Invalid Signature on Logout Request" + it "return false when cert expired and check_idp_cert_expiration expired" do + settings.security[:check_idp_cert_expiration] = true + settings.idp_cert = nil + settings.idp_cert_multi = { + :signing => [ruby_saml_cert_text], + :encryption => [] + } + + # These SP settings are for dummy params generation. + settings.certificate = ruby_saml_cert_text + settings.private_key = ruby_saml_key_text + + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings + + refute logout_request_sign_test.send(:validate_signature) + assert_includes logout_request_sign_test.errors, "IdP x509 certificate expired" + end + + it "return false when none cert on idp_cert_multi is valid" do + settings.idp_cert_fingerprint = ruby_saml_cert_fingerprint + settings.idp_cert_multi = { + :signing => [ruby_saml_cert_text2, ruby_saml_cert_text2], + :encryption => [] + } + + params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params['RelayState'] = params[:RelayState] + options = {} + options[:get_params] = params + logout_request_sign_test = RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options) + logout_request_sign_test.settings = settings + + refute logout_request_sign_test.send(:validate_signature) + assert_includes logout_request_sign_test.errors, "Invalid Signature on Logout Request" + end + end end end end diff --git a/test/slo_logoutresponse_test.rb b/test/slo_logoutresponse_test.rb index 421017f39..1636c3030 100644 --- a/test/slo_logoutresponse_test.rb +++ b/test/slo_logoutresponse_test.rb @@ -17,7 +17,7 @@ class SloLogoutresponseTest < Minitest::Test logout_request.settings = settings end - it "create the deflated SAMLResponse URL parameter" do + it "creates the deflated SAMLResponse URL parameter" do unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id) assert_match(/^http:\/\/unauth\.com\/logout\?SAMLResponse=/, unauth_url) @@ -97,261 +97,243 @@ class SloLogoutresponseTest < Minitest::Test end end - describe "signing with HTTP-POST binding" do - before do - settings.idp_sso_service_binding = :redirect - settings.idp_slo_service_binding = :post - settings.security[:logout_responses_signed] = true - end - - it "doesn't sign through create_xml_document" do - unauth_res = RubySaml::SloLogoutresponse.new - inflated = unauth_res.create_xml_document(settings).to_s - - refute_match %r[([a-zA-Z0-9/+=]+)], inflated - refute_match %r[], inflated - refute_match %r[], inflated + describe "#manipulate response_id" do + it "be able to modify the response id" do + logoutresponse = RubySaml::SloLogoutresponse.new + response_id = logoutresponse.response_id + assert_equal response_id, logoutresponse.uuid + logoutresponse.uuid = "new_uuid" + assert_equal logoutresponse.response_id, logoutresponse.uuid + assert_equal "new_uuid", logoutresponse.response_id end + end - it "sign unsigned request" do - unauth_res = RubySaml::SloLogoutresponse.new - unauth_res_doc = unauth_res.create_xml_document(settings) - inflated = unauth_res_doc.to_s - - refute_match %r[([a-zA-Z0-9/+=]+)], inflated - refute_match %r[], inflated - refute_match %r[], inflated - - inflated = unauth_res.sign_document(unauth_res_doc, settings).to_s - - assert_match %r[([a-zA-Z0-9/+=]+)], inflated - assert_match %r[], inflated - assert_match %r[], inflated - end + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe 'signing with HTTP-POST binding' do + before do + settings.idp_sso_service_binding = :redirect + settings.idp_slo_service_binding = :post + settings.security[:logout_responses_signed] = true + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo) + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end - it "signs through create_logout_response_xml_doc" do - unauth_res = RubySaml::SloLogoutresponse.new - inflated = unauth_res.create_logout_response_xml_doc(settings).to_s + it "doesn't sign through create_xml_document" do + unauth_res = RubySaml::SloLogoutresponse.new + inflated = unauth_res.create_xml_document(settings).to_s - assert_match %r[([a-zA-Z0-9/+=]+)], inflated - assert_match %r[], inflated - assert_match %r[], inflated - end + refute_match(/([a-zA-Z0-9/+=]+)], response_xml - assert_match(//, response_xml) - assert_match(//, response_xml) - end + inflated = unauth_res.sign_document(unauth_res_doc, settings).to_s - it "create a signed logout response with SHA384 digest and signature method RSA_SHA512" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA512 - settings.security[:digest_method] = RubySaml::XML::Document::SHA384 - logout_request.settings = settings + assert_match(signature_value_matcher, inflated) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), inflated) + assert_match(digest_method_matcher(sp_hash_algo), inflated) + end - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + it "signs through create_logout_response_xml_doc" do + unauth_res = RubySaml::SloLogoutresponse.new + inflated = unauth_res.create_logout_response_xml_doc(settings).to_s - response_xml = Base64.decode64(params["SAMLResponse"]) - assert_match %r[([a-zA-Z0-9/+=]+)], response_xml - assert_match(//, response_xml) - assert_match(//, response_xml) - end + assert_match(signature_value_matcher, inflated) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), inflated) + assert_match(digest_method_matcher(sp_hash_algo), inflated) + end - it "create a signed logout response with SHA512 digest and signature method RSA_SHA384" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA384 - settings.security[:digest_method] = RubySaml::XML::Document::SHA512 - logout_request.settings = settings + it "creates a signed logout response" do + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + response_xml = Base64.decode64(params["SAMLResponse"]) - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + assert_match(signature_value_matcher, response_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), response_xml) + assert_match(digest_method_matcher(sp_hash_algo), response_xml) + end - response_xml = Base64.decode64(params["SAMLResponse"]) - assert_match %r[([a-zA-Z0-9/+=]+)], response_xml - assert_match(//, response_xml) - assert_match(//, response_xml) - end + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + response_xml = Base64.decode64(params["SAMLResponse"]) + + assert_match(signature_value_matcher, response_xml) + assert_match(signature_method_matcher(sp_key_algo, :sha256), response_xml) + assert_match(digest_method_matcher(sp_hash_algo), response_xml) + end + + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + response_xml = Base64.decode64(params["SAMLResponse"]) + + assert_match(signature_value_matcher, response_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), response_xml) + assert_match(digest_method_matcher(:sha256), response_xml) + end + end - it "create a signed logout response using the first certificate and key" do - settings.certificate = nil - settings.private_key = nil - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - logout_request.settings = settings - - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") - - response_xml = Base64.decode64(params["SAMLResponse"]) - assert_match %r[([a-zA-Z0-9/+=]+)], response_xml - assert_match(//, response_xml) - assert_match(//, response_xml) - end + it "creates a signed logout response using the first certificate and key" do + settings.certificate = nil + settings.private_key = nil + settings.sp_cert_multi = { + signing: [ + CertificateHelper.generate_pem_hash(sp_key_algo), + CertificateHelper.generate_pem_hash + ] + } + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + response_xml = Base64.decode64(params["SAMLResponse"]) + + assert_match(signature_value_matcher, response_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), response_xml) + assert_match(digest_method_matcher(sp_hash_algo), response_xml) + end - it "create a signed logout response using the first valid certificate and key when :check_sp_cert_expiration is true" do - settings.certificate = nil - settings.private_key = nil - settings.security[:check_sp_cert_expiration] = true - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - logout_request.settings = settings - - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") - - response_xml = Base64.decode64(params["SAMLResponse"]) - assert_match %r[([a-zA-Z0-9/+=]+)], response_xml - assert_match(//, response_xml) - assert_match(//, response_xml) - end + it "creates a signed logout response using the first valid certificate and key when :check_sp_cert_expiration is true" do + settings.certificate = nil + settings.private_key = nil + settings.security[:check_sp_cert_expiration] = true + settings.sp_cert_multi = { + signing: [ + CertificateHelper.generate_pem_hash(sp_key_algo), + CertificateHelper.generate_pem_hash + ] + } + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + response_xml = Base64.decode64(params["SAMLResponse"]) + + assert_match(signature_value_matcher, response_xml) + assert_match(signature_method_matcher(sp_key_algo, sp_hash_algo), response_xml) + assert_match(digest_method_matcher(sp_hash_algo), response_xml) + end - it "raises error when no valid certs and :check_sp_cert_expiration is true" do - settings.security[:check_sp_cert_expiration] = true - logout_request.settings = settings + it "raises error when no valid certs and :check_sp_cert_expiration is true" do + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo, not_after: Time.now - 60) + settings.security[:check_sp_cert_expiration] = true + logout_request.settings = settings - assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do + RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message") + end end end end - describe "signing with HTTP-Redirect binding" do - let(:cert) { OpenSSL::X509::Certificate.new(ruby_saml_cert_text) } - - before do - settings.idp_sso_service_binding = :post - settings.idp_slo_service_binding = :redirect - settings.security[:logout_responses_signed] = true - end - - it "create a signature parameter with RSA_SHA1 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') - assert params['SAMLResponse'] - assert params[:RelayState] - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA1 - - query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA1 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end - - it "create a signature parameter with RSA_SHA256 /SHA256 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 - - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') - assert params['SAMLResponse'] - assert params[:RelayState] - assert params['Signature'] - - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA256 - - query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA256 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end - - it "create a signature parameter with RSA_SHA384 / SHA384 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA384 - - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') - assert params['SAMLResponse'] - assert params[:RelayState] - assert params['Signature'] - - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA384 - - query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA384 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + each_signature_algorithm do |sp_key_algo, sp_hash_algo| + describe 'signing with HTTP-Redirect binding' do + before do + settings.idp_sso_service_binding = :post + settings.idp_slo_service_binding = :redirect + settings.security[:logout_responses_signed] = true + @cert, @pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.certificate, settings.private_key = [@cert, @pkey].map(&:to_pem) + settings.security[:signature_method] = signature_method(sp_key_algo, sp_hash_algo) + settings.security[:digest_method] = digest_method(sp_hash_algo) + end - it "create a signature parameter with RSA_SHA512 / SHA512 and validate it" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA512 + it "creates a signature parameter and validate it" do + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') - assert params['SAMLResponse'] - assert params[:RelayState] - assert params['Signature'] + assert params['SAMLResponse'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA512 + query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA512 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + unless sp_hash_algo == :sha256 + it 'using mixed signature and digest methods (signature SHA256)' do + # RSA is ignored here; only the hash sp_key_algo is used + settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA256 + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + + assert params['SAMLResponse'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha256) + + query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end + + it 'using mixed signature and digest methods (digest SHA256)' do + settings.security[:digest_method] = RubySaml::XML::Document::SHA256 + logout_request.settings = settings + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + + assert params['SAMLResponse'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) + + query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert @cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end + end - it "create a signature parameter using the first certificate and key" do - settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 - settings.certificate = nil - settings.private_key = nil - settings.sp_cert_multi = { - signing: [ - { certificate: ruby_saml_cert_text, private_key: ruby_saml_key_text }, - CertificateHelper.generate_pair_hash - ] - } - - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') - assert params['SAMLResponse'] - assert params[:RelayState] - assert params['Signature'] - assert_equal params['SigAlg'], RubySaml::XML::Document::RSA_SHA1 - - query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" - query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - - signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(params['SigAlg']) - assert_equal signature_algorithm, OpenSSL::Digest::SHA1 - assert cert.public_key.verify(signature_algorithm.new, Base64.decode64(params['Signature']), query_string) - end + it "creates a signature parameter using the first certificate and key" do + settings.certificate = nil + settings.private_key = nil + cert, pkey = CertificateHelper.generate_pair(sp_key_algo) + settings.sp_cert_multi = { + signing: [ + { certificate: cert.to_pem, private_key: pkey.to_pem }, + CertificateHelper.generate_pem_hash + ] + } + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + + assert params['SAMLResponse'] + assert params[:RelayState] + assert params['Signature'] + assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) + + query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" + query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" + + assert cert.public_key.verify(RubySaml::XML::Crypto.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) + end - it "raises error when no valid certs and :check_sp_cert_expiration is true" do - settings.security[:check_sp_cert_expiration] = true + it "raises error when no valid certs and :check_sp_cert_expiration is true" do + settings.certificate, settings.private_key = CertificateHelper.generate_pem_array(sp_key_algo, not_after: Time.now - 60) + settings.security[:check_sp_cert_expiration] = true - assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do + RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + end end end end - - describe "#manipulate response_id" do - it "be able to modify the response id" do - logoutresponse = RubySaml::SloLogoutresponse.new - response_id = logoutresponse.response_id - assert_equal response_id, logoutresponse.uuid - logoutresponse.uuid = "new_uuid" - assert_equal logoutresponse.response_id, logoutresponse.uuid - assert_equal "new_uuid", logoutresponse.response_id - end - end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3a4bb1ca3..e262044a7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -46,6 +46,73 @@ def fixture(document, base64 = true) end end + def self.each_key_algorithm(&block) + key_algorithms.each do |algorithm| + describe "#{algorithm.upcase} algorithm" do + block.call(algorithm) + end + end + end + + def self.each_signature_algorithm(&block) + key_algorithms.each do |key_algorithm| + hash_algorithms(key_algorithm).each do |hash_algorithm| + describe "#{key_algorithm.upcase} #{hash_algorithm.upcase} algorithm" do + block.call(key_algorithm, hash_algorithm) + end + end + end + end + + def self.key_algorithms + algorithms = %i[rsa dsa] + + # JRuby does not support ECDSA due to a known issue: + # https://github.com/jruby/jruby-openssl/issues/257 + algorithms << :ecdsa unless jruby? + algorithms + end + + def self.hash_algorithms(key_algorithm = :rsa) + if key_algorithm == :dsa + jruby? ? %i[sha256] : %i[sha1 sha256] + else + %i[sha1 sha224 sha256 sha384 sha512] + end + end + + def expected_key_class(algorithm) + case algorithm + when :dsa + OpenSSL::PKey::DSA + when :ec, :ecdsa + OpenSSL::PKey::EC + else + OpenSSL::PKey::RSA + end + end + + def signature_method(algorithm, digest = :sha256) + algorithm = :ecdsa if algorithm == :ec + RubySaml::XML::Crypto.const_get("#{algorithm}_#{digest}".upcase) + end + + def digest_method(digest = :sha256) + RubySaml::XML::Crypto.const_get(digest.upcase) + end + + def signature_value_matcher + %r{([a-zA-Z0-9/+]+=?=?)} + end + + def signature_method_matcher(algorithm = :rsa, digest = :sha256) + %r{} + end + + def digest_method_matcher(digest = :sha256) + %r{} + end + def read_response(response) File.read(File.join(File.dirname(__FILE__), "responses", response)) end diff --git a/test/utils_test.rb b/test/utils_test.rb index c3d641213..522933ffe 100644 --- a/test/utils_test.rb +++ b/test/utils_test.rb @@ -40,8 +40,8 @@ def result(duration, reference = 0) end describe ".format_cert" do - let(:formatted_certificate) {read_certificate("formatted_certificate")} - let(:formatted_chained_certificate) {read_certificate("formatted_chained_certificate")} + let(:formatted_certificate) { read_certificate("formatted_certificate") } + let(:formatted_chained_certificate) { read_certificate("formatted_chained_certificate") } it "returns empty string when the cert is an empty string" do cert = '' @@ -148,9 +148,12 @@ def result(duration, reference = 0) end describe '.build_cert_object' do - it 'returns a certificate object for valid certificate string' do - cert_object = RubySaml::Utils.build_cert_object(ruby_saml_cert_text) - assert_instance_of OpenSSL::X509::Certificate, cert_object + each_key_algorithm do |algorithm| + it 'returns a certificate object for valid certificate string' do + pem = CertificateHelper.generate_cert(algorithm).to_pem + cert_object = RubySaml::Utils.build_cert_object(pem) + assert_instance_of OpenSSL::X509::Certificate, cert_object + end end it 'returns nil for nil certificate string' do @@ -169,9 +172,12 @@ def result(duration, reference = 0) end describe '.build_private_key_object' do - it 'returns a private key object for valid private key string' do - private_key_object = RubySaml::Utils.build_private_key_object(ruby_saml_key_text) - assert_instance_of OpenSSL::PKey::RSA, private_key_object + each_key_algorithm do |algorithm| + it 'returns a private key object for valid private key string' do + pem = CertificateHelper.generate_private_key(algorithm).to_pem + private_key_object = RubySaml::Utils.build_private_key_object(pem) + assert_instance_of(expected_key_class(algorithm), private_key_object) + end end it 'returns nil for nil private key string' do @@ -344,8 +350,8 @@ def result(duration, reference = 0) describe '.decrypt_multi' do let(:private_key) { ruby_saml_key } - let(:invalid_key1) { CertificateHelper.generate_key } - let(:invalid_key2) { CertificateHelper.generate_key } + let(:invalid_key1) { CertificateHelper.generate_private_key } + let(:invalid_key2) { CertificateHelper.generate_private_key } let(:settings) { RubySaml::Settings.new(:private_key => private_key.to_pem) } let(:response) { RubySaml::Response.new(signed_message_encrypted_unsigned_assertion, :settings => settings) } let(:encrypted) do @@ -357,11 +363,11 @@ def result(duration, reference = 0) end it 'successfully decrypts with the first private key' do - assert_match /\A RubySaml::XML::Document::SHA384) end it "validate using SHA512" do @@ -191,7 +191,7 @@ class XmlTest < Minitest::Test end end - describe "XmlSecurity::SignedDocument" do + describe "RubySaml::XML::SignedDocument" do describe "#extract_inclusive_namespaces" do it "support explicit namespace resolution for exclusive canonicalization" do @@ -417,7 +417,7 @@ class XmlTest < Minitest::Test assert document.validate_document_with_cert(idp_cert), 'Document should be valid' end end - + describe 'when response has no cert but you have local cert' do let(:document) { RubySaml::Response.new(response_document_valid_signed_without_x509certificate).document } let(:idp_cert) { OpenSSL::X509::Certificate.new(ruby_saml_cert_text) }