Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sp_cert_multi to facilitate SP cert/key rotation #673

Merged
merged 1 commit into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
# Ruby SAML Changelog

### 1.17.0
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) Add `Settings#sp_cert_multi` paramter to facilitate SP certificate and key rotation.
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) Support multiple simultaneous SP decryption keys via `Settings#sp_cert_multi` parameter.
* [#673](https://github.com/SAML-Toolkits/ruby-saml/pull/673) Deprecate `Settings#certificate_new` parameter.
* [#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.
* [#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`.
* [#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.

### 1.16.0 (Oct 09, 2023)
* [#671](https://github.com/SAML-Toolkits/ruby-saml/pull/671) Add support on LogoutRequest with Encrypted NameID

### 1.15.0 (Jan 04, 2023)
* [#650](https://github.com/SAML-Toolkits/ruby-saml/pull/650) Replace strip! by strip on compute_digest method
* [#638](https://github.com/SAML-Toolkits/ruby-saml/pull/638) Fix dateTime format for the validUntil attribute of the generated metadata
* [#576](https://github.com/SAML-Toolkits/ruby-saml/pull/576) Support idp cert multi with string keys
* [#576](https://github.com/SAML-Toolkits/ruby-saml/pull/576) Support `Settings#idp_cert_multi` with string keys
* [#567](https://github.com/SAML-Toolkits/ruby-saml/pull/567) Improve Code quality
* Add info about new repo, new maintainer, new security contact
* Fix tests, Adjust dependencies, Add ruby 3.2 and new jruby versions tests to the CI. Add coveralls support
Expand Down
65 changes: 42 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,48 @@ validation fails. You may disable such exceptions using the `settings.security[:
settings.security[:soft] = true # Do not raise error on failed signature/certificate validations
```

#### Advanced SP Certificate Usage & Key Rollover

Ruby SAML provides the `settings.sp_cert_multi` parameter to enable the following
advanced usage scenarios:
- Rotating SP certificates and private keys without disruption of service.
- Specifying separate SP certificates for signing and encryption.

The `sp_cert_multi` parameter replaces `certificate` and `private_key`
(you may not specify both pparameters at the same time.) `sp_cert_multi` has the following shape:

```ruby
settings.sp_cert_multi = {
signing: [
{ certificate: cert1, private_key: private_key1 },
{ certificate: cert2, private_key: private_key2 }
],
encryption: [
{ certificate: cert1, private_key: private_key1 },
{ certificate: cert3, private_key: private_key1 }
],
}
```

Certificate rotation is acheived by inserting new certificates at the bottom of each list,
and then removing the old certificates from the top of the list once your IdPs have migrated.
A common practice is for apps to publish the current SP metadata at a URL endpoint and have
the IdP regularly poll for updates.

Note the following:
- You may re-use the same certificate and/or private key in multiple places, including for both signing and encryption.
- The IdP should attempt to verify signatures with *all* `:signing` certificates,
and permit if *any one* succeeds. When signing, Ruby SAML will use the first SP certificate
in the `sp_cert_multi[:signing]` array. This will be the first active/non-expired certificate
in the array if `settings.security[:check_sp_cert_expiration]` is true.
- The IdP may encrypt with any of the SP certificates in the `sp_cert_multi[:encryption]`
array. When decrypting, Ruby SAML attempt to decrypt with each SP private key in
`sp_cert_multi[:encryption]` until the decryption is successful. This will skip private
keys for inactive/expired certificates if `:check_sp_cert_expiration` is true.
- If `:check_sp_cert_expiration` is true, the generated SP metadata XML will not include
inactive/expired certificates. This avoids validation errors when the IdP reads the SP
metadata.

#### Audience Validation

A service provider should only consider a SAML response valid if the IdP includes an <AudienceRestriction>
Expand All @@ -758,29 +800,6 @@ is invalid using the `settings.security[:strict_audience_validation]` parameter.
settings.security[:strict_audience_validation] = true
```

#### Key Rollover

To update the SP X.509 certificate and private key without disruption of service, you may define the parameter
`settings.certificate_new`. This will publish the new SP certificate in your metadata so that your IdP counterparties
may cache it in preparation for rollover.

For example, if you to rollover from `CERT A` to `CERT B`. Before rollover, your settings should look as follows.
Both `CERT A` and `CERT B` will now appear in your SP metadata, however `CERT A` will still be used for signing
and encryption at this time.

```ruby
settings.certificate = "CERT A"
settings.private_key = "PRIVATE KEY FOR CERT A"
settings.certificate_new = "CERT B"
```

After the IdP has cached `CERT B`, you may then change your settings as follows:

```ruby
settings.certificate = "CERT B"
settings.private_key = "PRIVATE KEY FOR CERT B"
```

## Single Log Out

Ruby SAML supports SP-initiated Single Logout and IdP-Initiated Single Logout.
Expand Down
13 changes: 6 additions & 7 deletions lib/onelogin/ruby-saml/authrequest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,18 @@ def create_params(settings, params={})
request = deflate(request) if settings.compress_request
base64_request = encode(request)
request_params = {"SAMLRequest" => base64_request}
sp_signing_key = settings.get_sp_signing_key

if settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && settings.private_key
params['SigAlg'] = settings.security[:signature_method]
if settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && sp_signing_key
params['SigAlg'] = settings.security[:signature_method]
url_string = OneLogin::RubySaml::Utils.build_query(
:type => 'SAMLRequest',
:data => base64_request,
:relay_state => relay_state,
:sig_alg => params['SigAlg']
)
sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method])
signature = settings.get_sp_key.sign(sign_algorithm.new, url_string)
signature = sp_signing_key.sign(sign_algorithm.new, url_string)
params['Signature'] = encode(signature)
end

Expand Down Expand Up @@ -179,15 +180,13 @@ def create_xml_document(settings)
end

def sign_document(document, settings)
if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && settings.private_key && settings.certificate
private_key = settings.get_sp_key
cert = settings.get_sp_cert
cert, private_key = settings.get_sp_signing_pair
if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && private_key && cert
document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
end

document
end

end
end
end
10 changes: 5 additions & 5 deletions lib/onelogin/ruby-saml/logoutrequest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ def create_params(settings, params={})
request = deflate(request) if settings.compress_request
base64_request = encode(request)
request_params = {"SAMLRequest" => base64_request}
sp_signing_key = settings.get_sp_signing_key

if settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] && settings.security[:logout_requests_signed] && settings.private_key
if settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] && settings.security[:logout_requests_signed] && sp_signing_key
params['SigAlg'] = settings.security[:signature_method]
url_string = OneLogin::RubySaml::Utils.build_query(
:type => 'SAMLRequest',
Expand All @@ -79,7 +80,7 @@ def create_params(settings, params={})
:sig_alg => params['SigAlg']
)
sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method])
signature = settings.get_sp_key.sign(sign_algorithm.new, url_string)
signature = settings.get_sp_signing_key.sign(sign_algorithm.new, url_string)
params['Signature'] = encode(signature)
end

Expand Down Expand Up @@ -138,9 +139,8 @@ def create_xml_document(settings)

def sign_document(document, settings)
# embed signature
if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && settings.private_key && settings.certificate
private_key = settings.get_sp_key
cert = settings.get_sp_cert
cert, private_key = settings.get_sp_signing_pair
if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && private_key && cert
document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
end

Expand Down
44 changes: 20 additions & 24 deletions lib/onelogin/ruby-saml/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,29 +62,14 @@ def add_sp_sso_element(root, settings)
}
end

# Add KeyDescriptor if messages will be signed / encrypted
# with SP certificate, and new SP certificate if any
# Add KeyDescriptor elements for SP certificates.
def add_sp_certificates(sp_sso, settings)
cert = settings.get_sp_cert
cert_new = settings.get_sp_cert_new

for sp_cert in [cert, cert_new]
if sp_cert
cert_text = Base64.encode64(sp_cert.to_der).gsub("\n", '')
kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
xd = ki.add_element "ds:X509Data"
xc = xd.add_element "ds:X509Certificate"
xc.text = cert_text

if settings.security[:want_assertions_encrypted]
kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
xd2 = ki2.add_element "ds:X509Data"
xc2 = xd2.add_element "ds:X509Certificate"
xc2.text = cert_text
end
end
certs = settings.get_sp_certs

certs[:signing].each { |cert, _| add_sp_cert_element(sp_sso, cert, :signing) }

if settings.security[:want_assertions_encrypted]
certs[:encryption].each { |cert, _| add_sp_cert_element(sp_sso, cert, :encryption) }
end

sp_sso
Expand Down Expand Up @@ -153,8 +138,7 @@ def add_extras(root, _settings)
def embed_signature(meta_doc, settings)
return unless settings.security[:metadata_signed]

private_key = settings.get_sp_key
cert = settings.get_sp_cert
cert, private_key = settings.get_sp_signing_pair
return unless private_key && cert

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

ret
end

private

def add_sp_cert_element(sp_sso, cert, use)
return unless cert
cert_text = Base64.encode64(cert.to_der).gsub("\n", '')
kd = sp_sso.add_element "md:KeyDescriptor", { "use" => use.to_s }
ki = kd.add_element "ds:KeyInfo", { "xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#" }
xd = ki.add_element "ds:X509Data"
xc = xd.add_element "ds:X509Certificate"
xc.text = cert_text
end
end
end
end
36 changes: 18 additions & 18 deletions lib/onelogin/ruby-saml/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -915,9 +915,9 @@ def name_id_node
begin
encrypted_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID')
if encrypted_node
node = decrypt_nameid(encrypted_node)
decrypt_nameid(encrypted_node)
else
node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
xpath_first_from_signed_assertion('/a:Subject/a:NameID')
end
end
end
Expand Down Expand Up @@ -969,7 +969,7 @@ def xpath_from_signed_assertion(subelt=nil)
# @return [XMLSecurity::SignedDocument] The SAML Response with the assertion decrypted
#
def generate_decrypted_document
if settings.nil? || !settings.get_sp_key
if settings.nil? || settings.get_sp_decryption_keys.empty?
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')
end

Expand Down Expand Up @@ -1012,42 +1012,42 @@ def decrypt_assertion(encrypted_assertion_node)
end

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

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

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


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

elem_plaintext = OneLogin::RubySaml::Utils.decrypt_data(encrypt_node, settings.get_sp_key)
elem_plaintext = OneLogin::RubySaml::Utils.decrypt_multi(encrypt_node, settings.get_sp_decryption_keys)

# If we get some problematic noise in the plaintext after decrypting.
# This quick regexp parse will grab only the Element and discard the noise.
elem_plaintext = elem_plaintext.match(rgrex)[0]
elem_plaintext = elem_plaintext.match(regexp)[0]

# To avoid namespace errors if saml namespace is not defined
# create a parent node first with the namespace defined
Expand Down
Loading
Loading