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 decrypt support. #241

Merged
merged 9 commits into from
Jun 25, 2015
Merged
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ Once you've redirected back to the identity provider, it will ensure that the us

```ruby
def consume
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse])
response.settings = saml_settings
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], :settings => saml_settings)

# We validate the SAML Response and check if the user already exists in the system
if response.is_valid?
Expand All @@ -105,6 +104,16 @@ end

In the above there are a few assumptions in place, one being that the response.name_id is an email address. 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:

If the assertion of the SAMLResponse is not encrypted, you can initialize the Response without the :settings parameter and set it later,

```
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse])
response.settings = saml_settings
```
but if the SAMLResponse contains an encrypted assertion, you need to provide the settings in the
initialize method in order to be able to obtain the decrypted assertion, using the service provider private key in order to decrypt.
If you don't know what expect, use always the first proposed way (always set the settings on the initialize method).

```ruby
def saml_settings
settings = OneLogin::RubySaml::Settings.new
Expand Down Expand Up @@ -330,8 +339,8 @@ The Ruby Toolkit supports 2 different kinds of signature: Embeded and as GET par
In order to be able to sign we need first to define the private key and the public cert of the service provider

```ruby
settings.certificate = "CERTIFICATE TEXT WITH HEADS"
settings.private_key = "PRIVATE KEY TEXT WITH HEADS"
settings.certificate = "CERTIFICATE TEXT WITH HEAD AND FOOT"
settings.private_key = "PRIVATE KEY TEXT WITH HEAD AND FOOT"
```

The settings related to sign are stored in the `security` attribute of the settings:
Expand All @@ -354,6 +363,28 @@ Notice that the RelayState parameter is used when creating the Signature on the
remember to provide it to the Signature builder if you are sending a GET RelayState parameter or
Signature validation process will fail at the Identity Provider.

The Service Provider will sign the request/responses with its private key.
The Identity Provider will validate the sign of the received request/responses with the public x500 cert of the
Service Provider.

Notice that this toolkit uses 'settings.certificate' and 'settings.private_key' for the sign and the decrypt process.


## Decrypting

The Ruby Toolkit supports EncryptedAssertion.

In order to be able to decrypt a SAML Response that contains a EncryptedAssertion we need first to define the private key and the public cert of the service provider, and share this with the Identity Provider.

```ruby
settings.certificate = "CERTIFICATE TEXT WITH HEAD AND FOOT"
settings.private_key = "PRIVATE KEY TEXT WITH HEAD AND FOOT"
```

The Identity Provider will encrypt the Assertion with the public cert of the Service Provider.
The Service Provider will decrypt the EncryptedAssertion with its private key.

Notice that this toolkit uses 'settings.certificate' and 'settings.private_key' for the sign and the decrypt process.

## Single Log Out

Expand Down
11 changes: 9 additions & 2 deletions lib/onelogin/ruby-saml/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,21 @@ def generate(settings, pretty_print=false)
}
end

# Add KeyDescriptor if messages will be signed
# Add KeyDescriptor if messages will be signed / encrypted
cert = settings.get_sp_cert
if cert
cert_text = Base64.encode64(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 = Base64.encode64(cert.to_der).gsub("\n", '')
xc.text = cert_text

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

if settings.attribute_consuming_service.configured?
Expand Down
147 changes: 125 additions & 22 deletions lib/onelogin/ruby-saml/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Response < SamlMessage
ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
DSIG = "http://www.w3.org/2000/09/xmldsig#"
XENC = "http://www.w3.org/2001/04/xmlenc#"

# TODO: Settings should probably be initialized too... WDYT?

Expand All @@ -24,6 +25,7 @@ class Response < SamlMessage
attr_accessor :errors

attr_reader :document
attr_reader :decrypted_document
attr_reader :response
attr_reader :options

Expand All @@ -39,7 +41,7 @@ def initialize(response, options = {})
@errors = []

raise ArgumentError.new("Response cannot be nil") if response.nil?
@options = options
@options = options

@soft = true
if !options.empty? && !options[:settings].nil?
Expand All @@ -51,6 +53,21 @@ def initialize(response, options = {})

@response = decode_raw_saml(response)
@document = XMLSecurity::SignedDocument.new(@response, @errors)

return unless assertion_encrypted?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the code after this line is for encrypt the assertion, you should create a method for that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**decrypt

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a method for that: decrypt_assertion_from_document but the problem is that I need to clone the document in order to later decrypt it (if I no clone, then when decrypting I change the original object). And due Marshal bugs on Ruby 1.8.7 I need to offer an alternative re-parsing the response. And I want to pass to the method the cloned object. Does it make sense?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not clone the object inside decrypt_assertion_from_document and pass document or response (whatever is best) to that same method?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luisvm, I think that your proposal turns the code more complex..and this is maybe what we are trying to avoid.. You need to pass document on 1.8.7 and response in other case, so you need to maintain the if else, and later, in the decrypt method, add another if/else statement...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pitbulk well, initialize is already pretty complex

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


if @settings.nil? || !@settings.get_sp_key
validation_error('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

# Marshal at Ruby 1.8.7 throw an Exception
if RUBY_VERSION < "1.9"
document_copy = XMLSecurity::SignedDocument.new(@response, @errors)
else
document_copy = Marshal.load(Marshal.dump(@document))
end

@decrypted_document = decrypt_assertion_from_document(document_copy)
end

# Append the cause to the errors array, and based on the value of soft, return false or raise
Expand All @@ -76,7 +93,12 @@ def is_valid?
#
def name_id
@name_id ||= begin
node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
enc_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would complete the name to make it more readable. encrypted_node

if enc_node
node = decrypt_nameid(enc_node)
else
node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
end
node.nil? ? nil : node.text
end
end
Expand Down Expand Up @@ -205,9 +227,10 @@ def issuers
issuers = []
nodes = REXML::XPath.match(
document,
"/p:Response/a:Issuer | /p:Response/a:Assertion/a:Issuer",
"/p:Response/a:Issuer",
{ "p" => PROTOCOL, "a" => ASSERTION }
)
nodes += xpath_from_signed_assertion("/a:Issuer")
nodes.each do |node|
issuers << node.text if node.text
end
Expand Down Expand Up @@ -371,11 +394,7 @@ def validate_num_assertion
# @raise [ValidationError] if soft == false and validation fails
#
def validate_no_encrypted_attributes
nodes = REXML::XPath.match(
document,
"/p:Response/a:Assertion/a:AttributeStatement/a:EncryptedAttribute",
{ "p" => PROTOCOL, "a" => ASSERTION }
)
nodes = xpath_from_signed_assertion("/a:AttributeStatement/a:EncryptedAttribute")
if nodes && nodes.length > 0
return append_error("There is an EncryptedAttribute in the Response and this SP not support them")
end
Expand All @@ -391,7 +410,7 @@ def validate_no_encrypted_attributes
#
def validate_signed_elements
signature_nodes = REXML::XPath.match(
document,
decrypted_document.nil? ? document : decrypted_document,
"//ds:Signature",
{"ds"=>DSIG}
)
Expand Down Expand Up @@ -452,13 +471,13 @@ def validate_conditions

now = Time.now.utc

if not_before && (now + (options[:allowed_clock_drift] || 0)) < not_before
error_msg = "Current time is earlier than NotBefore condition #{(now + (options[:allowed_clock_drift] || 0))} < #{not_before})"
if not_before && (now + allowed_clock_drift) < not_before
error_msg = "Current time is earlier than NotBefore condition #{(now + allowed_clock_drift)} < #{not_before})"
return append_error(error_msg)
end

if not_on_or_after && now >= not_on_or_after
error_msg = "Current time is on or after NotOnOrAfter condition (#{now} >= #{not_on_or_after})"
if not_on_or_after && now >= (not_on_or_after + allowed_clock_drift)
error_msg = "Current time is on or after NotOnOrAfter condition (#{now} >= #{not_on_or_after + allowed_clock_drift})"
return append_error(error_msg)
end

Expand Down Expand Up @@ -551,7 +570,17 @@ def validate_subject_confirmation
def validate_signature
fingerprint = settings.get_fingerprint

unless fingerprint && document.validate_document(fingerprint, soft, :fingerprint_alg => settings.idp_cert_fingerprint_algorithm)
# If the response contains the signature, and the assertion was encrypted, validate the original SAML Response
# otherwise, review if the decrypted assertion contains a signature
response_signed = REXML::XPath.first(
document,
"/p:Response[@ID=$id]",
{ "p" => PROTOCOL, "ds" => DSIG },
{ 'id' => document.signed_element_id }
)
doc = (response_signed || decrypted_document.nil?) ? document : decrypted_document

unless fingerprint && doc.validate_document(fingerprint, :fingerprint_alg => settings.idp_cert_fingerprint_algorithm)
error_msg = "Invalid Signature on SAML Response"
return append_error(error_msg)
end
Expand All @@ -565,17 +594,18 @@ def validate_signature
# @return [REXML::Element | nil] If any matches, return the Element
#
def xpath_first_from_signed_assertion(subelt=nil)
doc = decrypted_document.nil? ? document : decrypted_document

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is repeated, maybe it worth a method?

node = REXML::XPath.first(
document,
doc,
"/p:Response/a:Assertion[@ID=$id]#{subelt}",
{ "p" => PROTOCOL, "a" => ASSERTION },
{ 'id' => document.signed_element_id }
{ 'id' => doc.signed_element_id }
)
node ||= REXML::XPath.first(
document,
doc,
"/p:Response[@ID=$id]/a:Assertion#{subelt}",
{ "p" => PROTOCOL, "a" => ASSERTION },
{ 'id' => document.signed_element_id }
{ 'id' => doc.signed_element_id }
)
node
end
Expand All @@ -586,20 +616,93 @@ def xpath_first_from_signed_assertion(subelt=nil)
# @return [Array of REXML::Element] Return all matches
#
def xpath_from_signed_assertion(subelt=nil)
doc = decrypted_document.nil? ? document : decrypted_document
node = REXML::XPath.match(
document,
doc,
"/p:Response/a:Assertion[@ID=$id]#{subelt}",
{ "p" => PROTOCOL, "a" => ASSERTION },
{ 'id' => document.signed_element_id }
{ 'id' => doc.signed_element_id }
)
node.concat( REXML::XPath.match(
document,
doc,
"/p:Response[@ID=$id]/a:Assertion#{subelt}",
{ "p" => PROTOCOL, "a" => ASSERTION },
{ 'id' => document.signed_element_id }
{ 'id' => doc.signed_element_id }
))
end

# Obtains a SAML Response with the EncryptedAssertion element decrypted
# @param document_copy [XMLSecurity::SignedDocument] A copy of the original SAML Response with the encrypted assertion
# @return [XMLSecurity::SignedDocument] The SAML Response with the assertion decrypted
#
def decrypt_assertion_from_document(document_copy)
response_node = REXML::XPath.first(
document_copy,
"/p:Response/",
{ "p" => PROTOCOL }
)
encrypted_assertion_node = REXML::XPath.first(
document_copy,
"(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
{ "p" => PROTOCOL, "a" => ASSERTION }
)
response_node.add(decrypt_assertion(encrypted_assertion_node))
encrypted_assertion_node.remove
XMLSecurity::SignedDocument.new(response_node.to_s)
end

# Checks if the SAML Response contains or not an EncryptedAssertion element
# @return [Boolean] True if the SAML Response contains an EncryptedAssertion element
#
def assertion_encrypted?
encrypted_node = REXML::XPath.first(
document,
"(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
{ "p" => PROTOCOL, "a" => ASSERTION }
)
!encrypted_node.nil?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make this simpler:

! REXML::XPath.first(
          document,
          "(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
          { "p" => PROTOCOL, "a" => ASSERTION }
        ).nil?

end

# Decrypts an EncryptedAssertion element
# @param encrypted_assertion_node [REXML::Element] The EncryptedAssertion element
# @return [REXML::Document] The decrypted EncryptedAssertion element
#
def decrypt_assertion(encrypted_assertion_node)
if settings.nil? || !settings.get_sp_key

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be expressed with an eager return:

if settings.nil? || !settings.get_sp_key
  return validation_error('An EncryptedAssertion found and no SP private key found on the settings to decrypt it')
end
<Then all the code>

validation_error('An EncryptedAssertion found and no SP private key found on the settings to decrypt it')
else
assertion_plaintext = OneLogin::RubySaml::Utils.decrypt_data(encrypted_assertion_node, settings.get_sp_key)
# If we get some problematic noise in the plaintext after decrypting.
# This quick regexp parse will grab only the assertion and discard the noise.
assertion_plaintext = assertion_plaintext.match(/(.*<\/(saml:|)Assertion>)/m)[0]
# To avoid namespace errors if saml namespace is not defined at assertion_plaintext
# create a parent node first with the saml namespace defined
assertion_plaintext = '<node xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">'+ assertion_plaintext + '</node>'
doc = REXML::Document.new(assertion_plaintext)
doc.root[0]
end
end

# Decrypts an EncryptedID element
# @param encryptedid_node [REXML::Element] The EncryptedID element
# @return [REXML::Document] The decrypted EncrypedtID element
#
def decrypt_nameid(encryptedid_node)
if settings.nil? || !settings.get_sp_key

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eager return here too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will refactor both methods ;)

validation_error('An EncryptedID found and no SP private key found on the settings to decrypt it')
else
nameid_plaintext = OneLogin::RubySaml::Utils.decrypt_data(encryptedid_node, settings.get_sp_key)
# If we get some problematic noise in the plaintext after decrypting.
# This quick regexp parse will grab only the NameID and discard the noise.
nameid_plaintext = nameid_plaintext.match(/(.*<\/(saml:|)NameID>)/m)[0]
# To avoid namespace errors if saml namespace is not defined at assertion_plaintext
# create a parent node first with the saml namespace defined
nameid_plaintext = '<node xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">'+ nameid_plaintext + '</node>'
doc = REXML::Document.new(nameid_plaintext)
doc.root[0]
end
end

# Parse the attribute of a given node in Time format
# @param node [REXML:Element] The node
# @param attribute [String] The attribute name
Expand Down
Loading