Skip to content

Commit f40c59b

Browse files
authored
Merge pull request #673 from johnnyshields/sp-cert-multi
Add sp_cert_multi to facilitate SP cert/key rotation
2 parents a3844d2 + a2a0002 commit f40c59b

21 files changed

+1124
-159
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
# Ruby SAML Changelog
2+
3+
### 1.17.0
4+
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) Add `Settings#sp_cert_multi` paramter to facilitate SP certificate and key rotation.
5+
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) Support multiple simultaneous SP decryption keys via `Settings#sp_cert_multi` parameter.
6+
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) Deprecate `Settings#certificate_new` parameter.
7+
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) `:check_sp_cert_expiration` will use the first non-expired certificate/key when signing/decrypting. It will raise an error only if there are no valid certificates/keys.
8+
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) `:check_sp_cert_expiration` now validates the certificate `not_before` condition; previously it was only validating `not_after`.
9+
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) `:check_sp_cert_expiration` now causes the generated SP metadata to exclude any inactive/expired certificates.
10+
211
### 1.16.0 (Oct 09, 2023)
312
* [#671](https://github.com/SAML-Toolkits/ruby-saml/pull/671) Add support on LogoutRequest with Encrypted NameID
413

514
### 1.15.0 (Jan 04, 2023)
615
* [#650](https://github.com/SAML-Toolkits/ruby-saml/pull/650) Replace strip! by strip on compute_digest method
716
* [#638](https://github.com/SAML-Toolkits/ruby-saml/pull/638) Fix dateTime format for the validUntil attribute of the generated metadata
8-
* [#576](https://github.com/SAML-Toolkits/ruby-saml/pull/576) Support idp cert multi with string keys
17+
* [#576](https://github.com/SAML-Toolkits/ruby-saml/pull/576) Support `Settings#idp_cert_multi` with string keys
918
* [#567](https://github.com/SAML-Toolkits/ruby-saml/pull/567) Improve Code quality
1019
* Add info about new repo, new maintainer, new security contact
1120
* Fix tests, Adjust dependencies, Add ruby 3.2 and new jruby versions tests to the CI. Add coveralls support

README.md

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,48 @@ validation fails. You may disable such exceptions using the `settings.security[:
735735
settings.security[:soft] = true # Do not raise error on failed signature/certificate validations
736736
```
737737
738+
#### Advanced SP Certificate Usage & Key Rollover
739+
740+
Ruby SAML provides the `settings.sp_cert_multi` parameter to enable the following
741+
advanced usage scenarios:
742+
- Rotating SP certificates and private keys without disruption of service.
743+
- Specifying separate SP certificates for signing and encryption.
744+
745+
The `sp_cert_multi` parameter replaces `certificate` and `private_key`
746+
(you may not specify both pparameters at the same time.) `sp_cert_multi` has the following shape:
747+
748+
```ruby
749+
settings.sp_cert_multi = {
750+
signing: [
751+
{ certificate: cert1, private_key: private_key1 },
752+
{ certificate: cert2, private_key: private_key2 }
753+
],
754+
encryption: [
755+
{ certificate: cert1, private_key: private_key1 },
756+
{ certificate: cert3, private_key: private_key1 }
757+
],
758+
}
759+
```
760+
761+
Certificate rotation is acheived by inserting new certificates at the bottom of each list,
762+
and then removing the old certificates from the top of the list once your IdPs have migrated.
763+
A common practice is for apps to publish the current SP metadata at a URL endpoint and have
764+
the IdP regularly poll for updates.
765+
766+
Note the following:
767+
- You may re-use the same certificate and/or private key in multiple places, including for both signing and encryption.
768+
- The IdP should attempt to verify signatures with *all* `:signing` certificates,
769+
and permit if *any one* succeeds. When signing, Ruby SAML will use the first SP certificate
770+
in the `sp_cert_multi[:signing]` array. This will be the first active/non-expired certificate
771+
in the array if `settings.security[:check_sp_cert_expiration]` is true.
772+
- The IdP may encrypt with any of the SP certificates in the `sp_cert_multi[:encryption]`
773+
array. When decrypting, Ruby SAML attempt to decrypt with each SP private key in
774+
`sp_cert_multi[:encryption]` until the decryption is successful. This will skip private
775+
keys for inactive/expired certificates if `:check_sp_cert_expiration` is true.
776+
- If `:check_sp_cert_expiration` is true, the generated SP metadata XML will not include
777+
inactive/expired certificates. This avoids validation errors when the IdP reads the SP
778+
metadata.
779+
738780
#### Audience Validation
739781
740782
A service provider should only consider a SAML response valid if the IdP includes an <AudienceRestriction>
@@ -758,29 +800,6 @@ is invalid using the `settings.security[:strict_audience_validation]` parameter.
758800
settings.security[:strict_audience_validation] = true
759801
```
760802
761-
#### Key Rollover
762-
763-
To update the SP X.509 certificate and private key without disruption of service, you may define the parameter
764-
`settings.certificate_new`. This will publish the new SP certificate in your metadata so that your IdP counterparties
765-
may cache it in preparation for rollover.
766-
767-
For example, if you to rollover from `CERT A` to `CERT B`. Before rollover, your settings should look as follows.
768-
Both `CERT A` and `CERT B` will now appear in your SP metadata, however `CERT A` will still be used for signing
769-
and encryption at this time.
770-
771-
```ruby
772-
settings.certificate = "CERT A"
773-
settings.private_key = "PRIVATE KEY FOR CERT A"
774-
settings.certificate_new = "CERT B"
775-
```
776-
777-
After the IdP has cached `CERT B`, you may then change your settings as follows:
778-
779-
```ruby
780-
settings.certificate = "CERT B"
781-
settings.private_key = "PRIVATE KEY FOR CERT B"
782-
```
783-
784803
## Single Log Out
785804
786805
Ruby SAML supports SP-initiated Single Logout and IdP-Initiated Single Logout.

lib/onelogin/ruby-saml/authrequest.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,18 @@ def create_params(settings, params={})
7272
request = deflate(request) if settings.compress_request
7373
base64_request = encode(request)
7474
request_params = {"SAMLRequest" => base64_request}
75+
sp_signing_key = settings.get_sp_signing_key
7576

76-
if settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && settings.private_key
77-
params['SigAlg'] = settings.security[:signature_method]
77+
if settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && sp_signing_key
78+
params['SigAlg'] = settings.security[:signature_method]
7879
url_string = OneLogin::RubySaml::Utils.build_query(
7980
:type => 'SAMLRequest',
8081
:data => base64_request,
8182
:relay_state => relay_state,
8283
:sig_alg => params['SigAlg']
8384
)
8485
sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method])
85-
signature = settings.get_sp_key.sign(sign_algorithm.new, url_string)
86+
signature = sp_signing_key.sign(sign_algorithm.new, url_string)
8687
params['Signature'] = encode(signature)
8788
end
8889

@@ -179,15 +180,13 @@ def create_xml_document(settings)
179180
end
180181

181182
def sign_document(document, settings)
182-
if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && settings.private_key && settings.certificate
183-
private_key = settings.get_sp_key
184-
cert = settings.get_sp_cert
183+
cert, private_key = settings.get_sp_signing_pair
184+
if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && private_key && cert
185185
document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
186186
end
187187

188188
document
189189
end
190-
191190
end
192191
end
193192
end

lib/onelogin/ruby-saml/logoutrequest.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ def create_params(settings, params={})
6969
request = deflate(request) if settings.compress_request
7070
base64_request = encode(request)
7171
request_params = {"SAMLRequest" => base64_request}
72+
sp_signing_key = settings.get_sp_signing_key
7273

73-
if settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] && settings.security[:logout_requests_signed] && settings.private_key
74+
if settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] && settings.security[:logout_requests_signed] && sp_signing_key
7475
params['SigAlg'] = settings.security[:signature_method]
7576
url_string = OneLogin::RubySaml::Utils.build_query(
7677
:type => 'SAMLRequest',
@@ -79,7 +80,7 @@ def create_params(settings, params={})
7980
:sig_alg => params['SigAlg']
8081
)
8182
sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method])
82-
signature = settings.get_sp_key.sign(sign_algorithm.new, url_string)
83+
signature = settings.get_sp_signing_key.sign(sign_algorithm.new, url_string)
8384
params['Signature'] = encode(signature)
8485
end
8586

@@ -138,9 +139,8 @@ def create_xml_document(settings)
138139

139140
def sign_document(document, settings)
140141
# embed signature
141-
if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && settings.private_key && settings.certificate
142-
private_key = settings.get_sp_key
143-
cert = settings.get_sp_cert
142+
cert, private_key = settings.get_sp_signing_pair
143+
if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && private_key && cert
144144
document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
145145
end
146146

lib/onelogin/ruby-saml/metadata.rb

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -62,29 +62,14 @@ def add_sp_sso_element(root, settings)
6262
}
6363
end
6464

65-
# Add KeyDescriptor if messages will be signed / encrypted
66-
# with SP certificate, and new SP certificate if any
65+
# Add KeyDescriptor elements for SP certificates.
6766
def add_sp_certificates(sp_sso, settings)
68-
cert = settings.get_sp_cert
69-
cert_new = settings.get_sp_cert_new
70-
71-
for sp_cert in [cert, cert_new]
72-
if sp_cert
73-
cert_text = Base64.encode64(sp_cert.to_der).gsub("\n", '')
74-
kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
75-
ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
76-
xd = ki.add_element "ds:X509Data"
77-
xc = xd.add_element "ds:X509Certificate"
78-
xc.text = cert_text
79-
80-
if settings.security[:want_assertions_encrypted]
81-
kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
82-
ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
83-
xd2 = ki2.add_element "ds:X509Data"
84-
xc2 = xd2.add_element "ds:X509Certificate"
85-
xc2.text = cert_text
86-
end
87-
end
67+
certs = settings.get_sp_certs
68+
69+
certs[:signing].each { |cert, _| add_sp_cert_element(sp_sso, cert, :signing) }
70+
71+
if settings.security[:want_assertions_encrypted]
72+
certs[:encryption].each { |cert, _| add_sp_cert_element(sp_sso, cert, :encryption) }
8873
end
8974

9075
sp_sso
@@ -153,8 +138,7 @@ def add_extras(root, _settings)
153138
def embed_signature(meta_doc, settings)
154139
return unless settings.security[:metadata_signed]
155140

156-
private_key = settings.get_sp_key
157-
cert = settings.get_sp_cert
141+
cert, private_key = settings.get_sp_signing_pair
158142
return unless private_key && cert
159143

160144
meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
@@ -172,6 +156,18 @@ def output_xml(meta_doc, pretty_print)
172156

173157
ret
174158
end
159+
160+
private
161+
162+
def add_sp_cert_element(sp_sso, cert, use)
163+
return unless cert
164+
cert_text = Base64.encode64(cert.to_der).gsub("\n", '')
165+
kd = sp_sso.add_element "md:KeyDescriptor", { "use" => use.to_s }
166+
ki = kd.add_element "ds:KeyInfo", { "xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#" }
167+
xd = ki.add_element "ds:X509Data"
168+
xc = xd.add_element "ds:X509Certificate"
169+
xc.text = cert_text
170+
end
175171
end
176172
end
177173
end

lib/onelogin/ruby-saml/response.rb

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,9 @@ def name_id_node
915915
begin
916916
encrypted_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID')
917917
if encrypted_node
918-
node = decrypt_nameid(encrypted_node)
918+
decrypt_nameid(encrypted_node)
919919
else
920-
node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
920+
xpath_first_from_signed_assertion('/a:Subject/a:NameID')
921921
end
922922
end
923923
end
@@ -969,7 +969,7 @@ def xpath_from_signed_assertion(subelt=nil)
969969
# @return [XMLSecurity::SignedDocument] The SAML Response with the assertion decrypted
970970
#
971971
def generate_decrypted_document
972-
if settings.nil? || !settings.get_sp_key
972+
if settings.nil? || settings.get_sp_decryption_keys.empty?
973973
raise ValidationError.new('An EncryptedAssertion found and no SP private key found on the settings to decrypt it. Be sure you provided the :settings parameter at the initialize method')
974974
end
975975

@@ -1012,42 +1012,42 @@ def decrypt_assertion(encrypted_assertion_node)
10121012
end
10131013

10141014
# Decrypts an EncryptedID element
1015-
# @param encryptedid_node [REXML::Element] The EncryptedID element
1015+
# @param encrypted_id_node [REXML::Element] The EncryptedID element
10161016
# @return [REXML::Document] The decrypted EncrypedtID element
10171017
#
1018-
def decrypt_nameid(encryptedid_node)
1019-
decrypt_element(encryptedid_node, /(.*<\/(\w+:)?NameID>)/m)
1018+
def decrypt_nameid(encrypted_id_node)
1019+
decrypt_element(encrypted_id_node, /(.*<\/(\w+:)?NameID>)/m)
10201020
end
10211021

1022-
# Decrypts an EncryptedID element
1023-
# @param encryptedid_node [REXML::Element] The EncryptedID element
1024-
# @return [REXML::Document] The decrypted EncrypedtID element
1022+
# Decrypts an EncryptedAttribute element
1023+
# @param encrypted_attribute_node [REXML::Element] The EncryptedAttribute element
1024+
# @return [REXML::Document] The decrypted EncryptedAttribute element
10251025
#
1026-
def decrypt_attribute(encryptedattribute_node)
1027-
decrypt_element(encryptedattribute_node, /(.*<\/(\w+:)?Attribute>)/m)
1026+
def decrypt_attribute(encrypted_attribute_node)
1027+
decrypt_element(encrypted_attribute_node, /(.*<\/(\w+:)?Attribute>)/m)
10281028
end
10291029

10301030
# Decrypt an element
1031-
# @param encryptedid_node [REXML::Element] The encrypted element
1032-
# @param rgrex string Regex
1031+
# @param encrypt_node [REXML::Element] The encrypted element
1032+
# @param regexp [Regexp] The regular expression to extract the decrypted data
10331033
# @return [REXML::Document] The decrypted element
10341034
#
1035-
def decrypt_element(encrypt_node, rgrex)
1036-
if settings.nil? || !settings.get_sp_key
1035+
def decrypt_element(encrypt_node, regexp)
1036+
if settings.nil? || settings.get_sp_decryption_keys.empty?
10371037
raise ValidationError.new('An ' + encrypt_node.name + ' found and no SP private key found on the settings to decrypt it')
10381038
end
10391039

1040-
10411040
if encrypt_node.name == 'EncryptedAttribute'
10421041
node_header = '<node xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
10431042
else
10441043
node_header = '<node xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">'
10451044
end
10461045

1047-
elem_plaintext = OneLogin::RubySaml::Utils.decrypt_data(encrypt_node, settings.get_sp_key)
1046+
elem_plaintext = OneLogin::RubySaml::Utils.decrypt_multi(encrypt_node, settings.get_sp_decryption_keys)
1047+
10481048
# If we get some problematic noise in the plaintext after decrypting.
10491049
# This quick regexp parse will grab only the Element and discard the noise.
1050-
elem_plaintext = elem_plaintext.match(rgrex)[0]
1050+
elem_plaintext = elem_plaintext.match(regexp)[0]
10511051

10521052
# To avoid namespace errors if saml namespace is not defined
10531053
# create a parent node first with the namespace defined

0 commit comments

Comments
 (0)