From 113f39adc89f5df1b16793bad9bf7b85d4bc4dfe Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Sat, 13 Jul 2024 05:39:14 +0900 Subject: [PATCH 1/2] 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. --- .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..7f076678 --- /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 3f067b145042e7396874c362602cdcde1b4571b3 Mon Sep 17 00:00:00 2001 From: Johnny Shields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 15 Jul 2024 14:22:34 +0900 Subject: [PATCH 2/2] Small change to regex --- lib/ruby_saml/pem_formatter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ruby_saml/pem_formatter.rb b/lib/ruby_saml/pem_formatter.rb index 7f076678..6a75527f 100644 --- a/lib/ruby_saml/pem_formatter.rb +++ b/lib/ruby_saml/pem_formatter.rb @@ -106,7 +106,7 @@ def format_pem_body(pem) # 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*' + 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