From 626140ba140372a891e4c89bfaec54567fc2eb9a Mon Sep 17 00:00:00 2001 From: Johnny Shields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 11 Jul 2024 02:05:53 +0900 Subject: [PATCH 1/7] Remove stray "include REXML" + fix REXML deprecation (#706) * Remove stray "include REXML" * Change deprecated REXML::Document.entity_expansion_limit to REXML::Security.entity_expansion_limit --- .rubocop_todo.yml | 21 ++++++++------------- lib/ruby_saml/authrequest.rb | 1 - lib/ruby_saml/idp_metadata_parser.rb | 1 - lib/ruby_saml/saml_message.rb | 1 - lib/ruby_saml/xml/base_document.rb | 2 +- 5 files changed, 9 insertions(+), 17 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 564b4825..f8a166e5 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2024-07-09 08:57:21 UTC using RuboCop version 1.64.1. +# on 2024-07-10 16:10:44 UTC using RuboCop version 1.64.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -20,7 +20,7 @@ Layout/EmptyLineAfterGuardClause: - 'lib/ruby_saml/slo_logoutrequest.rb' - 'lib/ruby_saml/slo_logoutresponse.rb' -# Offense count: 6 +# Offense count: 7 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only @@ -31,6 +31,7 @@ Layout/EmptyLinesAroundClassBody: - 'lib/ruby_saml/logoutrequest.rb' - 'lib/ruby_saml/logoutresponse.rb' - 'lib/ruby_saml/metadata.rb' + - 'lib/ruby_saml/saml_message.rb' - 'lib/ruby_saml/slo_logoutresponse.rb' # Offense count: 1 @@ -39,7 +40,7 @@ Layout/EmptyLinesAroundMethodBody: Exclude: - 'lib/ruby_saml/slo_logoutrequest.rb' -# Offense count: 11 +# Offense count: 13 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines @@ -47,6 +48,8 @@ Layout/EmptyLinesAroundModuleBody: Exclude: - 'lib/ruby_saml/attribute_service.rb' - 'lib/ruby_saml/attributes.rb' + - 'lib/ruby_saml/authrequest.rb' + - 'lib/ruby_saml/idp_metadata_parser.rb' - 'lib/ruby_saml/logoutrequest.rb' - 'lib/ruby_saml/logoutresponse.rb' - 'lib/ruby_saml/metadata.rb' @@ -294,7 +297,7 @@ Performance/StringReplacement: - 'lib/ruby_saml/utils.rb' - 'lib/ruby_saml/xml/document.rb' -# Offense count: 52 +# Offense count: 48 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: separated, grouped @@ -414,14 +417,6 @@ Style/ModuleFunction: Exclude: - 'lib/ruby_saml/logging.rb' -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle, Autocorrect. -# SupportedStyles: module_function, extend_self, forbidden -Style/ModuleFunction: - Exclude: - - 'lib/ruby_saml/logging.rb' - # Offense count: 16 # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? @@ -514,7 +509,7 @@ Style/SymbolArray: Exclude: - 'lib/ruby_saml/settings.rb' -# Offense count: 95 +# Offense count: 92 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # URISchemes: http, https diff --git a/lib/ruby_saml/authrequest.rb b/lib/ruby_saml/authrequest.rb index a96ae94f..95a4433a 100644 --- a/lib/ruby_saml/authrequest.rb +++ b/lib/ruby_saml/authrequest.rb @@ -9,7 +9,6 @@ # Only supports SAML 2.0 module RubySaml - include REXML # SAML2 Authentication. AuthNRequest (SSO SP initiated, Builder) # diff --git a/lib/ruby_saml/idp_metadata_parser.rb b/lib/ruby_saml/idp_metadata_parser.rb index 364c1e2f..7bf1cf93 100644 --- a/lib/ruby_saml/idp_metadata_parser.rb +++ b/lib/ruby_saml/idp_metadata_parser.rb @@ -8,7 +8,6 @@ # Only supports SAML 2.0 module RubySaml - include REXML # Auxiliary class to retrieve and parse the Identity Provider Metadata # diff --git a/lib/ruby_saml/saml_message.rb b/lib/ruby_saml/saml_message.rb index 7d38ec90..b9dc997f 100644 --- a/lib/ruby_saml/saml_message.rb +++ b/lib/ruby_saml/saml_message.rb @@ -15,7 +15,6 @@ module RubySaml # SAML2 Message # class SamlMessage - include REXML ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" diff --git a/lib/ruby_saml/xml/base_document.rb b/lib/ruby_saml/xml/base_document.rb index a2918eab..8cfcce26 100644 --- a/lib/ruby_saml/xml/base_document.rb +++ b/lib/ruby_saml/xml/base_document.rb @@ -10,7 +10,7 @@ module RubySaml module XML class BaseDocument < REXML::Document - REXML::Document.entity_expansion_limit = 0 + REXML::Security.entity_expansion_limit = 0 C14N = 'http://www.w3.org/2001/10/xml-exc-c14n#' DSIG = 'http://www.w3.org/2000/09/xmldsig#' From a7b1f2ede2137333f61ef0d509274ecb7cf4c31e Mon Sep 17 00:00:00 2001 From: Sixto Martin Date: Tue, 10 Sep 2024 21:26:13 +0200 Subject: [PATCH 2/7] Use correct XPaths and resolve to correct elements. Block references that resolve to multiple nodes to prevent signature wrapping attacks --- lib/ruby_saml/xml/signed_document.rb | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/ruby_saml/xml/signed_document.rb b/lib/ruby_saml/xml/signed_document.rb index 444a062a..03a47608 100644 --- a/lib/ruby_saml/xml/signed_document.rb +++ b/lib/ruby_saml/xml/signed_document.rb @@ -129,17 +129,30 @@ def validate_signature(base64_cert, soft = true) canon_string = noko_signed_info_element.canonicalize(canon_algorithm) noko_sig_element.remove + # get signed info + signed_info_element = REXML::XPath.first( + sig_element, + "./ds:SignedInfo", + { "ds" => DSIG } + ) + # get inclusive namespaces inclusive_namespaces = extract_inclusive_namespaces # check digests - ref = REXML::XPath.first(sig_element, '//ds:Reference', {'ds'=>DSIG}) + ref = REXML::XPath.first(signed_info_element, "./ds:Reference", {"ds"=>DSIG}) - hashed_element = document.at_xpath('//*[@ID=$id]', nil, { 'id' => extract_signed_element_id }) + reference_nodes = document.xpath("//*[@ID=$id]", nil, { 'id' => extract_signed_element_id }) + + if reference_nodes.length > 1 # ensures no elements with same ID to prevent signature wrapping attack. + return append_error("Digest mismatch. Duplicated ID found", soft) + end + + hashed_element = reference_nodes[0] canon_algorithm = canon_algorithm REXML::XPath.first( - ref, - '//ds:CanonicalizationMethod', + signed_info_element, + './ds:CanonicalizationMethod', { 'ds' => DSIG } ) @@ -155,7 +168,7 @@ def validate_signature(base64_cert, soft = true) hash = digest_algorithm.digest(canon_hashed_element) encoded_digest_value = REXML::XPath.first( ref, - '//ds:DigestValue', + './ds:DigestValue', { 'ds' => DSIG } ) digest_value = Base64.decode64(RubySaml::Utils.element_text(encoded_digest_value)) @@ -181,7 +194,7 @@ def validate_signature(base64_cert, soft = true) def process_transforms(ref, canon_algorithm) transforms = REXML::XPath.match( ref, - '//ds:Transforms/ds:Transform', + './ds:Transforms/ds:Transform', { 'ds' => DSIG } ) From 3333f0ab5bc6a21c1eb1188bf97de5215877bf80 Mon Sep 17 00:00:00 2001 From: Johnny Shields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 30 Sep 2024 03:01:00 -0400 Subject: [PATCH 3/7] [READY] v2.0 - Consistently format cert/private key PEMs (#711) * Standardize how RubySaml reads and formats certificate and private_key PEM values, including the `RubySaml::Util#format_cert` and `#format_private_key` methods. Introduces new `RubySaml::PemFormatter` module. * Small change to regex --- .rubocop.yml | 3 + .rubocop_todo.yml | 44 +-- CHANGELOG.md | 1 + UPGRADING.md | 35 +- lib/ruby_saml.rb | 1 + lib/ruby_saml/pem_formatter.rb | 126 +++++++ lib/ruby_saml/utils.rb | 145 +++----- test/pem_formatter_test.rb | 649 +++++++++++++++++++++++++++++++++ test/response_test.rb | 17 +- test/slo_logoutrequest_test.rb | 2 +- test/test_helper.rb | 8 + test/utils_test.rb | 10 +- 12 files changed, 913 insertions(+), 128 deletions(-) create mode 100644 lib/ruby_saml/pem_formatter.rb create mode 100644 test/pem_formatter_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index c0296bdf..7a7f2e7e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,9 @@ AllCops: - 'tmp/**/*' - 'vendor/**/*' +Style/ModuleFunction: + EnforcedStyle: extend_self + Style/NumericPredicate: EnforcedStyle: comparison diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f8a166e5..e285db58 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2024-07-10 16:10:44 UTC using RuboCop version 1.64.1. +# on 2024-07-11 13:04:30 UTC using RuboCop version 1.64.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -127,7 +127,7 @@ Layout/SpaceAroundOperators: - 'lib/ruby_saml/xml/document.rb' - 'lib/ruby_saml/xml/signed_document.rb' -# Offense count: 5 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space @@ -180,7 +180,7 @@ Lint/UselessAssignment: Exclude: - 'lib/ruby_saml/slo_logoutrequest.rb' -# Offense count: 42 +# Offense count: 41 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 100 @@ -191,21 +191,26 @@ Metrics/AbcSize: Metrics/BlockLength: Max: 27 -# Offense count: 9 +# Offense count: 8 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: Max: 652 -# Offense count: 25 +# Offense count: 26 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: Max: 21 -# Offense count: 59 +# Offense count: 58 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 63 +# Offense count: 1 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ModuleLength: + Max: 244 + # Offense count: 2 # Configuration parameters: Max, CountKeywordArgs. Metrics/ParameterLists: @@ -279,22 +284,20 @@ Performance/RedundantEqualityComparisonBlock: Exclude: - 'lib/ruby_saml/settings.rb' -# Offense count: 5 +# Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). Performance/StringInclude: Exclude: - 'lib/ruby_saml/authrequest.rb' - 'lib/ruby_saml/logoutrequest.rb' - 'lib/ruby_saml/slo_logoutresponse.rb' - - 'lib/ruby_saml/utils.rb' -# Offense count: 8 +# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). Performance/StringReplacement: Exclude: - 'lib/ruby_saml/metadata.rb' - 'lib/ruby_saml/saml_message.rb' - - 'lib/ruby_saml/utils.rb' - 'lib/ruby_saml/xml/document.rb' # Offense count: 48 @@ -409,14 +412,6 @@ Style/IfUnlessModifier: - 'lib/ruby_saml/xml/document.rb' - 'lib/ruby_saml/xml/signed_document.rb' -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle, Autocorrect. -# SupportedStyles: module_function, extend_self, forbidden -Style/ModuleFunction: - Exclude: - - 'lib/ruby_saml/logging.rb' - # Offense count: 16 # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? @@ -432,18 +427,11 @@ Style/OptionalBooleanParameter: - 'lib/ruby_saml/utils.rb' - 'lib/ruby_saml/xml/signed_document.rb' -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Style/RedundantBegin: - Exclude: - - 'lib/ruby_saml/utils.rb' - -# Offense count: 8 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). Style/RedundantRegexpArgument: Exclude: - 'lib/ruby_saml/saml_message.rb' - - 'lib/ruby_saml/utils.rb' - 'lib/ruby_saml/xml/document.rb' # Offense count: 3 @@ -472,7 +460,7 @@ Style/StringConcatenation: - 'lib/ruby_saml/saml_message.rb' - 'lib/ruby_saml/slo_logoutrequest.rb' -# Offense count: 351 +# Offense count: 339 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. # SupportedStyles: single_quotes, double_quotes @@ -509,7 +497,7 @@ Style/SymbolArray: Exclude: - 'lib/ruby_saml/settings.rb' -# Offense count: 92 +# Offense count: 104 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # URISchemes: http, https diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7f4849..32e370db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * [#690](https://github.com/SAML-Toolkits/ruby-saml/pull/690) Remove deprecated `settings.security[:embed_sign]` parameter. * [#697](https://github.com/SAML-Toolkits/ruby-saml/pull/697) Add deprecation for various parameters in `RubySaml::Settings`. * [#709](https://github.com/SAML-Toolkits/ruby-saml/pull/709) Allow passing in `Net::HTTP` `:open_timeout`, `:read_timeout`, and `:max_retries` settings to `IdpMetadataParser#parse_remote`. +* [#711](https://github.com/SAML-Toolkits/ruby-saml/pull/711) Standardize how RubySaml reads and formats certificate and private_key PEM values, including the `RubySaml::Util#format_cert` and `#format_private_key` methods. ### 1.17.0 * [#687](https://github.com/SAML-Toolkits/ruby-saml/pull/687) Add CI coverage for Ruby 3.3 and Windows. diff --git a/UPGRADING.md b/UPGRADING.md index 7b62a328..ca0b2854 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -50,7 +50,7 @@ settings.security[:digest_method] = RubySaml::XML::Document::SHA1 settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1 ``` -### Removal of embed_sign Setting +### Removal of embed_sign setting The deprecated `settings.security[:embed_sign]` parameter has been removed. If you were using it, please instead switch to using both the `settings.idp_sso_service_binding` and `settings.idp_slo_service_binding` parameters as show below. @@ -68,7 +68,7 @@ settings.idp_slo_service_binding = :redirect For clarity, the default value of both parameters is `:redirect` if they are not set. -### Deprecation of Compression Settings +### Deprecation of compression settings The `settings.compress_request` and `settings.compress_response` parameters have been deprecated and are no longer functional. They will be removed in RubySaml 2.1.0. Please remove `compress_request` @@ -80,7 +80,7 @@ The SAML SP request/response message compression behavior is now controlled auto "compression" is used to make redirect URLs which contain SAML messages be shorter. For POST messages, compression may be achieved by enabling `Content-Encoding: gzip` on your webserver. -## Settings deprecations +### Other settings deprecations The following parameters in `RubySaml::Settings` are deprecated and will be removed in RubySaml 2.1.0: @@ -92,6 +92,35 @@ The following parameters in `RubySaml::Settings` are deprecated and will be remo - `#certificate_new` is deprecated and replaced by `#sp_cert_multi`. Refer to documentation as `#sp_cert_multi` has a different value type than `#certificate_new`. +### 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 +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 | +|---|------------------------------------------------------|---------------------------------------------------------|---------------------------| +| 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 | +| 3 | PEM header other than `CERTIFICATE` or `PRIVATE KEY` | Format if header ends in `CERTIFICATE` or `PRIVATE KEY` | Skip PEM formatting | +| 4 | `#format_cert` given `PRIVATE KEY` (and vice-versa) | Ignore PEMs of incorrect type | Return a bad PEM | +| 5 | Text outside header/footer values | Strip out text outside header/footer values | Skip PEM formatting | +| 6 | Input non-ASCII characters | Ignore non-ASCII chars if they are outside the PEM | Skip PEM formatting | +| 7 | `#format_cert` input contains mix of good/bad certs | Return only good cert PEMs (joined with `\n`) | Return good and bad certs | + +**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 + 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), + text present between the footer and header of two different certificates will also be + stripped out. +- Case 7: If no valid certificates are found, the entire original string will be returned. + ## Updating from 1.12.x to 1.13.0 Version `1.13.0` adds `settings.idp_sso_service_binding` and `settings.idp_slo_service_binding`, and diff --git a/lib/ruby_saml.rb b/lib/ruby_saml.rb index b9c7544f..b481a2ba 100644 --- a/lib/ruby_saml.rb +++ b/lib/ruby_saml.rb @@ -16,6 +16,7 @@ require 'ruby_saml/validation_error' require 'ruby_saml/metadata' require 'ruby_saml/idp_metadata_parser' +require 'ruby_saml/pem_formatter' require 'ruby_saml/utils' require 'ruby_saml/version' diff --git a/lib/ruby_saml/pem_formatter.rb b/lib/ruby_saml/pem_formatter.rb new file mode 100644 index 00000000..6a75527f --- /dev/null +++ b/lib/ruby_saml/pem_formatter.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module RubySaml + # Formats PEM-encoded X.509 certificates and private keys to canonical + # RFC 7468 PEM format, including 64-char lines and BEGIN/END headers. + # + # @api private + module PemFormatter + extend self + + # Formats X.509 certificate(s) to an array of strings in canonical + # RFC 7468 PEM format. + # + # @param certs [String|Array] String(s) containing + # unformatted certificate(s). + # @return [Array] The formatted certificate(s). + def format_cert_array(certs) + format_pem_array(certs, 'CERTIFICATE') + end + + # Formats one or multiple X.509 certificate(s) to canonical + # RFC 7468 PEM format. + # + # @param cert [String] A string containing unformatted certificate(s). + # @param multi [true|false] Whether to return multiple certificates + # delimited by newline. Default false. + # @return [String] The formatted certificate(s). Returns nil if the + # input is blank. + def format_cert(cert, multi: false) + pem_array_to_string(format_cert_array(cert), multi: multi) + end + + # Formats private keys(s) to canonical RFC 7468 PEM format. + # + # @param keys [String|Array] String(s) containing unformatted + # private keys(s). + # @return [Array] The formatted private keys(s). + def format_private_key_array(keys) + format_pem_array(keys, 'PRIVATE KEY', %w[RSA ECDSA EC DSA]) + end + + # Formats one or multiple private key(s) to canonical RFC 7468 + # PEM format. + # + # @param key [String] A string containing unformatted private keys(s). + # @param multi [true|false] Whether to return multiple keys + # delimited by newline. Default false. + # @return [String|nil] The formatted private key(s). Returns + # nil if the input is blank. + def format_private_key(key, multi: false) + pem_array_to_string(format_private_key_array(key), multi: multi) + end + + private + + def format_pem_array(str, label, known_prefixes = nil) + return [] unless str + + # Normalize array input using '?' char as a delimiter + str = str.is_a?(Array) ? str.map { |s| encode_utf8(s) }.join('?') : encode_utf8(str) + str.strip! + return [] if str.empty? + + # Find and format PEMs matching the desired label + pems = str.scan(pem_scan_regexp(label)).map { |pem| format_pem(pem, label, known_prefixes) } + + # If no PEMs matched, remove non-matching PEMs then format the remaining string + if pems.empty? + str.gsub!(pem_scan_regexp, '') + str.strip! + pems = format_pem(str, label, known_prefixes).scan(pem_scan_regexp(label)) unless str.empty? + end + + pems + end + + def pem_array_to_string(pems, multi: false) + return if pems.empty? + return pems unless pems.is_a?(Array) + + multi ? pems.join("\n") : pems.first + end + + # Given a PEM, a label such as "PRIVATE KEY", and a list of known prefixes + # such as "RSA", "DSA", etc., returns the formatted PEM preserving the known + # prefix if possible. + def format_pem(pem, label, known_prefixes = nil) + prefix = detect_label_prefix(pem, label, known_prefixes) + label = "#{prefix} #{label}" if prefix + "-----BEGIN #{label}-----\n#{format_pem_body(pem)}\n-----END #{label}-----" + end + + # Given a PEM, a label such as "PRIVATE KEY", and a list of known prefixes + # such as "RSA", "DSA", etc., detects and returns the known prefix if it exists. + def detect_label_prefix(pem, label, known_prefixes) + return unless known_prefixes && !known_prefixes.empty? + + pem.match(/(#{Array(known_prefixes).join('|')})\s+#{label.gsub(' ', '\s+')}/)&.[](1) + end + + # Given a PEM, strips all whitespace and the BEGIN/END lines, + # then splits the body into 64-character lines. + def format_pem_body(pem) + pem.gsub(/\s|#{pem_scan_header}/, '').scan(/.{1,64}/).join("\n") + end + + # Returns a regexp which can be used to loosely match unformatted PEM(s) in a string. + def pem_scan_regexp(label = nil) + base64 = '[A-Za-z\d+/\s]*[A-Za-z\d+][A-Za-z\d+/\s]*=?\s*=?\s*' + /#{pem_scan_header('BEGIN', label)}#{base64}#{pem_scan_header('END', label)}/m + end + + # Returns a regexp component string to match PEM headers. + def pem_scan_header(marker = nil, label = nil) + marker ||= '(BEGIN|END)' + label ||= '[A-Z\d]+' + "-{5}\\s*#{marker}\\s(?:[A-Z\\d\\s]*\\s)?#{label.gsub(' ', '\s+')}\\s*-{5}" + end + + # Encode to UTF-8 using '?' as a delimiter so that non-ASCII chars + # appearing inside a PEM will cause the PEM to be considered invalid. + def encode_utf8(str) + str.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') + end + end +end diff --git a/lib/ruby_saml/utils.rb b/lib/ruby_saml/utils.rb index 55f917a1..e055925e 100644 --- a/lib/ruby_saml/utils.rb +++ b/lib/ruby_saml/utils.rb @@ -1,13 +1,16 @@ # frozen_string_literal: true require 'securerandom' -require "openssl" +require 'openssl' +require 'ruby_saml/pem_formatter' module RubySaml # SAML2 Auxiliary class # - class Utils + module Utils + extend self + BINDINGS = { post: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", redirect: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" }.freeze DSIG = "http://www.w3.org/2000/09/xmldsig#" @@ -33,9 +36,8 @@ class Utils # # @param cert [OpenSSL::X509::Certificate|String] The x509 certificate. # @return [true|false] Whether the certificate is expired. - def self.is_cert_expired(cert) - cert = OpenSSL::X509::Certificate.new(cert) if cert.is_a?(String) - + def is_cert_expired(cert) + cert = build_cert_object(cert) if cert.is_a?(String) cert.not_after < Time.now end @@ -43,8 +45,8 @@ def self.is_cert_expired(cert) # # @param cert [OpenSSL::X509::Certificate|String] The x509 certificate. # @return [true|false] Whether the certificate is currently active. - def self.is_cert_active(cert) - cert = OpenSSL::X509::Certificate.new(cert) if cert.is_a?(String) + def is_cert_active(cert) + cert = build_cert_object(cert) if cert.is_a?(String) now = Time.now cert.not_before <= now && cert.not_after >= now end @@ -58,7 +60,7 @@ def self.is_cert_active(cert) # # @return [Integer] The new timestamp, after the duration is applied. # - def self.parse_duration(duration, timestamp=Time.now.utc) + def parse_duration(duration, timestamp=Time.now.utc) matches = duration.match(DURATION_FORMAT) if matches.nil? @@ -84,77 +86,52 @@ def self.parse_duration(duration, timestamp=Time.now.utc) datetime.to_time.utc.to_i + (durHours * 3600) + (durMinutes * 60) + durSeconds end - # Return a properly formatted x509 certificate + # Formats one or multiple X.509 certificate(s) to canonical RFC 7468 PEM format. # - # @param cert [String] The original certificate - # @return [String] The formatted certificate + # @note Unlike `PemFormatter#format_cert`, this method returns the original + # input string if the input cannot be parsed. # - def self.format_cert(cert) - # don't try to format an encoded certificate or if is empty or nil - if cert.respond_to?(:ascii_only?) - return cert if cert.nil? || cert.empty? || !cert.ascii_only? - elsif cert.nil? || cert.empty? || cert.match(/\x0d/) - return cert - end - - if cert.scan(/BEGIN CERTIFICATE/).length > 1 - formatted_cert = [] - cert.scan(/-{5}BEGIN CERTIFICATE-{5}[\n\r]?.*?-{5}END CERTIFICATE-{5}[\n\r]?/m) do |c| - formatted_cert << format_cert(c) - end - formatted_cert.join("\n") - else - cert = cert.gsub(/-{5}\s?(BEGIN|END) CERTIFICATE\s?-{5}/, "") - cert = cert.gsub(/\r/, "") - cert = cert.gsub(/\n/, "") - cert = cert.gsub(/\s/, "") - cert = cert.scan(/.{1,64}/) - cert = cert.join("\n") - "-----BEGIN CERTIFICATE-----\n#{cert}\n-----END CERTIFICATE-----" - end + # @param cert [String] The original certificate(s). + # @param multi [true|false] Whether to return multiple keys delimited by newline. + # Default true for compatibility with legacy behavior (i.e. to parse cert chains). + # @return [String] The formatted certificate(s). For legacy compatibility reasons, + # this method returns the original string if the input cannot be parsed. + def format_cert(cert, multi: true) + PemFormatter.format_cert(cert, multi: multi) || cert end - # Return a properly formatted private key + # Formats one or multiple private key(s) to canonical RFC 7468 PEM format. # - # @param key [String] The original private key - # @return [String] The formatted private key + # @note Unlike `PemFormatter#format_private_key`, this method returns the + # original input string if the input cannot be parsed. # - def self.format_private_key(key) - # don't try to format an encoded private key or if is empty - return key if key.nil? || key.empty? || key.match(/\x0d/) - - # is this an rsa key? - rsa_key = key.match("RSA PRIVATE KEY") - key = key.gsub(/-{5}\s?(BEGIN|END)( RSA)? PRIVATE KEY\s?-{5}/, "") - key = key.gsub(/\n/, "") - key = key.gsub(/\r/, "") - key = key.gsub(/\s/, "") - key = key.scan(/.{1,64}/) - key = key.join("\n") - key_label = rsa_key ? "RSA PRIVATE KEY" : "PRIVATE KEY" - "-----BEGIN #{key_label}-----\n#{key}\n-----END #{key_label}-----" + # @param key [String] The original private key(s) + # @param multi [true|false] Whether to return multiple keys delimited by newline. + # Default false for compatibility with legacy behavior. + # @return [String] The formatted private key(s). For legacy compatibility reasons, + # this method returns the original string if the input cannot be parsed. + def format_private_key(key, multi: false) + PemFormatter.format_private_key(key, multi: multi) || key end # Given a certificate string, return an OpenSSL::X509::Certificate object. # - # @param cert [String] The original certificate + # @param pem [String] The original certificate # @return [OpenSSL::X509::Certificate] The certificate object - # - def self.build_cert_object(cert) - return nil if cert.nil? || cert.empty? + def build_cert_object(pem) + return unless (pem = PemFormatter.format_cert(pem, multi: false)) - OpenSSL::X509::Certificate.new(format_cert(cert)) + OpenSSL::X509::Certificate.new(pem) end # Given a private key string, return an OpenSSL::PKey::RSA object. # - # @param cert [String] The original private key - # @return [OpenSSL::PKey::RSA] The private key object - # - def self.build_private_key_object(private_key) - return nil if private_key.nil? || private_key.empty? + # @param pem [String] The original private key. + # @return [OpenSSL::PKey::RSA] The private key object. + def build_private_key_object(pem) + return unless (pem = PemFormatter.format_private_key(pem, multi: false)) - OpenSSL::PKey::RSA.new(format_private_key(private_key)) + OpenSSL::PKey::RSA.new(pem) end # Build the Query String signature that will be used in the HTTP-Redirect binding @@ -166,8 +143,8 @@ def self.build_private_key_object(private_key) # @option params [String] :sig_alg The SigAlg parameter # @return [String] The Query String # - def self.build_query(params) - type, data, relay_state, sig_alg = %i[type data relay_state sig_alg].map { |k| params[k]} + def build_query(params) + type, data, relay_state, sig_alg = params.values_at(:type, :data, :relay_state, :sig_alg) url_string = +"#{type}=#{CGI.escape(data)}" url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state @@ -183,8 +160,8 @@ def self.build_query(params) # @option params [String] :raw_sig_alg URI-encoded SigAlg parameter, as sent by IDP # @return [String] The Query String # - def self.build_query_from_raw_parts(params) - type, raw_data, raw_relay_state, raw_sig_alg = %i[type raw_data raw_relay_state raw_sig_alg].map { |k| params[k]} + def build_query_from_raw_parts(params) + type, raw_data, raw_relay_state, raw_sig_alg = params.values_at(:type, :raw_data, :raw_relay_state, :raw_sig_alg) url_string = +"#{type}=#{raw_data}" url_string << "&RelayState=#{raw_relay_state}" if raw_relay_state @@ -199,7 +176,7 @@ def self.build_query_from_raw_parts(params) # @param lowercase_url_encoding [bool] Lowercase URL Encoding (For ADFS urlencode compatiblity) # @return [Hash] New raw parameters # - def self.prepare_raw_get_params(rawparams, params, lowercase_url_encoding=false) + def prepare_raw_get_params(rawparams, params, lowercase_url_encoding=false) rawparams ||= {} if rawparams['SAMLRequest'].nil? && !params['SAMLRequest'].nil? @@ -218,7 +195,7 @@ def self.prepare_raw_get_params(rawparams, params, lowercase_url_encoding=false) rawparams end - def self.escape_request_param(param, lowercase_url_encoding) + def escape_request_param(param, lowercase_url_encoding) CGI.escape(param).tap do |escaped| next unless lowercase_url_encoding @@ -234,7 +211,7 @@ def self.escape_request_param(param, lowercase_url_encoding) # @option params [String] query_string The full GET Query String to be compared # @return [Boolean] True if the Signature is valid, False otherwise # - def self.verify_signature(params) + 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.public_key.verify(signature_algorithm.new, Base64.decode64(signature), query_string) @@ -244,7 +221,7 @@ def self.verify_signature(params) # @param status_code [String] StatusCode value # @param status_message [Strig] StatusMessage value # @return [String] The status error message - def self.status_error_msg(error_msg, raw_status_code = nil, status_message = nil) + def status_error_msg(error_msg, raw_status_code = nil, status_message = nil) unless raw_status_code.nil? if raw_status_code.include?("|") status_codes = raw_status_code.split(' | ') @@ -268,16 +245,14 @@ def self.status_error_msg(error_msg, raw_status_code = nil, status_message = nil # @param encrypted_node [REXML::Element] The Encrypted element # @param private_keys [Array] The Service provider private key # @return [String] The decrypted data - def self.decrypt_multi(encrypted_node, private_keys) + def decrypt_multi(encrypted_node, private_keys) raise ArgumentError.new('private_keys must be specified') if !private_keys || private_keys.empty? error = nil private_keys.each do |key| - begin - return decrypt_data(encrypted_node, key) - rescue OpenSSL::PKey::PKeyError => e - error ||= e - end + return decrypt_data(encrypted_node, key) + rescue OpenSSL::PKey::PKeyError => e + error ||= e end raise(error) if error @@ -287,7 +262,7 @@ def self.decrypt_multi(encrypted_node, private_keys) # @param encrypted_node [REXML::Element] The Encrypted element # @param private_key [OpenSSL::PKey::RSA] The Service provider private key # @return [String] The decrypted data - def self.decrypt_data(encrypted_node, private_key) + def decrypt_data(encrypted_node, private_key) encrypt_data = REXML::XPath.first( encrypted_node, "./xenc:EncryptedData", @@ -313,7 +288,7 @@ def self.decrypt_data(encrypted_node, private_key) # @param encrypt_data [REXML::Element] The EncryptedData element # @param private_key [OpenSSL::PKey::RSA] The Service provider private key # @return [String] The symmetric key - def self.retrieve_symmetric_key(encrypt_data, private_key) + def retrieve_symmetric_key(encrypt_data, private_key) encrypted_key = REXML::XPath.first( encrypt_data, "./ds:KeyInfo/xenc:EncryptedKey | ./KeyInfo/xenc:EncryptedKey | //xenc:EncryptedKey[@Id=$id]", @@ -339,7 +314,7 @@ def self.retrieve_symmetric_key(encrypt_data, private_key) retrieve_plaintext(cipher_text, private_key, algorithm) end - def self.retrieve_symetric_key_reference(encrypt_data) + def retrieve_symetric_key_reference(encrypt_data) REXML::XPath.first( encrypt_data, "substring-after(./ds:KeyInfo/ds:RetrievalMethod/@URI, '#')", @@ -352,7 +327,7 @@ def self.retrieve_symetric_key_reference(encrypt_data) # @param symmetric_key [String] The symmetric key used to encrypt the text # @param algorithm [String] The encrypted algorithm # @return [String] The deciphered text - def self.retrieve_plaintext(cipher_text, symmetric_key, algorithm) + def retrieve_plaintext(cipher_text, symmetric_key, algorithm) case algorithm when 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc' then cipher = OpenSSL::Cipher.new('DES-EDE3-CBC').decrypt when 'http://www.w3.org/2001/04/xmlenc#aes128-cbc' then cipher = OpenSSL::Cipher.new('AES-128-CBC').decrypt @@ -394,11 +369,11 @@ def self.retrieve_plaintext(cipher_text, symmetric_key, algorithm) end end - def self.set_prefix(value) + def set_prefix(value) UUID_PREFIX.replace value end - def self.uuid + def uuid "#{UUID_PREFIX}#{SecureRandom.uuid}" end @@ -407,7 +382,7 @@ def self.uuid # RFC for URIs. If Rails can not parse the string in to URL pieces, return a boolean match of the # two strings. This maintains the previous functionality. # @return [Boolean] - def self.uri_match?(destination_url, settings_url) + def uri_match?(destination_url, settings_url) dest_uri = URI.parse(destination_url) acs_uri = URI.parse(settings_url) @@ -425,14 +400,14 @@ def self.uri_match?(destination_url, settings_url) # If Rails' URI.parse can't match to valid URL, default back to the original matching service. # @return [Boolean] - def self.original_uri_match?(destination_url, settings_url) + def original_uri_match?(destination_url, settings_url) destination_url == settings_url end # Given a REXML::Element instance, return the concatenation of all child text nodes. Assumes # that there all children other than text nodes can be ignored (e.g. comments). If nil is # passed, nil will be returned. - def self.element_text(element) + def element_text(element) element.texts.map(&:value).join if element end end diff --git a/test/pem_formatter_test.rb b/test/pem_formatter_test.rb new file mode 100644 index 00000000..717c9ff9 --- /dev/null +++ b/test/pem_formatter_test.rb @@ -0,0 +1,649 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class PemFormatterTest < Minitest::Test + BASE64_RAW = "\t \n\n\rR290 IGEgbG9uZyBsaX N0IG9mIG\rV4LWx/dmVycwpUaGV 5J2xsIHR" \ + "l\n bGwgeW91 IqE+bSBpbn\t NhbmUKQnV0IE\r\nkndmUgZ 2/0IGEgYmxhbmsgc" \ + "3BhY+UsIGJhY nkKQW5kIEkn/Gwgd3\npdG UgeW91ciBuYW1l \n\r\n" + BASE64_OUT = <<~BASE64.strip + R290IGEgbG9uZyBsaXN0IG9mIGV4LWx/dmVycwpUaGV5J2xsIHRlbGwgeW91IqE+ + bSBpbnNhbmUKQnV0IEkndmUgZ2/0IGEgYmxhbmsgc3BhY+UsIGJhYnkKQW5kIEkn + /Gwgd3pdGUgeW91ciBuYW1l + BASE64 + + describe RubySaml::PemFormatter do + def build_pem(label, body) + "-----BEGIN #{label}-----\n#{body}\n-----END #{label}-----" + end + + def build_cert(body) + build_pem('CERTIFICATE', body) + end + + def build_pkey(body) + build_pem('PRIVATE KEY', body) + end + + describe '.format_cert_array and .format_cert' do + it 'returns nil for nil input' do + input = nil + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil for whitespace inputs without modifying the input' do + ['', ' ', "\n\n", "\n \t\r"].each do |whitespace| + input = whitespace.dup + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + assert_equal input, whitespace + end + end + + it 'returns nil for empty array input' do + input = [] + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil for array of whitespace strings input without modifying the input' do + array = ['', ' ', "\n\n", "\n \t\r"] + input = array.map(&:dup) + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + assert_equal input, array + end + + it 'returns nil for missing PEM body' do + input = build_cert('') + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil for blank PEM body' do + input = build_cert("\n \t\r") + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'formats a single valid PEM without modifying the input' do + raw_pem = build_pem(" \n TRUSTED \tX509 \n\r CERTIFICATE \n", BASE64_RAW) + input = raw_pem.dup + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input, multi: true) + assert_equal input, raw_pem + end + + it 'formats multiple PEMs without modifying the input' do + multi = "\n#{build_cert(BASE64_RAW)}\n #{build_pem("\t \nXXX \t\n\r CERTIFICATE \n ", 'F00==')} \n" + input = multi.dup + expected_ary = [build_cert(BASE64_OUT), build_cert('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_cert(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_cert(input, multi: true) + assert_equal input, multi + end + + it 'formats array of PEMs without modifying the input' do + array = ["\n#{build_cert(BASE64_RAW)}\n ", "\t#{build_pem("\t \nXXX \t\n\r CERTIFICATE \n ", 'F00==')} \n"] + input = array.map(&:dup) + expected_ary = [build_cert(BASE64_OUT), build_cert('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_cert(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_cert(input, multi: true) + assert_equal input, array + end + + it 'ignores non-cert PEMs when multiple PEMs are given' do + multi = "#{build_pkey('BAR=')}\n#{build_cert(BASE64_RAW)}\n #{build_cert("\n")} #{build_pkey('BAZ')} " \ + "#{build_pem("\t \nXXX \t\n\r CERTIFICATE \n ", 'F00==')} #{build_pkey('QUX==')}\n" + input = multi.dup + expected_ary = [build_cert(BASE64_OUT), build_cert('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_cert(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_cert(input, multi: true) + assert_equal input, multi + end + + it 'ignores non-cert PEMs array of PEMs is given' do + array = [build_pkey('BAR='), + "#{build_cert("\n")} \n#{build_cert(BASE64_RAW)}\n #{build_pkey('BAZ')} ", + build_pkey('BAZ'), + "\t#{build_pem("\t \nXXX \t\n\r CERTIFICATE \n ", 'F00==')} \n", + build_pkey('QUX==')] + input = array.map(&:dup) + expected_ary = [build_cert(BASE64_OUT), build_cert('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_cert(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_cert(input, multi: true) + assert_equal input, array + end + + it 'formats multiple PEMs with non-ASCII chars outside' do + multi = "おはよう#{build_cert(BASE64_RAW)}こんにちは#{build_cert('F00==')}おやすみ" + input = multi.dup + expected_ary = [build_cert(BASE64_OUT), build_cert('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_cert(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_cert(input, multi: true) + assert_equal input, multi + end + + it 'formats PEM without headers' do + input = BASE64_RAW + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil for non-ASCII input without headers' do + input = "非ASCII証明書#{BASE64_RAW}" + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil for non-ASCII inside PEM body' do + input = build_cert("非ASCII証明書#{BASE64_RAW}") + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'formats PEM with begin but no end' do + input = "-----BEGIN CERTIFICATE-----\n#{BASE64_RAW}" + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + end + + it 'formats PEM with end but no begin' do + input = "#{BASE64_RAW}\n-----END CERTIFICATE-----" + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + end + + it 'allows extra whitespace inside headers' do + input = "----- \r BEGIN \n\n\n \tCERTIFICATE \r -----\n#{BASE64_RAW}\n-----END CERTIFICATE -----" + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + end + + it 'does not allow non-standard header labels' do + [build_pem('CERT', BASE64_OUT), + build_pem('CERT XXX', BASE64_OUT)].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'does not allow spaces inside header words' do + input = build_pem('CERT IFICATE', BASE64_OUT) + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'requires spaces between header words' do + [build_cert(BASE64_OUT).gsub('BEGIN CERTIFICATE', 'BEGINCERTIFICATE'), + build_cert(BASE64_OUT).gsub('END CERTIFICATE', 'ENDCERTIFICATE'), + build_pem('XXX CERTIFICATE', BASE64_OUT).gsub('BEGIN XXX', 'BEGINXXX'), + build_pem('XXX CERTIFICATE', BASE64_OUT).gsub('END XXX', 'ENDXXX')].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'normalizes labels' do + input = "-----BEGIN \nTRUSTED \tX509 \n\r CERTIFICATE \n-----\n#{BASE64_RAW}\n----- \tEND\t X509 CERTIFICATE -----" + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil if BEGIN is missing' do + input = "-----CERTIFICATE-----\n#{BASE64_OUT}\n-----END CERTIFICATE-----" + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil if END is missing' do + input = "-----BEGIN CERTIFICATE-----\n#{BASE64_OUT}\n-----CERTIFICATE-----" + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil if wrong hyphens' do + cert = build_cert(BASE64_OUT) + ['----', '------', '-- ---', "---\n--"].each do |dashes| + input = cert.gsub(/-{5}/, dashes) + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + end + + it 'ignores comments' do + input = "# This is a comment\n#{build_cert(BASE64_RAW)}\n# Another comment" + expected = build_cert(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + end + + it 'ignores private keys' do + input = build_pkey(BASE64_OUT) + + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_nil RubySaml::PemFormatter.format_cert(input) + end + + it 'returns nil when PEM body contains equal sign not at end' do + ['=ABCDEF', 'ABC=DEF', 'ABC+=DEF', " AB C\n=\nDEF ", "=\nABCDEF"].each do |input| + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_empty RubySaml::PemFormatter.format_cert_array(build_cert(input)) + assert_nil RubySaml::PemFormatter.format_cert(input) + assert_nil RubySaml::PemFormatter.format_cert(build_cert(input)) + end + end + + it 'allows PEM body to contain one equal sign at end' do + expected = build_cert('AbC+DEf=') + ['AbC+DEf=', 'AbC+DEf =', "\t A\nbC\t+DEf \t= \n"].each do |input| + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(build_cert(input)) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(build_cert(input)) + end + end + + it 'allows PEM body to contain two equal signs at end' do + expected = build_cert('aBC+DEf==') + ['aBC+DEf==', 'aBC+DEf= =', 'aBC+DEf = =', "\t a\nBC+\tDEf \t=\t= \n"].each do |input| + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(build_cert(input)) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(build_cert(input)) + end + end + + it 'does not format when PEM body contains three equal signs at end' do + ['ABCDEF===', 'ABCDEF = = ='].each do |input| + assert_empty RubySaml::PemFormatter.format_cert_array(input) + assert_empty RubySaml::PemFormatter.format_cert_array(build_cert(input)) + assert_nil RubySaml::PemFormatter.format_cert(input) + assert_nil RubySaml::PemFormatter.format_cert(build_cert(input)) + end + end + + it 'formats PEM to exactly 64 characters per line' do + input = 'A' * 130 + expected = build_cert("#{('A' * 64)}\n#{('A' * 64)}\nAA") + + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(input) + assert_equal [expected], RubySaml::PemFormatter.format_cert_array(build_cert(input)) + assert_equal expected, RubySaml::PemFormatter.format_cert(input) + assert_equal expected, RubySaml::PemFormatter.format_cert(build_cert(input)) + end + end + + describe '.format_private_key_array and .format_private_key' do + it 'returns nil for nil input' do + input = nil + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil for whitespace inputs without modifying the input' do + ['', ' ', "\n\n", "\n \t\r"].each do |whitespace| + input = whitespace.dup + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + assert_equal input, whitespace + end + end + + it 'returns nil for empty array input' do + input = [] + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil for array of whitespace strings input without modifying the input' do + array = ['', ' ', "\n\n", "\n \t\r"] + input = array.map(&:dup) + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + assert_equal input, array + end + + it 'returns nil for missing PEM body' do + input = build_pkey('') + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil for blank PEM body' do + input = build_pkey("\n \t\r") + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'formats a single valid PEM without modifying the input' do + raw_pem = build_pem(" \n TRUSTED \tX509 \n\r PRIVATE \t\r KEY \n", BASE64_RAW) + input = raw_pem.dup + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input, multi: true) + assert_equal input, raw_pem + end + + it 'formats multiple PEMs without modifying the input' do + multi = "\n#{build_pkey(BASE64_RAW)}\n #{build_pem("\t \nXXX\t\n\rPRIVATE\n\nKEY \n ", 'F00==')} \n" + input = multi.dup + expected_ary = [build_pkey(BASE64_OUT), build_pkey('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_private_key(input, multi: true) + assert_equal input, multi + end + + it 'formats array of PEMs without modifying the input' do + array = ["\n#{build_pkey(BASE64_RAW)}\n ", "\t#{build_pem("\t \nXXX \t\n\r PRIVATE KEY \n ", 'F00==')} \n"] + input = array.map(&:dup) + expected_ary = [build_pkey(BASE64_OUT), build_pkey('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_private_key(input, multi: true) + assert_equal input, array + end + + it 'ignores non-private key PEMs when multiple PEMs are given' do + multi = "#{build_cert('BAR=')}\n#{build_pkey(BASE64_RAW)}\n #{build_pkey("\n")} #{build_cert('BAZ')} " \ + "#{build_pem("\t \nXXX \t\n\r PRIVATE KEY \n ", 'F00==')} #{build_cert('QUX==')}\n" + input = multi.dup + expected_ary = [build_pkey(BASE64_OUT), build_pkey('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_private_key(input, multi: true) + assert_equal input, multi + end + + it 'ignores non-cert PEMs array of PEMs is given' do + array = [build_cert('BAR='), + "#{build_pkey("\n")} \n#{build_pkey(BASE64_RAW)}\n #{build_cert('BAZ')} ", + build_cert('BAZ'), + "\t#{build_pem("\t \nXXX \t\n\r PRIVATE KEY \n ", 'F00==')} \n", + build_cert('QUX==')] + input = array.map(&:dup) + expected_ary = [build_pkey(BASE64_OUT), build_pkey('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_private_key(input, multi: true) + assert_equal input, array + end + + it 'formats multiple PEMs with non-ASCII chars outside' do + multi = "おはよう#{build_pkey(BASE64_RAW)}こんにちは#{build_pkey('F00==')}おやすみ" + input = multi.dup + expected_ary = [build_pkey(BASE64_OUT), build_pkey('F00==')] + expected_one = expected_ary.first + expected_mul = expected_ary.join("\n") + + assert_equal expected_ary, RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected_one, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected_mul, RubySaml::PemFormatter.format_private_key(input, multi: true) + assert_equal input, multi + end + + it 'formats PEM without headers' do + input = BASE64_RAW + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil for non-ASCII input without headers' do + input = "非ASCII証明書#{BASE64_RAW}" + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil for non-ASCII inside PEM body' do + input = build_pkey("非ASCII証明書#{BASE64_RAW}") + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'formats PEM with begin but no end' do + input = "-----BEGIN PRIVATE KEY-----\n#{BASE64_RAW}" + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it 'formats PEM with end but no begin' do + input = "#{BASE64_RAW}\n-----END PRIVATE KEY-----" + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil if wrong hyphens' do + pkey = build_pkey(BASE64_OUT) + ['----', '------', '-- ---', "---\n--"].each do |dashes| + input = pkey.gsub(/-{5}/, dashes) + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'allows extra whitespace inside headers' do + input = "----- \r BEGIN \n\n\n \tPRIVATE\n\nKEY \r -----\n#{BASE64_RAW}\n-----END PRIVATE\n KEY -----" + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it 'does not allow non-standard header labels' do + [build_pem('PKEY', BASE64_OUT), + build_pem('PRIVATE KEY XXX', BASE64_OUT)].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'does not allow spaces inside header words' do + [build_pem('PRI VATE KEY', BASE64_OUT), + build_pem('RSA PRIVATE KE Y', BASE64_OUT)].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'requires spaces between header words' do + [build_pkey(BASE64_OUT).gsub('BEGIN PRIVATE', 'BEGINPRIVATE'), + build_pkey(BASE64_OUT).gsub('END PRIVATE', 'ENDPRIVATE'), + build_pem('PRIVATEKEY', BASE64_OUT), + build_pem('RSAPRIVATE KEY', BASE64_OUT), + build_pem('RSA PRIVATE KEY', BASE64_OUT).gsub('BEGIN RSA', 'BEGINRSA'), + build_pem('RSA PRIVATE KEY', BASE64_OUT).gsub('END RSA', 'ENDRSA')].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'normalizes labels' do + input = "-----BEGIN \nXXX \n\r PRIVATE KEY \n-----\n#{BASE64_RAW}\n----- \tEND\t XXX PRIVATE KEY -----" + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil if BEGIN is missing' do + input = "-----PRIVATE KEY-----\n#{BASE64_OUT}\n-----END PRIVATE KEY-----" + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil if END is missing' do + input = "-----BEGIN PRIVATE KEY-----\n#{BASE64_OUT}\n-----PRIVATE KEY-----" + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'ignores comments' do + input = "# This is a comment\n#{build_pkey(BASE64_RAW)}\n# Another comment" + expected = build_pkey(BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it 'ignores certs' do + input = build_cert(BASE64_OUT) + + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_nil RubySaml::PemFormatter.format_private_key(input) + end + + it 'returns nil when PEM body contains equal sign not at end' do + ['=ABCDEF', 'ABC=DEF', 'ABC+=DEF', " AB C\n=\nDEF ", "=\nABCDEF"].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_empty RubySaml::PemFormatter.format_private_key_array(build_pkey(input)) + assert_nil RubySaml::PemFormatter.format_private_key(input) + assert_nil RubySaml::PemFormatter.format_private_key(build_pkey(input)) + end + end + + it 'allows PEM body to contain one equal sign at end' do + expected = build_pkey('AbC+DEf=') + ['AbC+DEf=', 'AbC+DEf =', "\t A\nbC\t+DEf \t= \n"].each do |input| + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(build_pkey(input)) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(build_pkey(input)) + end + end + + it 'allows PEM body to contain two equal signs at end' do + expected = build_pkey('aBC+DEf==') + ['aBC+DEf==', 'aBC+DEf= =', 'aBC+DEf = =', "\t a\nBC+\tDEf \t=\t= \n"].each do |input| + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(build_pkey(input)) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(build_pkey(input)) + end + end + + it 'does not format when PEM body contains three equal signs at end' do + ['ABCDEF===', 'ABCDEF = = ='].each do |input| + assert_empty RubySaml::PemFormatter.format_private_key_array(input) + assert_empty RubySaml::PemFormatter.format_private_key_array(build_pkey(input)) + assert_nil RubySaml::PemFormatter.format_private_key(input) + assert_nil RubySaml::PemFormatter.format_private_key(build_pkey(input)) + end + end + + it 'formats PEM to exactly 64 characters per line' do + input = 'A' * 130 + expected = build_pkey("#{('A' * 64)}\n#{('A' * 64)}\nAA") + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(build_pkey(input)) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(build_pkey(input)) + end + + %w[RSA ECDSA EC DSA].each do |algo| + it "preserves #{algo} in label" do + input = build_pem("FOO \t #{algo} PRIVATE\n KEY", BASE64_RAW) + expected = build_pem("#{algo} PRIVATE KEY", BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + + it "preserves #{algo} in label if it appears at end" do + input = "-----BEGIN PRIVATE KEY-----\n#{BASE64_RAW}\n-----END #{algo} PRIVATE KEY-----" + expected = build_pem("#{algo} PRIVATE KEY", BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + end + + it 'removes unknown private key header prefix' do + input = build_pem(' XXX PRIVATE KEY', BASE64_RAW) + expected = build_pem('PRIVATE KEY', BASE64_OUT) + + assert_equal [expected], RubySaml::PemFormatter.format_private_key_array(input) + assert_equal expected, RubySaml::PemFormatter.format_private_key(input) + end + end + end +end diff --git a/test/response_test.rb b/test/response_test.rb index be9a437e..2eef8464 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -1160,7 +1160,7 @@ def generate_audience_error(expected, actual) it "optionally allows for clock drift on NotOnOrAfter" do # Java Floats behave differently than MRI - java = defined?(RUBY_ENGINE) && %w[jruby truffleruby].include?(RUBY_ENGINE) + java = jruby? || truffleruby? settings.soft = true @@ -1461,14 +1461,19 @@ def generate_audience_error(expected, actual) end end - it 'raise if an encrypted assertion is found and the sp private key is wrong' do + it 'raise if an encrypted assertion is found and the SP private key does not match cert' do settings.certificate = ruby_saml_cert_text - wrong_private_key = ruby_saml_key_text.sub!('A', 'B') + wrong_private_key = ruby_saml_key_text.sub!('Z', 'X') settings.private_key = wrong_private_key - error_msg = "Neither PUB key nor PRIV key: nested asn1 error" - assert_raises(OpenSSL::PKey::RSAError, error_msg) do - RubySaml::Response.new(signed_message_encrypted_unsigned_assertion, :settings => settings) + error, msg = if jruby? + [Java::JavaLang::IllegalStateException, 'RSA engine faulty decryption/signing detected'] + else + [OpenSSL::PKey::RSAError, 'Neither PUB key nor PRIV key: nested asn1 error'] + end + + assert_raises(error, msg) do + RubySaml::Response.new(signed_message_encrypted_unsigned_assertion, settings: settings) end end diff --git a/test/slo_logoutrequest_test.rb b/test/slo_logoutrequest_test.rb index d1657c83..f5777270 100644 --- a/test/slo_logoutrequest_test.rb +++ b/test/slo_logoutrequest_test.rb @@ -211,7 +211,7 @@ class RubySamlTest < Minitest::Test it "optionally allows for clock drift" do # Java Floats behave differently than MRI - java = defined?(RUBY_ENGINE) && %w[jruby truffleruby].include?(RUBY_ENGINE) + java = jruby? || truffleruby? logout_request.soft = true logout_request.document.root.attributes['NotOnOrAfter'] = '2011-06-14T18:31:01.516Z' diff --git a/test/test_helper.rb b/test/test_helper.rb index 60bce42a..3a4bb1ca 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -29,6 +29,14 @@ RubySaml::Logging.logger = TEST_LOGGER class Minitest::Test + def jruby? + defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby' + end + + def truffleruby? + defined?(RUBY_ENGINE) && RUBY_ENGINE == 'truffleruby' + end + def fixture(document, base64 = true) response = Dir.glob(File.join(File.dirname(__FILE__), "responses", "#{document}*")).first if base64 && response =~ /\.xml$/ diff --git a/test/utils_test.rb b/test/utils_test.rb index b0e7f2b1..c3d64121 100644 --- a/test/utils_test.rb +++ b/test/utils_test.rb @@ -44,8 +44,8 @@ def result(duration, reference = 0) let(:formatted_chained_certificate) {read_certificate("formatted_chained_certificate")} it "returns empty string when the cert is an empty string" do - cert = "" - assert_equal "", RubySaml::Utils.format_cert(cert) + cert = '' + assert_equal '', RubySaml::Utils.format_cert(cert) end it "returns nil when the cert is nil" do @@ -67,7 +67,7 @@ def result(duration, reference = 0) assert_equal formatted_certificate, RubySaml::Utils.format_cert(invalid_certificate2) end - it "returns the cert when it's encoded" do + it "returns the original cert when it's encoded" do encoded_certificate = read_certificate("certificate.der") assert_equal encoded_certificate, RubySaml::Utils.format_cert(encoded_certificate) end @@ -93,8 +93,8 @@ def result(duration, reference = 0) end it "returns empty string when the private key is an empty string" do - private_key = "" - assert_equal "", RubySaml::Utils.format_private_key(private_key) + private_key = '' + assert_equal '', RubySaml::Utils.format_private_key(private_key) end it "returns nil when the private key is nil" do From aa9bf70824ca4c147b127c1ee9a1a139276773bc Mon Sep 17 00:00:00 2001 From: Sixto Martin Date: Mon, 30 Sep 2024 09:05:40 +0200 Subject: [PATCH 4/7] Fix ambiguous regex warnings, See #720 --- test/utils_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils_test.rb b/test/utils_test.rb index c3d64121..277d5e13 100644 --- a/test/utils_test.rb +++ b/test/utils_test.rb @@ -357,11 +357,11 @@ def result(duration, reference = 0) end it 'successfully decrypts with the first private key' do - assert_match /\A Date: Mon, 30 Sep 2024 09:18:46 +0200 Subject: [PATCH 5/7] Add support to retrieve from SAMLResponse the AuthnInstant and AuthnContextClassRef values. See #718 --- CHANGELOG.md | 15 ++++++++------- lib/ruby_saml/response.rb | 21 +++++++++++++++++++++ test/response_test.rb | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e370db..3f326514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Ruby SAML Changelog ### 2.0.0 +* [#718](https://github.com/SAML-Toolkits/ruby-saml/pull/718/) Add support to retrieve from SAMLResponse the AuthnInstant and AuthnContextClassRef values * [#685](https://github.com/SAML-Toolkits/ruby-saml/pull/685) Remove `OneLogin` namespace. The root namespace of the gem is now `RubySaml`. * [#685](https://github.com/SAML-Toolkits/ruby-saml/pull/685) Create namespace alias `OneLogin = Object` for backward compatibility, to be removed in version `2.1.0`. * [#685](https://github.com/SAML-Toolkits/ruby-saml/pull/685) Change directly structure from `lib/onelogin/ruby-saml` to `lib/ruby_saml`. @@ -28,7 +29,7 @@ ### 1.15.0 (Jan 04, 2023) * [#650](https://github.com/SAML-Toolkits/ruby-saml/pull/650) Replace strip! by strip on compute_digest method -* [#638](https://github.com/SAML-Toolkits/ruby-saml/pull/638) Fix dateTime format for the validUntil attribute of the generated metadata +* [#638](https://github.com/SAML-Toolkits/ruby-saml/pull/638) Fix dateTime format for the validUntil attribute of the generated metadata * [#576](https://github.com/SAML-Toolkits/ruby-saml/pull/576) Support `Settings#idp_cert_multi` with string keys * [#567](https://github.com/SAML-Toolkits/ruby-saml/pull/567) Improve Code quality * Add info about new repo, new maintainer, new security contact @@ -62,7 +63,7 @@ ### 1.12.0 (Feb 18, 2021) * Support AES-128-GCM, AES-192-GCM, and AES-256-GCM encryptions -* Parse & return SLO ResponseLocation in IDPMetadataParser & Settings +* Parse & return SLO ResponseLocation in IDPMetadataParser & Settings * Adding idp_sso_service_url and idp_slo_service_url settings * [#536](https://github.com/SAML-Toolkits/ruby-saml/pull/536) Adding feth method to be able retrieve attributes based on regex * Reduce size of built gem by excluding the test folder @@ -192,7 +193,7 @@ * Fix response_test.rb of gem 1.3.0 * Add reference to Security Guidelines * Update License -* [#334](https://github.com/SAML-Toolkits/ruby-saml/pull/334) Keep API backward-compatibility on IdpMetadataParser fingerprint method. +* [#334](https://github.com/SAML-Toolkits/ruby-saml/pull/334) Keep API backward-compatibility on IdpMetadataParser fingerprint method. ### 1.3.0 (June 24, 2016) * [Security Fix](https://github.com/SAML-Toolkits/ruby-saml/commit/a571f52171e6bfd87db59822d1d9e8c38fb3b995) Add extra validations to prevent Signature wrapping attacks @@ -210,7 +211,7 @@ * [#316](https://github.com/SAML-Toolkits/ruby-saml/pull/316) Fix Misspelling of transation_id to transaction_id * [#321](https://github.com/SAML-Toolkits/ruby-saml/pull/321) Support Attribute Names on IDPSSODescriptor parser * Changes on empty URI of Signature reference management -* [#320](https://github.com/SAML-Toolkits/ruby-saml/pull/320) Dont mutate document to fix lack of reference URI +* [#320](https://github.com/SAML-Toolkits/ruby-saml/pull/320) Dont mutate document to fix lack of reference URI * [#306](https://github.com/SAML-Toolkits/ruby-saml/pull/306) Support WantAssertionsSigned ### 1.1.2 (February 15, 2016) @@ -227,9 +228,9 @@ * [#270](https://github.com/SAML-Toolkits/ruby-saml/pull/270) Allow SAML elements to come from any namespace (at decryption process) * [#261](https://github.com/SAML-Toolkits/ruby-saml/pull/261) Allow validate_subject_confirmation Response validation to be skipped * [#258](https://github.com/SAML-Toolkits/ruby-saml/pull/258) Fix allowed_clock_drift on the validate_session_expiration test -* [#256](https://github.com/SAML-Toolkits/ruby-saml/pull/256) Separate the create_authentication_xml_doc in two methods. +* [#256](https://github.com/SAML-Toolkits/ruby-saml/pull/256) Separate the create_authentication_xml_doc in two methods. * [#255](https://github.com/SAML-Toolkits/ruby-saml/pull/255) Refactor validate signature. -* [#254](https://github.com/SAML-Toolkits/ruby-saml/pull/254) Handle empty URI references +* [#254](https://github.com/SAML-Toolkits/ruby-saml/pull/254) Handle empty URI references * [#251](https://github.com/SAML-Toolkits/ruby-saml/pull/251) Support qualified and unqualified NameID in attributes * [#234](https://github.com/SAML-Toolkits/ruby-saml/pull/234) Add explicit support for JRuby @@ -237,7 +238,7 @@ * [#247](https://github.com/SAML-Toolkits/ruby-saml/pull/247) Avoid entity expansion (XEE attacks) * [#246](https://github.com/SAML-Toolkits/ruby-saml/pull/246) Fix bug generating Logout Response (issuer was at wrong order) * [#243](https://github.com/SAML-Toolkits/ruby-saml/issues/243) and [#244](https://github.com/SAML-Toolkits/ruby-saml/issues/244) Fix metadata builder errors. Fix metadata xsd. -* [#241](https://github.com/SAML-Toolkits/ruby-saml/pull/241) Add decrypt support (EncryptID and EncryptedAssertion). Improve compatibility with namespaces. +* [#241](https://github.com/SAML-Toolkits/ruby-saml/pull/241) Add decrypt support (EncryptID and EncryptedAssertion). Improve compatibility with namespaces. * [#240](https://github.com/SAML-Toolkits/ruby-saml/pull/240) and [#238](https://github.com/SAML-Toolkits/ruby-saml/pull/238) Improve test coverage and refactor. * [#239](https://github.com/SAML-Toolkits/ruby-saml/pull/239) Improve security: Add more validations to SAMLResponse, LogoutRequest and LogoutResponse. Refactor code and improve tests coverage. * [#237](https://github.com/SAML-Toolkits/ruby-saml/pull/237) Don't pretty print metadata by default. diff --git a/lib/ruby_saml/response.rb b/lib/ruby_saml/response.rb index a12007ea..eee85da4 100644 --- a/lib/ruby_saml/response.rb +++ b/lib/ruby_saml/response.rb @@ -201,6 +201,27 @@ def session_expires_at end end + # Gets the AuthnInstant from the AuthnStatement. + # Could be used to require re-authentication if a long time has passed + # since the last user authentication. + # @return [String] AuthnInstant value + # + def authn_instant + @authn_instant ||= begin + node = xpath_first_from_signed_assertion('/a:AuthnStatement') + node.nil? ? nil : node.attributes['AuthnInstant'] + end + end + + # Gets the AuthnContextClassRef from the AuthnStatement + # Could be used to require re-authentication if the assertion + # did not met the requested authentication context class. + # @return [String] AuthnContextClassRef value + # + def authn_context_class_ref + @authn_context_class_ref ||= Utils.element_text(xpath_first_from_signed_assertion('/a:AuthnStatement/a:AuthnContext/a:AuthnContextClassRef')) + end + # Checks if the Status has the "Success" code # @return [Boolean] True if the StatusCode is Sucess # diff --git a/test/response_test.rb b/test/response_test.rb index 2eef8464..fb443074 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -1357,6 +1357,27 @@ def generate_audience_error(expected, actual) end end + # Gets the AuthnInstant from the AuthnStatement. + # Could be used to require re-authentication if a long time has passed + # since the last user authentication. + # @return [String] AuthnInstant value + # + def authn_instant + @authn_instant ||= begin + node = xpath_first_from_signed_assertion('/a:AuthnStatement') + node.nil? ? nil : node.attributes['AuthnInstant'] + end + end + + # Gets the AuthnContextClassRef from the AuthnStatement + # Could be used to require re-authentication if the assertion + # did not met the requested authentication context class. + # @return [String] AuthnContextClassRef value + # + def authn_context_class_ref + @authn_context_class_ref ||= Utils.element_text(xpath_first_from_signed_assertion('/a:AuthnStatement/a:AuthnContext/a:AuthnContextClassRef')) + end + describe "#success" do it "find a status code that says success" do response.success? From fd86e2cc92f2ffe7149602183cd125d89b921490 Mon Sep 17 00:00:00 2001 From: Johnny Shields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 30 Sep 2024 03:21:06 -0400 Subject: [PATCH 6/7] Change error "The SPNameQualifier value mistmatch the SP entityID value." to "SPNameQualifier value does not match the SP entityID value." (#715) --- CHANGELOG.md | 3 ++- lib/ruby_saml/response.rb | 2 +- test/response_test.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f326514..b978752c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ # Ruby SAML Changelog ### 2.0.0 -* [#718](https://github.com/SAML-Toolkits/ruby-saml/pull/718/) Add support to retrieve from SAMLResponse the AuthnInstant and AuthnContextClassRef values * [#685](https://github.com/SAML-Toolkits/ruby-saml/pull/685) Remove `OneLogin` namespace. The root namespace of the gem is now `RubySaml`. * [#685](https://github.com/SAML-Toolkits/ruby-saml/pull/685) Create namespace alias `OneLogin = Object` for backward compatibility, to be removed in version `2.1.0`. * [#685](https://github.com/SAML-Toolkits/ruby-saml/pull/685) Change directly structure from `lib/onelogin/ruby-saml` to `lib/ruby_saml`. @@ -12,6 +11,8 @@ * [#690](https://github.com/SAML-Toolkits/ruby-saml/pull/690) Remove deprecated `settings.security[:embed_sign]` parameter. * [#697](https://github.com/SAML-Toolkits/ruby-saml/pull/697) Add deprecation for various parameters in `RubySaml::Settings`. * [#709](https://github.com/SAML-Toolkits/ruby-saml/pull/709) Allow passing in `Net::HTTP` `:open_timeout`, `:read_timeout`, and `:max_retries` settings to `IdpMetadataParser#parse_remote`. +* [#715](https://github.com/SAML-Toolkits/ruby-saml/pull/715) Fix typo in error when SPNameQualifier value does not match the SP entityID. +* [#718](https://github.com/SAML-Toolkits/ruby-saml/pull/718/) Add support to retrieve from SAMLResponse the AuthnInstant and AuthnContextClassRef values * [#711](https://github.com/SAML-Toolkits/ruby-saml/pull/711) Standardize how RubySaml reads and formats certificate and private_key PEM values, including the `RubySaml::Util#format_cert` and `#format_private_key` methods. ### 1.17.0 diff --git a/lib/ruby_saml/response.rb b/lib/ruby_saml/response.rb index eee85da4..f562a252 100644 --- a/lib/ruby_saml/response.rb +++ b/lib/ruby_saml/response.rb @@ -825,7 +825,7 @@ def validate_name_id end if !(settings.sp_entity_id.nil? || settings.sp_entity_id.empty? || name_id_spnamequalifier.nil? || name_id_spnamequalifier.empty?) && (name_id_spnamequalifier != settings.sp_entity_id) - return append_error("The SPNameQualifier value mistmatch the SP entityID value.") + return append_error('SPNameQualifier value does not match the SP entityID value.') end end diff --git a/test/response_test.rb b/test/response_test.rb index fb443074..fd4028a8 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -1027,7 +1027,7 @@ def generate_audience_error(expected, actual) settings.sp_entity_id = 'sp_entity_id' response_wrong_spnamequalifier.settings = settings assert !response_wrong_spnamequalifier.send(:validate_name_id) - assert_includes response_wrong_spnamequalifier.errors, "The SPNameQualifier value mistmatch the SP entityID value." + assert_includes response_wrong_spnamequalifier.errors, 'SPNameQualifier value does not match the SP entityID value.' end it "return true when no nameid element but not required by settings" do From 455d17df06347395d6d27cb05f99fb9c9edfd90f Mon Sep 17 00:00:00 2001 From: Johnny Shields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 30 Sep 2024 03:57:59 -0400 Subject: [PATCH 7/7] [READY] v2.0: Support DSA and ECDSA signing keys (#705) * v2.0 Support EC/DSA crypto --- .rubocop_todo.yml | 7 +- README.md | 7 +- UPGRADING.md | 31 +- lib/ruby_saml/authrequest.rb | 6 +- lib/ruby_saml/idp_metadata_parser.rb | 2 +- lib/ruby_saml/logoutrequest.rb | 6 +- lib/ruby_saml/metadata.rb | 2 +- lib/ruby_saml/response.rb | 4 +- lib/ruby_saml/saml_message.rb | 6 +- lib/ruby_saml/settings.rb | 43 +- lib/ruby_saml/slo_logoutresponse.rb | 6 +- lib/ruby_saml/utils.rb | 38 +- lib/ruby_saml/xml.rb | 1 + lib/ruby_saml/xml/base_document.rb | 46 +- lib/ruby_saml/xml/crypto.rb | 96 ++++ lib/ruby_saml/xml/document.rb | 61 ++- lib/ruby_saml/xml/signed_document.rb | 87 ++- test/{request_test.rb => authrequest_test.rb} | 349 ++++++------ test/helpers/certificate_helper.rb | 48 +- test/idp_metadata_parser_test.rb | 8 +- test/logoutrequest_test.rb | 402 +++++++------- test/logoutresponse_test.rb | 432 +++++++-------- test/metadata_test.rb | 151 +++--- test/response_test.rb | 90 +++- test/responses/response_unsigned2.xml | 26 + test/saml_message_test.rb | 1 - test/settings_test.rb | 263 ++++++++- test/slo_logoutrequest_test.rb | 498 +++++++++--------- test/slo_logoutresponse_test.rb | 432 ++++++++------- test/test_helper.rb | 116 ++++ test/utils_test.rb | 38 +- test/xml_test.rb | 38 +- 32 files changed, 2003 insertions(+), 1338 deletions(-) create mode 100644 lib/ruby_saml/xml/crypto.rb rename test/{request_test.rb => authrequest_test.rb} (57%) create mode 100644 test/responses/response_unsigned2.xml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e285db58..ec6b9b1d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -204,7 +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: 300 # Offense count: 1 # Configuration parameters: CountComments, CountAsOne. diff --git a/README.md b/README.md index 4d5eb8ba..77998045 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 ca0b2854..72aa7ac6 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,12 +103,14 @@ 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 | |---|------------------------------------------------------|---------------------------------------------------------|---------------------------| | 1 | Input contains a bad (e.g. non-base64) PEM | Skip PEM formatting | Return a bad PEM | @@ -113,7 +124,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 95a4433a..6aeccc10 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 7bf1cf93..f54314fb 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 a82b9393..9ebf325c 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 b76624d1..a39850d7 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 f562a252..fecf3d31 100644 --- a/lib/ruby_saml/response.rb +++ b/lib/ruby_saml/response.rb @@ -882,8 +882,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 b9dc997f..bad8c6ef 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 36badd69..1d90e887 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 b17d81f7..3cd5a215 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 e055925e..fa71174f 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,11 +250,15 @@ 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 SP 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? + if private_keys.none?(OpenSSL::PKey::RSA) + raise ArgumentError.new('private_keys must be OpenSSL::PKey::RSA keys') + end + error = nil private_keys.each do |key| return decrypt_data(encrypted_node, key) @@ -260,7 +271,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::RSA] The SP private key # @return [String] The decrypted data def decrypt_data(encrypted_node, private_key) encrypt_data = REXML::XPath.first( @@ -286,7 +297,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::RSA] The SP private key # @return [String] The symmetric key def retrieve_symmetric_key(encrypt_data, private_key) encrypted_key = REXML::XPath.first( @@ -410,5 +421,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 b6ab1b62..2a85a665 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 8cfcce26..bb6a122b 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 00000000..2ab613be --- /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 a37ab09f..4a543801 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 03a47608..8e2c7a52 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 @@ -132,44 +132,49 @@ def validate_signature(base64_cert, soft = true) # get signed info signed_info_element = REXML::XPath.first( sig_element, - "./ds:SignedInfo", - { "ds" => DSIG } + './ds:SignedInfo', + { 'ds' => RubySaml::XML::Crypto::DSIG } ) # get inclusive namespaces inclusive_namespaces = extract_inclusive_namespaces # check digests - ref = REXML::XPath.first(signed_info_element, "./ds:Reference", {"ds"=>DSIG}) + ref = REXML::XPath.first( + signed_info_element, + './ds:Reference', + { 'ds' => RubySaml::XML::Crypto::DSIG } + ) - reference_nodes = document.xpath("//*[@ID=$id]", nil, { 'id' => extract_signed_element_id }) + reference_nodes = document.xpath('//*[@ID=$id]', nil, { 'id' => extract_signed_element_id }) - if reference_nodes.length > 1 # ensures no elements with same ID to prevent signature wrapping attack. - return append_error("Digest mismatch. Duplicated ID found", soft) + # ensure no elements with same ID to prevent signature wrapping attack. + if reference_nodes.length > 1 + return append_error('Digest mismatch. Duplicated ID found', soft) end hashed_element = reference_nodes[0] - canon_algorithm = canon_algorithm REXML::XPath.first( + canon_algorithm = RubySaml::XML::Crypto.canon_algorithm(REXML::XPath.first( signed_info_element, './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:DigestMethod', + { '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)) @@ -182,9 +187,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 @@ -195,24 +203,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 @@ -226,7 +223,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? @@ -239,7 +236,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 bf47b54e..45468511 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 e826fbfb..577b7db9 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(private_key = :rsa, digest: nil, not_before: nil, not_after: nil) + key = generate_private_key(private_key) 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 1e9067ff..caad52a9 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 a1368249..82828964 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 55f41901..7185a044 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 b97b8b38..d82f8489 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 fd4028a8..ea56b435 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) } @@ -1521,7 +1520,6 @@ def authn_context_class_ref 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' @@ -1623,7 +1621,7 @@ def authn_context_class_ref 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 } ] } @@ -1746,8 +1744,8 @@ def authn_context_class_ref 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' @@ -1758,7 +1756,6 @@ def authn_context_class_ref 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' @@ -1804,5 +1801,88 @@ def authn_context_class_ref 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 00000000..c510c8ba --- /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 ef0563e3..62f48138 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 21f4b6f8..5f8a722a 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 @@ -308,80 +308,111 @@ class SettingsTest < Minitest::Test describe "#get_sp_cert" do it "returns nil when the cert is an empty string" do - @settings.certificate = "" + @settings.certificate = '' + assert_nil @settings.get_sp_cert end it "returns nil when the cert is nil" do @settings.certificate = nil + assert_nil @settings.get_sp_cert end it "returns the certificate when it is valid" do @settings.certificate = ruby_saml_cert_text + assert @settings.get_sp_cert.kind_of? OpenSSL::X509::Certificate end it "raises when the certificate is not valid" do # formatted but invalid cert @settings.certificate = read_certificate("formatted_certificate") + assert_raises(OpenSSL::X509::CertificateError) { @settings.get_sp_cert } end it "raises an error if SP certificate expired and check_sp_cert_expiration enabled" do @settings.certificate = ruby_saml_cert_text @settings.security[:check_sp_cert_expiration] = true + assert_raises(RubySaml::ValidationError) { @settings.get_sp_cert } end + + each_key_algorithm do |sp_cert_algo| + it "allows a certificate with a #{sp_cert_algo.upcase} private key" do + @settings.certificate, _= CertificateHelper.generate_cert(sp_cert_algo).to_pem + + assert @settings.get_sp_cert.kind_of? OpenSSL::X509::Certificate + end + end end describe "#get_sp_cert_new" do it "returns nil when the cert is an empty string" do - @settings.certificate_new = "" + @settings.certificate_new = '' + assert_nil @settings.get_sp_cert_new end it "returns nil when the cert is nil" do @settings.certificate_new = nil + assert_nil @settings.get_sp_cert_new end it "returns the certificate when it is valid" do @settings.certificate_new = ruby_saml_cert_text + assert @settings.get_sp_cert_new.kind_of? OpenSSL::X509::Certificate end - it "raises when the certificate is not valid" do - # formatted but invalid cert + it "raises when the certificate is formatted but invalid" do @settings.certificate_new = read_certificate("formatted_certificate") - assert_raises(OpenSSL::X509::CertificateError) { - @settings.get_sp_cert_new - } + + assert_raises(OpenSSL::X509::CertificateError) { @settings.get_sp_cert_new } + end + + each_key_algorithm do |sp_cert_algo| + it "allows a certificate with a #{sp_cert_algo.upcase} private key" do + @settings.certificate_new = CertificateHelper.generate_cert(sp_cert_algo).to_pem + + assert @settings.get_sp_cert_new.kind_of? OpenSSL::X509::Certificate + end end end describe "#get_sp_key" do it "returns nil when the private key is an empty string" do - @settings.private_key = "" + @settings.private_key = '' + assert_nil @settings.get_sp_key end it "returns nil when the private key is nil" do @settings.private_key = nil + assert_nil @settings.get_sp_key end it "returns the private key when it is valid" do @settings.private_key = ruby_saml_key_text + assert @settings.get_sp_key.kind_of? OpenSSL::PKey::RSA end - it "raises when the private key is not valid" do - # formatted but invalid rsa private key + it "raises when the private key is formatted but invalid" do @settings.private_key = read_certificate("formatted_rsa_private_key") - assert_raises(OpenSSL::PKey::RSAError) { - @settings.get_sp_key - } + + assert_raises(OpenSSL::PKey::RSAError) { @settings.get_sp_key } + end + + each_key_algorithm do |sp_cert_algo| + it "allows a #{sp_cert_algo.upcase} private key" do + @settings.private_key = CertificateHelper.generate_private_key(sp_cert_algo).to_pem + + assert @settings.get_sp_key.kind_of? expected_key_class(sp_cert_algo) + end end end @@ -420,7 +451,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 @@ -457,8 +488,31 @@ class SettingsTest < Minitest::Test expected_signing = [[cert_text1, key_text1], [cert_text2, key_text1]] expected_encryption = [[cert_text2, key_text1], [cert_text3, key_text2]] assert_equal [:signing, :encryption], actual.keys - assert_equal expected_signing, actual[:signing].map {|ary| ary.map(&:to_pem) } - assert_equal expected_encryption, actual[:encryption].map {|ary| ary.map(&:to_pem) } + assert_equal expected_signing, actual[:signing].map { |ary| ary.map(&:to_pem) } + assert_equal expected_encryption, actual[:encryption].map { |ary| ary.map(&:to_pem) } + end + + # TODO: :encryption should validate only RSA keys are used + each_key_algorithm do |sp_cert_algo| + it "sp_cert_multi allows #{sp_cert_algo.upcase} certs and private keys" do + @cert1, @pkey = CertificateHelper.generate_pair(sp_cert_algo) + @cert2 = CertificateHelper.generate_cert(@pkey) + @settings.sp_cert_multi = { + signing: [{ certificate: @cert1.to_pem, private_key: @pkey.to_pem }, + { certificate: @cert2.to_pem, private_key: @pkey.to_pem }, + { certificate: cert_text1, private_key: key_text1 }], + encryption: [{ certificate: @cert1.to_pem, private_key: @pkey.to_pem }, + { certificate: @cert2.to_pem, private_key: @pkey.to_pem }, + { certificate: cert_text2, private_key: key_text2 }] + } + + actual = @settings.get_sp_certs + expected_signing = @settings.sp_cert_multi[:signing].map { |pair| pair.values } + expected_encryption = @settings.sp_cert_multi[:encryption].map { |pair| pair.values } + assert_equal [:signing, :encryption], actual.keys + assert_equal expected_signing, actual[:signing].map { |ary| ary.map(&:to_pem) } + assert_equal expected_encryption, actual[:encryption].map { |ary| ary.map(&:to_pem) } + end end it "sp_cert_multi allows sending only signing" do @@ -570,9 +624,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 +677,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 +719,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 +765,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 f5777270..391a843f 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 421017f3..1636c303 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 3a4bb1ca..61581475 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 @@ -371,4 +438,53 @@ def validate_xml!(document, schema) def downcased_escape(str) CGI.escape(str).gsub(/%[A-Fa-f0-9]{2}/) { |match| match.downcase } end + + def encrypt_xml(assertion_xml, private_key) + # Generate a symmetric key (AES-256) + cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher.encrypt + symmetric_key = cipher.random_key + public_key = private_key.is_a?(OpenSSL::PKey::EC) ? private_key : private_key.public_key + + # Encrypt the symmetric key with the RSA public key + encrypted_symmetric_key = Base64.encode64(public_key.public_encrypt(symmetric_key)) + + # Encrypt the assertion XML with the symmetric key + cipher.key = symmetric_key + iv = cipher.random_iv + cipher.iv = iv + encrypted_assertion = cipher.update(assertion_xml) + cipher.final + + # Base64 encode the encrypted assertion and IV + encrypted_assertion_base64 = Base64.encode64(encrypted_assertion) + iv_base64 = Base64.encode64(iv) + + # Build the EncryptedAssertion XML + encrypted_assertion_xml = <<-XML + + + + + + + + #{encrypted_symmetric_key} + + + + + #{encrypted_assertion_base64} + + + + + + #{iv_base64} + + + + XML + + encrypted_assertion_xml + end end diff --git a/test/utils_test.rb b/test/utils_test.rb index 277d5e13..028ab069 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 @@ -387,6 +393,18 @@ def result(duration, reference = 0) RubySaml::Utils.decrypt_multi(encrypted, []) end end + + %i[ecdsa dsa].each do |sp_key_algo| + describe "#{sp_key_algo.upcase} private key" do + let(:non_rsa_key) { CertificateHelper.generate_private_key(sp_key_algo) } + + it 'raises unsupported error' do + assert_raises(ArgumentError, 'private_keys must be OpenSSL::PKey::RSA keys') do + RubySaml::Utils.decrypt_multi(encrypted, [non_rsa_key]) + end + end + end + end end describe '.is_cert_expired' do diff --git a/test/xml_test.rb b/test/xml_test.rb index b8a67176..c78c3907 100644 --- a/test/xml_test.rb +++ b/test/xml_test.rb @@ -86,48 +86,48 @@ class XmlTest < Minitest::Test describe "#canon_algorithm" do it "C14N_EXCLUSIVE_1_0" do canon_algorithm = Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("http://www.w3.org/2001/10/xml-exc-c14n#") - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("http://www.w3.org/2001/10/xml-exc-c14n#WithComments") - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("other") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("http://www.w3.org/2001/10/xml-exc-c14n#") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("http://www.w3.org/2001/10/xml-exc-c14n#WithComments") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("other") end it "C14N_1_0" do canon_algorithm = Nokogiri::XML::XML_C14N_1_0 - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("http://www.w3.org/TR/2001/REC-xml-c14n-20010315") - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("http://www.w3.org/TR/2001/REC-xml-c14n-20010315") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments") end it "XML_C14N_1_1" do canon_algorithm = Nokogiri::XML::XML_C14N_1_1 - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("http://www.w3.org/2006/12/xml-c14n11") - assert_equal canon_algorithm, RubySaml::XML::BaseDocument.new.canon_algorithm("http://www.w3.org/2006/12/xml-c14n11#WithComments") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("http://www.w3.org/2006/12/xml-c14n11") + assert_equal canon_algorithm, RubySaml::XML::Crypto.canon_algorithm("http://www.w3.org/2006/12/xml-c14n11#WithComments") end end describe "#algorithm" do it "SHA1" do alg = OpenSSL::Digest::SHA1 - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2000/09/xmldsig#rsa-sha1") - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2000/09/xmldsig#sha1") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2000/09/xmldsig#rsa-sha1") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2000/09/xmldsig#sha1") end it "SHA256" do alg = OpenSSL::Digest::SHA256 - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2001/04/xmldsig-more#sha256") - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("other") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2001/04/xmldsig-more#sha256") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("other") end it "SHA384" do alg = OpenSSL::Digest::SHA384 - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha384") - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2001/04/xmldsig-more#sha384") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha384") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2001/04/xmldsig-more#sha384") end it "SHA512" do alg = OpenSSL::Digest::SHA512 - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha512") - assert_equal alg, RubySaml::XML::BaseDocument.new.algorithm("http://www.w3.org/2001/04/xmldsig-more#sha512") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha512") + assert_equal alg, RubySaml::XML::Crypto.hash_algorithm("http://www.w3.org/2001/04/xmldsig-more#sha512") end end @@ -157,7 +157,7 @@ class XmlTest < Minitest::Test sha384_fingerprint = "98:FE:17:90:31:E7:68:18:8A:65:4D:DA:F5:76:E2:09:97:BE:8B:E3:7E:AA:8D:63:64:7C:0C:38:23:9A:AC:A2:EC:CE:48:A6:74:4D:E0:4C:50:80:40:B4:8D:55:14:14" assert !response_fingerprint_test.document.validate_document(sha384_fingerprint) - assert response_fingerprint_test.document.validate_document(sha384_fingerprint, true, fingerprint_alg: RubySaml::XML::Document::SHA384) + assert response_fingerprint_test.document.validate_document(sha384_fingerprint, true, :fingerprint_alg => 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) }