Skip to content

Commit

Permalink
v2.0 Support EC/DSA crypto
Browse files Browse the repository at this point in the history
  • Loading branch information
johnnyshields committed Jul 13, 2024
1 parent 113f39a commit 2ccb12b
Show file tree
Hide file tree
Showing 28 changed files with 1,131 additions and 877 deletions.
2 changes: 1 addition & 1 deletion .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Metrics/MethodLength:
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
Max: 244
Max: 300

# Offense count: 2
# Configuration parameters: Max, CountKeywordArgs.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ In the above there are a few assumptions, one being that `response.nameid` is an
This is all handled with how you specify the settings that are in play via the `saml_settings` method.
That could be implemented along the lines of this:
```
```ruby
response = RubySaml::Response.new(params[:SAMLResponse])
response.settings = saml_settings
```
Expand Down Expand Up @@ -759,6 +759,11 @@ Note the following:
inactive/expired certificates. This avoids validation errors when the IdP reads the SP
metadata.
#### Key Algorithm Support
Ruby SAML supports RSA, DSA, and ECDSA keys for both SP and IdP certificates.
JRuby cannot support ECDSA due to a [known issue](https://github.com/jruby/jruby-openssl/issues/257).
#### Audience Validation
A service provider should only consider a SAML response valid if the IdP includes an <AudienceRestriction>
Expand Down
6 changes: 3 additions & 3 deletions lib/ruby_saml/authrequest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ def create_params(settings, params={})
sp_signing_key = settings.get_sp_signing_key

if binding_redirect && settings.security[:authn_requests_signed] && sp_signing_key
params['SigAlg'] = settings.security[:signature_method]
params['SigAlg'] = settings.get_sp_signature_method
url_string = RubySaml::Utils.build_query(
type: 'SAMLRequest',
data: base64_request,
relay_state: relay_state,
sig_alg: params['SigAlg']
)
sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method])
sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method)
signature = sp_signing_key.sign(sign_algorithm.new, url_string)
params['Signature'] = encode(signature)
end
Expand Down Expand Up @@ -185,7 +185,7 @@ def create_xml_document(settings)
def sign_document(document, settings)
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])
document.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method)
end

document
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_saml/idp_metadata_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def fingerprint(certificate, fingerprint_algorithm = RubySaml::XML::Document::SH

cert = OpenSSL::X509::Certificate.new(Base64.decode64(certificate))

fingerprint_alg = RubySaml::XML::BaseDocument.new.algorithm(fingerprint_algorithm).new
fingerprint_alg = RubySaml::XML::Crypto.hash_algorithm(fingerprint_algorithm).new
fingerprint_alg.hexdigest(cert.to_der).upcase.scan(/../).join(":")
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/ruby_saml/logoutrequest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,14 @@ def create_params(settings, params={})
sp_signing_key = settings.get_sp_signing_key

if binding_redirect && settings.security[:logout_requests_signed] && sp_signing_key
params['SigAlg'] = settings.security[:signature_method]
params['SigAlg'] = settings.get_sp_signature_method
url_string = RubySaml::Utils.build_query(
type: 'SAMLRequest',
data: base64_request,
relay_state: relay_state,
sig_alg: params['SigAlg']
)
sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method])
sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method)
signature = settings.get_sp_signing_key.sign(sign_algorithm.new, url_string)
params['Signature'] = encode(signature)
end
Expand Down Expand Up @@ -144,7 +144,7 @@ def sign_document(document, settings)
# embed signature
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])
document.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method)
end

document
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_saml/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def embed_signature(meta_doc, settings)
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])
meta_doc.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method)
end

def output_xml(meta_doc, pretty_print)
Expand Down
44 changes: 41 additions & 3 deletions lib/ruby_saml/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def get_fingerprint
idp_cert_fingerprint || begin
idp_cert = get_idp_cert
if idp_cert
fingerprint_alg = RubySaml::XML::BaseDocument.new.algorithm(idp_cert_fingerprint_algorithm).new
fingerprint_alg = RubySaml::XML::Crypto.hash_algorithm(idp_cert_fingerprint_algorithm).new
fingerprint_alg.hexdigest(idp_cert.to_der).upcase.scan(/../).join(":")
end
end
Expand Down Expand Up @@ -159,7 +159,7 @@ def get_idp_cert_multi
certs
end

# @return [Hash<Symbol, Array<Array<OpenSSL::X509::Certificate, OpenSSL::PKey::RSA>>>]
# @return [Hash<Symbol, Array<Array<OpenSSL::X509::Certificate, OpenSSL::PKey::PKey>>>]
# Build the SP certificates and private keys from the settings. If
# check_sp_cert_expiration is true, only returns certificates and private keys
# that are not expired.
Expand All @@ -179,7 +179,7 @@ def get_sp_certs
active_certs.freeze
end

# @return [Array<OpenSSL::X509::Certificate, OpenSSL::PKey::RSA>]
# @return [Array<OpenSSL::X509::Certificate, OpenSSL::PKey::PKey>]
# The SP signing certificate and private key.
def get_sp_signing_pair
get_sp_certs[:signing].first
Expand Down Expand Up @@ -267,6 +267,44 @@ def get_binding(value)
end
end

# @return [String] The XML Signature Algorithm attribute.
#
# This method is intentionally hacky for backwards compatibility of the
# settings.security[:signature_method] parameter. Previously, this parameter
# could have a value such as "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
# which assumes the public key type RSA. To add support for DSA and ECDSA, we will now
# ignore the "rsa-" prefix and only use the "sha256" hash algorithm component.
def get_sp_signature_method
sig_alg = security[:signature_method] || 'sha1' # TODO: change to sha256 by default
hash_alg = sig_alg.to_s.match(/(?:\A|[#_-])(sha\d+)\z/i)[1]
key_alg = case get_sp_signing_key
when OpenSSL::PKey::RSA then 'RSA'
when OpenSSL::PKey::DSA then 'DSA'
when OpenSSL::PKey::EC then 'ECDSA'
else # rubocop:disable Lint/DuplicateBranch
# raise ArgumentError.new("Unsupported signing key type: #{get_sp_signing_key.class}")
'RSA'
end

begin
RubySaml::XML::Crypto.const_get("#{key_alg}_#{hash_alg}".upcase)
rescue NameError
raise ArgumentError.new("Unsupported signature method: #{sig_alg}")
end
end

# @return [String] The XML Signature Digest attribute.
def get_sp_digest_method
digest_alg = security[:digest_method] || 'sha1' # TODO: change to sha256 by default
alg = digest_alg.to_s.match(/(?:\A|#)(sha\d+)\z/i)[1]

begin
RubySaml::XML::Crypto.const_get(alg.upcase)
rescue NameError
raise ArgumentError.new("Unsupported signature method: #{digest_alg}")
end
end

# @deprecated Will be removed in v2.1.0
def certificate_new
certificate_new_deprecation
Expand Down
6 changes: 3 additions & 3 deletions lib/ruby_saml/slo_logoutresponse.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,14 @@ def create_params(settings, request_id = nil, logout_message = nil, params = {},
sp_signing_key = settings.get_sp_signing_key

if binding_redirect && settings.security[:logout_responses_signed] && sp_signing_key
params['SigAlg'] = settings.security[:signature_method]
params['SigAlg'] = settings.get_sp_signature_method
url_string = RubySaml::Utils.build_query(
type: 'SAMLResponse',
data: base64_response,
relay_state: relay_state,
sig_alg: params['SigAlg']
)
sign_algorithm = RubySaml::XML::BaseDocument.new.algorithm(settings.security[:signature_method])
sign_algorithm = RubySaml::XML::Crypto.hash_algorithm(settings.get_sp_signature_method)
signature = sp_signing_key.sign(sign_algorithm.new, url_string)
params['Signature'] = encode(signature)
end
Expand Down Expand Up @@ -155,7 +155,7 @@ def sign_document(document, settings)
# embed signature
cert, private_key = settings.get_sp_signing_pair
if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && private_key && cert
document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
document.sign_document(private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method)
end

document
Expand Down
32 changes: 25 additions & 7 deletions lib/ruby_saml/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,21 @@ def build_cert_object(pem)
OpenSSL::X509::Certificate.new(pem)
end

# Given a private key string, return an OpenSSL::PKey::RSA object.
# Given a private key string, return an OpenSSL::PKey::PKey object.
#
# @param pem [String] The original private key.
# @return [OpenSSL::PKey::RSA] The private key object.
# @return [OpenSSL::PKey::PKey] 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)
error = nil
private_key_classes(pem).each do |key_class|
return key_class.new(pem)
rescue OpenSSL::PKey::PKeyError => e
error ||= e
end

raise error
end

# Build the Query String signature that will be used in the HTTP-Redirect binding
Expand Down Expand Up @@ -213,7 +220,7 @@ def escape_request_param(param, lowercase_url_encoding)
#
def verify_signature(params)
cert, sig_alg, signature, query_string = %i[cert sig_alg signature query_string].map { |k| params[k]}
signature_algorithm = RubySaml::XML::BaseDocument.new.algorithm(sig_alg)
signature_algorithm = RubySaml::XML::Crypto.hash_algorithm(sig_alg)
cert.public_key.verify(signature_algorithm.new, Base64.decode64(signature), query_string)
end

Expand Down Expand Up @@ -243,7 +250,7 @@ def status_error_msg(error_msg, raw_status_code = nil, status_message = nil)
# Obtains the decrypted string from an Encrypted node element in XML,
# given multiple private keys to try.
# @param encrypted_node [REXML::Element] The Encrypted element
# @param private_keys [Array<OpenSSL::PKey::RSA>] The Service provider private key
# @param private_keys [Array<OpenSSL::PKey::PKey>] The Service provider private key
# @return [String] The decrypted data
def decrypt_multi(encrypted_node, private_keys)
raise ArgumentError.new('private_keys must be specified') if !private_keys || private_keys.empty?
Expand All @@ -260,7 +267,7 @@ def decrypt_multi(encrypted_node, private_keys)

# Obtains the decrypted string from an Encrypted node element in XML
# @param encrypted_node [REXML::Element] The Encrypted element
# @param private_key [OpenSSL::PKey::RSA] The Service provider private key
# @param private_key [OpenSSL::PKey::PKey] The Service provider private key
# @return [String] The decrypted data
def decrypt_data(encrypted_node, private_key)
encrypt_data = REXML::XPath.first(
Expand All @@ -286,7 +293,7 @@ def decrypt_data(encrypted_node, private_key)

# Obtains the symmetric key from the EncryptedData element
# @param encrypt_data [REXML::Element] The EncryptedData element
# @param private_key [OpenSSL::PKey::RSA] The Service provider private key
# @param private_key [OpenSSL::PKey::PKey] The Service provider private key
# @return [String] The symmetric key
def retrieve_symmetric_key(encrypt_data, private_key)
encrypted_key = REXML::XPath.first(
Expand Down Expand Up @@ -410,5 +417,16 @@ def original_uri_match?(destination_url, settings_url)
def element_text(element)
element.texts.map(&:value).join if element
end

# Given a private key PEM string, return an array of OpenSSL::PKey::PKey classes
# that can be used to parse it, with the most likely match first.
def private_key_classes(pem)
priority = case pem.match(/(RSA|ECDSA|EC|DSA) PRIVATE KEY/)&.[](1)
when 'RSA' then OpenSSL::PKey::RSA
when 'DSA' then OpenSSL::PKey::DSA
when 'ECDSA', 'EC' then OpenSSL::PKey::EC
end
Array(priority) | [OpenSSL::PKey::RSA, OpenSSL::PKey::DSA, OpenSSL::PKey::EC]
end
end
end
1 change: 1 addition & 0 deletions lib/ruby_saml/xml.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require 'ruby_saml/xml/crypto'
require 'ruby_saml/xml/base_document'
require 'ruby_saml/xml/document'
require 'ruby_saml/xml/signed_document'
Expand Down
46 changes: 13 additions & 33 deletions lib/ruby_saml/xml/base_document.rb
Original file line number Diff line number Diff line change
@@ -1,55 +1,35 @@
# frozen_string_literal: true

require 'rexml/document'
require 'rexml/security'
require 'rexml/xpath'
require 'nokogiri'
require 'openssl'
require 'digest/sha1'
require 'digest/sha2'
require 'ruby_saml/xml/crypto'

module RubySaml
module XML
class BaseDocument < REXML::Document
# TODO: This affects the global state
REXML::Security.entity_expansion_limit = 0

C14N = 'http://www.w3.org/2001/10/xml-exc-c14n#'
DSIG = 'http://www.w3.org/2000/09/xmldsig#'
# @deprecated Constants moved to Crypto module
C14N = RubySaml::XML::Crypto::C14N
DSIG = RubySaml::XML::Crypto::DSIG

NOKOGIRI_OPTIONS = Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET

def canon_algorithm(element)
algorithm = element
if algorithm.is_a?(REXML::Element)
algorithm = element.attribute('Algorithm').value
end

case algorithm
when 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315',
'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments'
Nokogiri::XML::XML_C14N_1_0
when 'http://www.w3.org/2006/12/xml-c14n11',
'http://www.w3.org/2006/12/xml-c14n11#WithComments'
Nokogiri::XML::XML_C14N_1_1
else
Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0
end
# @deprecated Remove in v2.1.0
def canon_algorithm(algorithm)
RubySaml::XML::Crypto.canon_algorithm(algorithm)
end

def algorithm(element)
algorithm = element
if algorithm.is_a?(REXML::Element)
algorithm = element.attribute('Algorithm').value
end

algorithm = algorithm && algorithm =~ /(rsa-)?sha(.*?)$/i && ::Regexp.last_match(2).to_i

case algorithm
when 1 then OpenSSL::Digest::SHA1
when 384 then OpenSSL::Digest::SHA384
when 512 then OpenSSL::Digest::SHA512
else
OpenSSL::Digest::SHA256
end
# @deprecated Remove in v2.1.0
def algorithm(algorithm)
RubySaml::XML::Crypto.hash_algorithm(algorithm)
end
end
end
Expand Down
Loading

0 comments on commit 2ccb12b

Please sign in to comment.