From 822da55a257eae8c4d204d4092c8a594869ba864 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Fri, 12 Jul 2024 00:57:46 +0900 Subject: [PATCH] Improvements --- CHANGELOG.md | 1 + UPGRADING.md | 35 +- lib/ruby_saml/pem_formatter.rb | 132 +++++-- lib/ruby_saml/utils.rb | 49 +-- test/pem_formatter_test.rb | 649 +++++++++++++++++++++++++++++++++ test/utils_test.rb | 6 +- 6 files changed, 808 insertions(+), 64 deletions(-) create mode 100644 test/pem_formatter_test.rb 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/pem_formatter.rb b/lib/ruby_saml/pem_formatter.rb index cddbd17a..7f076678 100644 --- a/lib/ruby_saml/pem_formatter.rb +++ b/lib/ruby_saml/pem_formatter.rb @@ -1,68 +1,126 @@ # frozen_string_literal: true module RubySaml - # Formats PEM-encoded X.509 certificates and private keys to - # canonical PEM format with 64-char lines and BEGIN/END headers. + # 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 one or many X.509 certificate(s) to canonical - # PEM format with 64-char lines and BEGIN/END headers. + # Formats X.509 certificate(s) to an array of strings in canonical + # RFC 7468 PEM format. # - # @param cert [String] The original certificate(s) - # @param multi [true|false] Whether to return multiple keys delimited by newline - # @return [String|nil] The formatted certificate(s) + # @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) - detect_pems(cert, 'CERTIFICATE', multi: multi) do |pem| - format_cert_single(pem) - end + 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 many private key(s) to canonical PEM format - # with 64-char lines and BEGIN/END headers. + # Formats one or multiple private key(s) to canonical RFC 7468 + # PEM format. # - # @param key [String] The original private key(s) - # @param multi [true|false] Whether to return multiple keys delimited by newline - # @return [String|nil] The formatted private key(s) + # @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) - detect_pems(key, '(?:RSA|DSA|EC|ECDSA) PRIVATE KEY', multi: multi) do |pem| - format_private_key_single(pem) - end + pem_array_to_string(format_private_key_array(key), multi: multi) end private - def detect_pems(str, label, multi: false, &block) - return if str.nil? || str.empty? - return str unless str.ascii_only? - return if str.match?(/\A\s*\z/) + 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) } - pems = str.scan(/-{5}\s*BEGIN #{label}\s*-{5}.*?-{5}\s*END #{label}\s*-{5}?/m).map(&block) + # 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 - # Try to format the original string if no pems were found - return yield(str) if pems.empty? + 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 - def format_cert_single(cert) - format_pem(cert, 'CERTIFICATE') + # 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 - def format_private_key_single(key) - algo = key.match(/((?:RSA|ECDSA|EC|DSA) )PRIVATE KEY/)&.[](1) - label = "#{algo}PRIVATE KEY" - format_pem(key, label) + # 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 - # Strips all whitespace and the BEGIN/END lines, - # then splits the string into 64-character lines, - # and re-applies BEGIN/END labels - def format_pem(str, label) - str = str.gsub(/\s|-{5}\s*(BEGIN|END) [A-Z\d\s]+-{5}/, '').scan(/.{1,64}/).join("\n") - "-----BEGIN #{label}-----\n#{str}\n-----END #{label}-----" + # 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 6fa20b26..e055925e 100644 --- a/lib/ruby_saml/utils.rb +++ b/lib/ruby_saml/utils.rb @@ -37,8 +37,7 @@ module Utils # @param cert [OpenSSL::X509::Certificate|String] The x509 certificate. # @return [true|false] Whether the certificate is expired. def is_cert_expired(cert) - cert = OpenSSL::X509::Certificate.new(cert) if cert.is_a?(String) - + cert = build_cert_object(cert) if cert.is_a?(String) cert.not_after < Time.now end @@ -47,7 +46,7 @@ def is_cert_expired(cert) # @param cert [OpenSSL::X509::Certificate|String] The x509 certificate. # @return [true|false] Whether the certificate is currently active. def is_cert_active(cert) - cert = OpenSSL::X509::Certificate.new(cert) if cert.is_a?(String) + cert = build_cert_object(cert) if cert.is_a?(String) now = Time.now cert.not_before <= now && cert.not_after >= now end @@ -87,42 +86,50 @@ def parse_duration(duration, timestamp=Time.now.utc) datetime.to_time.utc.to_i + (durHours * 3600) + (durMinutes * 60) + durSeconds end - # Formats one or many X.509 certificate(s) to canonical - # PEM format with 64-char lines and BEGIN/END headers. + # Formats one or multiple X.509 certificate(s) to canonical RFC 7468 PEM format. + # + # @note Unlike `PemFormatter#format_cert`, this method returns the original + # input string if the input cannot be parsed. # - # @param cert [String] The original certificate(s) - # @param multi [true|false] Whether to return multiple keys delimited by newline - # @return [String|nil] The formatted certificate(s) + # @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) + PemFormatter.format_cert(cert, multi: multi) || cert end - # Formats one or many private key(s) to canonical PEM format - # with 64-char lines and BEGIN/END headers. + # Formats one or multiple private key(s) to canonical RFC 7468 PEM format. + # + # @note Unlike `PemFormatter#format_private_key`, this method returns the + # original input string if the input cannot be parsed. # # @param key [String] The original private key(s) - # @param multi [true|false] Whether to return multiple keys delimited by newline - # @return [String|nil] The formatted 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) + 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 build_cert_object(cert) - return unless (pem = format_cert(cert, multi: false)) + def build_cert_object(pem) + return unless (pem = PemFormatter.format_cert(pem, multi: false)) 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 build_private_key_object(private_key) - return unless (pem = format_private_key(private_key, multi: false)) + # @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(pem) 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/utils_test.rb b/test/utils_test.rb index a8b30605..c3d64121 100644 --- a/test/utils_test.rb +++ b/test/utils_test.rb @@ -45,7 +45,7 @@ def result(duration, reference = 0) it "returns empty string when the cert is an empty string" do cert = '' - assert_nil RubySaml::Utils.format_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 @@ -94,7 +94,7 @@ def result(duration, reference = 0) it "returns empty string when the private key is an empty string" do private_key = '' - assert_nil RubySaml::Utils.format_private_key(private_key) + assert_equal '', RubySaml::Utils.format_private_key(private_key) end it "returns nil when the private key is nil" do