|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module RubySaml |
| 4 | + # Formats PEM-encoded X.509 certificates and private keys to canonical |
| 5 | + # RFC 7468 PEM format, including 64-char lines and BEGIN/END headers. |
| 6 | + # |
| 7 | + # @api private |
| 8 | + module PemFormatter |
| 9 | + extend self |
| 10 | + |
| 11 | + # Formats X.509 certificate(s) to an array of strings in canonical |
| 12 | + # RFC 7468 PEM format. |
| 13 | + # |
| 14 | + # @param certs [String|Array<String>] String(s) containing |
| 15 | + # unformatted certificate(s). |
| 16 | + # @return [Array<String>] The formatted certificate(s). |
| 17 | + def format_cert_array(certs) |
| 18 | + format_pem_array(certs, 'CERTIFICATE') |
| 19 | + end |
| 20 | + |
| 21 | + # Formats one or multiple X.509 certificate(s) to canonical |
| 22 | + # RFC 7468 PEM format. |
| 23 | + # |
| 24 | + # @param cert [String] A string containing unformatted certificate(s). |
| 25 | + # @param multi [true|false] Whether to return multiple certificates |
| 26 | + # delimited by newline. Default false. |
| 27 | + # @return [String] The formatted certificate(s). Returns nil if the |
| 28 | + # input is blank. |
| 29 | + def format_cert(cert, multi: false) |
| 30 | + pem_array_to_string(format_cert_array(cert), multi: multi) |
| 31 | + end |
| 32 | + |
| 33 | + # Formats private keys(s) to canonical RFC 7468 PEM format. |
| 34 | + # |
| 35 | + # @param keys [String|Array<String>] String(s) containing unformatted |
| 36 | + # private keys(s). |
| 37 | + # @return [Array<String>] The formatted private keys(s). |
| 38 | + def format_private_key_array(keys) |
| 39 | + format_pem_array(keys, 'PRIVATE KEY', %w[RSA ECDSA EC DSA]) |
| 40 | + end |
| 41 | + |
| 42 | + # Formats one or multiple private key(s) to canonical RFC 7468 |
| 43 | + # PEM format. |
| 44 | + # |
| 45 | + # @param key [String] A string containing unformatted private keys(s). |
| 46 | + # @param multi [true|false] Whether to return multiple keys |
| 47 | + # delimited by newline. Default false. |
| 48 | + # @return [String|nil] The formatted private key(s). Returns |
| 49 | + # nil if the input is blank. |
| 50 | + def format_private_key(key, multi: false) |
| 51 | + pem_array_to_string(format_private_key_array(key), multi: multi) |
| 52 | + end |
| 53 | + |
| 54 | + private |
| 55 | + |
| 56 | + def format_pem_array(str, label, known_prefixes = nil) |
| 57 | + return [] unless str |
| 58 | + |
| 59 | + # Normalize array input using '?' char as a delimiter |
| 60 | + str = str.is_a?(Array) ? str.map { |s| encode_utf8(s) }.join('?') : encode_utf8(str) |
| 61 | + str.strip! |
| 62 | + return [] if str.empty? |
| 63 | + |
| 64 | + # Find and format PEMs matching the desired label |
| 65 | + pems = str.scan(pem_scan_regexp(label)).map { |pem| format_pem(pem, label, known_prefixes) } |
| 66 | + |
| 67 | + # If no PEMs matched, remove non-matching PEMs then format the remaining string |
| 68 | + if pems.empty? |
| 69 | + str.gsub!(pem_scan_regexp, '') |
| 70 | + str.strip! |
| 71 | + pems = format_pem(str, label, known_prefixes).scan(pem_scan_regexp(label)) unless str.empty? |
| 72 | + end |
| 73 | + |
| 74 | + pems |
| 75 | + end |
| 76 | + |
| 77 | + def pem_array_to_string(pems, multi: false) |
| 78 | + return if pems.empty? |
| 79 | + return pems unless pems.is_a?(Array) |
| 80 | + |
| 81 | + multi ? pems.join("\n") : pems.first |
| 82 | + end |
| 83 | + |
| 84 | + # Given a PEM, a label such as "PRIVATE KEY", and a list of known prefixes |
| 85 | + # such as "RSA", "DSA", etc., returns the formatted PEM preserving the known |
| 86 | + # prefix if possible. |
| 87 | + def format_pem(pem, label, known_prefixes = nil) |
| 88 | + prefix = detect_label_prefix(pem, label, known_prefixes) |
| 89 | + label = "#{prefix} #{label}" if prefix |
| 90 | + "-----BEGIN #{label}-----\n#{format_pem_body(pem)}\n-----END #{label}-----" |
| 91 | + end |
| 92 | + |
| 93 | + # Given a PEM, a label such as "PRIVATE KEY", and a list of known prefixes |
| 94 | + # such as "RSA", "DSA", etc., detects and returns the known prefix if it exists. |
| 95 | + def detect_label_prefix(pem, label, known_prefixes) |
| 96 | + return unless known_prefixes && !known_prefixes.empty? |
| 97 | + |
| 98 | + pem.match(/(#{Array(known_prefixes).join('|')})\s+#{label.gsub(' ', '\s+')}/)&.[](1) |
| 99 | + end |
| 100 | + |
| 101 | + # Given a PEM, strips all whitespace and the BEGIN/END lines, |
| 102 | + # then splits the body into 64-character lines. |
| 103 | + def format_pem_body(pem) |
| 104 | + pem.gsub(/\s|#{pem_scan_header}/, '').scan(/.{1,64}/).join("\n") |
| 105 | + end |
| 106 | + |
| 107 | + # Returns a regexp which can be used to loosely match unformatted PEM(s) in a string. |
| 108 | + def pem_scan_regexp(label = nil) |
| 109 | + base64 = '[A-Za-z\d+/\s]*[A-Za-z\d+][A-Za-z\d+/\s]*=?\s*=?\s*' |
| 110 | + /#{pem_scan_header('BEGIN', label)}#{base64}#{pem_scan_header('END', label)}/m |
| 111 | + end |
| 112 | + |
| 113 | + # Returns a regexp component string to match PEM headers. |
| 114 | + def pem_scan_header(marker = nil, label = nil) |
| 115 | + marker ||= '(BEGIN|END)' |
| 116 | + label ||= '[A-Z\d]+' |
| 117 | + "-{5}\\s*#{marker}\\s(?:[A-Z\\d\\s]*\\s)?#{label.gsub(' ', '\s+')}\\s*-{5}" |
| 118 | + end |
| 119 | + |
| 120 | + # Encode to UTF-8 using '?' as a delimiter so that non-ASCII chars |
| 121 | + # appearing inside a PEM will cause the PEM to be considered invalid. |
| 122 | + def encode_utf8(str) |
| 123 | + str.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') |
| 124 | + end |
| 125 | + end |
| 126 | +end |
0 commit comments