Skip to content

Commit 3333f0a

Browse files
[READY] v2.0 - Consistently format cert/private key PEMs (#711)
* Standardize how RubySaml reads and formats certificate and private_key PEM values, including the `RubySaml::Util#format_cert` and `#format_private_key` methods. Introduces new `RubySaml::PemFormatter` module. * Small change to regex
1 parent a7b1f2e commit 3333f0a

File tree

12 files changed

+913
-128
lines changed

12 files changed

+913
-128
lines changed

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ AllCops:
1616
- 'tmp/**/*'
1717
- 'vendor/**/*'
1818

19+
Style/ModuleFunction:
20+
EnforcedStyle: extend_self
21+
1922
Style/NumericPredicate:
2023
EnforcedStyle: comparison
2124

.rubocop_todo.yml

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2024-07-10 16:10:44 UTC using RuboCop version 1.64.1.
3+
# on 2024-07-11 13:04:30 UTC using RuboCop version 1.64.1.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
@@ -127,7 +127,7 @@ Layout/SpaceAroundOperators:
127127
- 'lib/ruby_saml/xml/document.rb'
128128
- 'lib/ruby_saml/xml/signed_document.rb'
129129

130-
# Offense count: 5
130+
# Offense count: 3
131131
# This cop supports safe autocorrection (--autocorrect).
132132
# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters.
133133
# SupportedStyles: space, no_space
@@ -180,7 +180,7 @@ Lint/UselessAssignment:
180180
Exclude:
181181
- 'lib/ruby_saml/slo_logoutrequest.rb'
182182

183-
# Offense count: 42
183+
# Offense count: 41
184184
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
185185
Metrics/AbcSize:
186186
Max: 100
@@ -191,21 +191,26 @@ Metrics/AbcSize:
191191
Metrics/BlockLength:
192192
Max: 27
193193

194-
# Offense count: 9
194+
# Offense count: 8
195195
# Configuration parameters: CountComments, CountAsOne.
196196
Metrics/ClassLength:
197197
Max: 652
198198

199-
# Offense count: 25
199+
# Offense count: 26
200200
# Configuration parameters: AllowedMethods, AllowedPatterns.
201201
Metrics/CyclomaticComplexity:
202202
Max: 21
203203

204-
# Offense count: 59
204+
# Offense count: 58
205205
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
206206
Metrics/MethodLength:
207207
Max: 63
208208

209+
# Offense count: 1
210+
# Configuration parameters: CountComments, CountAsOne.
211+
Metrics/ModuleLength:
212+
Max: 244
213+
209214
# Offense count: 2
210215
# Configuration parameters: Max, CountKeywordArgs.
211216
Metrics/ParameterLists:
@@ -279,22 +284,20 @@ Performance/RedundantEqualityComparisonBlock:
279284
Exclude:
280285
- 'lib/ruby_saml/settings.rb'
281286

282-
# Offense count: 5
287+
# Offense count: 3
283288
# This cop supports unsafe autocorrection (--autocorrect-all).
284289
Performance/StringInclude:
285290
Exclude:
286291
- 'lib/ruby_saml/authrequest.rb'
287292
- 'lib/ruby_saml/logoutrequest.rb'
288293
- 'lib/ruby_saml/slo_logoutresponse.rb'
289-
- 'lib/ruby_saml/utils.rb'
290294

291-
# Offense count: 8
295+
# Offense count: 4
292296
# This cop supports safe autocorrection (--autocorrect).
293297
Performance/StringReplacement:
294298
Exclude:
295299
- 'lib/ruby_saml/metadata.rb'
296300
- 'lib/ruby_saml/saml_message.rb'
297-
- 'lib/ruby_saml/utils.rb'
298301
- 'lib/ruby_saml/xml/document.rb'
299302

300303
# Offense count: 48
@@ -409,14 +412,6 @@ Style/IfUnlessModifier:
409412
- 'lib/ruby_saml/xml/document.rb'
410413
- 'lib/ruby_saml/xml/signed_document.rb'
411414

412-
# Offense count: 1
413-
# This cop supports unsafe autocorrection (--autocorrect-all).
414-
# Configuration parameters: EnforcedStyle, Autocorrect.
415-
# SupportedStyles: module_function, extend_self, forbidden
416-
Style/ModuleFunction:
417-
Exclude:
418-
- 'lib/ruby_saml/logging.rb'
419-
420415
# Offense count: 16
421416
# Configuration parameters: AllowedMethods.
422417
# AllowedMethods: respond_to_missing?
@@ -432,18 +427,11 @@ Style/OptionalBooleanParameter:
432427
- 'lib/ruby_saml/utils.rb'
433428
- 'lib/ruby_saml/xml/signed_document.rb'
434429

435-
# Offense count: 1
436-
# This cop supports safe autocorrection (--autocorrect).
437-
Style/RedundantBegin:
438-
Exclude:
439-
- 'lib/ruby_saml/utils.rb'
440-
441-
# Offense count: 8
430+
# Offense count: 3
442431
# This cop supports safe autocorrection (--autocorrect).
443432
Style/RedundantRegexpArgument:
444433
Exclude:
445434
- 'lib/ruby_saml/saml_message.rb'
446-
- 'lib/ruby_saml/utils.rb'
447435
- 'lib/ruby_saml/xml/document.rb'
448436

449437
# Offense count: 3
@@ -472,7 +460,7 @@ Style/StringConcatenation:
472460
- 'lib/ruby_saml/saml_message.rb'
473461
- 'lib/ruby_saml/slo_logoutrequest.rb'
474462

475-
# Offense count: 351
463+
# Offense count: 339
476464
# This cop supports safe autocorrection (--autocorrect).
477465
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
478466
# SupportedStyles: single_quotes, double_quotes
@@ -509,7 +497,7 @@ Style/SymbolArray:
509497
Exclude:
510498
- 'lib/ruby_saml/settings.rb'
511499

512-
# Offense count: 92
500+
# Offense count: 104
513501
# This cop supports safe autocorrection (--autocorrect).
514502
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns.
515503
# URISchemes: http, https

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* [#690](https://github.com/SAML-Toolkits/ruby-saml/pull/690) Remove deprecated `settings.security[:embed_sign]` parameter.
1212
* [#697](https://github.com/SAML-Toolkits/ruby-saml/pull/697) Add deprecation for various parameters in `RubySaml::Settings`.
1313
* [#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`.
14+
* [#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.
1415

1516
### 1.17.0
1617
* [#687](https://github.com/SAML-Toolkits/ruby-saml/pull/687) Add CI coverage for Ruby 3.3 and Windows.

UPGRADING.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ settings.security[:digest_method] = RubySaml::XML::Document::SHA1
5050
settings.security[:signature_method] = RubySaml::XML::Document::RSA_SHA1
5151
```
5252

53-
### Removal of embed_sign Setting
53+
### Removal of embed_sign setting
5454

5555
The deprecated `settings.security[:embed_sign]` parameter has been removed. If you were using it, please instead switch
5656
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
6868

6969
For clarity, the default value of both parameters is `:redirect` if they are not set.
7070

71-
### Deprecation of Compression Settings
71+
### Deprecation of compression settings
7272

7373
The `settings.compress_request` and `settings.compress_response` parameters have been deprecated
7474
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
8080
"compression" is used to make redirect URLs which contain SAML messages be shorter. For POST messages,
8181
compression may be achieved by enabling `Content-Encoding: gzip` on your webserver.
8282

83-
## Settings deprecations
83+
### Other settings deprecations
8484

8585
The following parameters in `RubySaml::Settings` are deprecated and will be removed in RubySaml 2.1.0:
8686

@@ -92,6 +92,35 @@ The following parameters in `RubySaml::Settings` are deprecated and will be remo
9292
- `#certificate_new` is deprecated and replaced by `#sp_cert_multi`. Refer to documentation as `#sp_cert_multi`
9393
has a different value type than `#certificate_new`.
9494

95+
### Minor changes to Util#format_cert and #format_private_key
96+
97+
Version 2.0.0 standardizes how RubySaml reads and formats certificate and private key
98+
PEM strings. In general, version 2.0.0 is more permissive than 1.x, and the changes
99+
are not anticipated to affect most users. Please note the change affects parameters
100+
such `#idp_cert` and `#certificate`, as well as the `RubySaml::Util#format_cert`
101+
and `#format_private_key` methods. Specifically:
102+
103+
| # | Input value | RubySaml 2.0.0 | RubySaml 1.x |
104+
|---|------------------------------------------------------|---------------------------------------------------------|---------------------------|
105+
| 1 | Input contains a bad (e.g. non-base64) PEM | Skip PEM formatting | Return a bad PEM |
106+
| 2 | Input contains `\r` character(s) | Strip out all `\r` character(s) and format as PEM | Skip PEM formatting |
107+
| 3 | PEM header other than `CERTIFICATE` or `PRIVATE KEY` | Format if header ends in `CERTIFICATE` or `PRIVATE KEY` | Skip PEM formatting |
108+
| 4 | `#format_cert` given `PRIVATE KEY` (and vice-versa) | Ignore PEMs of incorrect type | Return a bad PEM |
109+
| 5 | Text outside header/footer values | Strip out text outside header/footer values | Skip PEM formatting |
110+
| 6 | Input non-ASCII characters | Ignore non-ASCII chars if they are outside the PEM | Skip PEM formatting |
111+
| 7 | `#format_cert` input contains mix of good/bad certs | Return only good cert PEMs (joined with `\n`) | Return good and bad certs |
112+
113+
**Notes**
114+
- Case 3: For example, `-----BEGIN TRUSTED X509 CERTIFICATE-----` is now
115+
considered a valid header as an input, but it will be formatted to
116+
`-----BEGIN CERTIFICATE-----` in the output. As a special case, in both 2.0.0
117+
and 1.x, if `RSA PRIVATE KEY` is present in the input string, the `RSA` prefix will
118+
be preserved in the output.
119+
- Case 5: When formatting multiple certificates in one string (i.e. a certificate chain),
120+
text present between the footer and header of two different certificates will also be
121+
stripped out.
122+
- Case 7: If no valid certificates are found, the entire original string will be returned.
123+
95124
## Updating from 1.12.x to 1.13.0
96125

97126
Version `1.13.0` adds `settings.idp_sso_service_binding` and `settings.idp_slo_service_binding`, and

lib/ruby_saml.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
require 'ruby_saml/validation_error'
1717
require 'ruby_saml/metadata'
1818
require 'ruby_saml/idp_metadata_parser'
19+
require 'ruby_saml/pem_formatter'
1920
require 'ruby_saml/utils'
2021
require 'ruby_saml/version'
2122

lib/ruby_saml/pem_formatter.rb

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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

Comments
 (0)