samlr 2.0.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of samlr might be problematic. Click here for more details.

Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +3 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +8 -0
  5. data/LICENSE +176 -0
  6. data/README.md +182 -0
  7. data/Rakefile +12 -0
  8. data/bin/samlr +46 -0
  9. data/config/schemas/XMLSchema.xsd +2534 -0
  10. data/config/schemas/saml-schema-assertion-2.0.xsd +283 -0
  11. data/config/schemas/saml-schema-metadata-2.0.xsd +337 -0
  12. data/config/schemas/saml-schema-protocol-2.0.xsd +302 -0
  13. data/config/schemas/xenc-schema.xsd +146 -0
  14. data/config/schemas/xml.xsd +287 -0
  15. data/config/schemas/xmldsig-core-schema.xsd +318 -0
  16. data/lib/samlr.rb +52 -0
  17. data/lib/samlr/assertion.rb +91 -0
  18. data/lib/samlr/certificate.rb +23 -0
  19. data/lib/samlr/command.rb +41 -0
  20. data/lib/samlr/condition.rb +31 -0
  21. data/lib/samlr/errors.rb +22 -0
  22. data/lib/samlr/fingerprint.rb +44 -0
  23. data/lib/samlr/logout_request.rb +7 -0
  24. data/lib/samlr/reference.rb +32 -0
  25. data/lib/samlr/request.rb +37 -0
  26. data/lib/samlr/response.rb +68 -0
  27. data/lib/samlr/signature.rb +129 -0
  28. data/lib/samlr/tools.rb +108 -0
  29. data/lib/samlr/tools/certificate_builder.rb +74 -0
  30. data/lib/samlr/tools/logout_request_builder.rb +27 -0
  31. data/lib/samlr/tools/metadata_builder.rb +41 -0
  32. data/lib/samlr/tools/request_builder.rb +44 -0
  33. data/lib/samlr/tools/response_builder.rb +157 -0
  34. data/lib/samlr/tools/timestamp.rb +26 -0
  35. data/samlr.gemspec +19 -0
  36. data/test/fixtures/default_samlr_certificate.pem +11 -0
  37. data/test/fixtures/default_samlr_private_key.pem +9 -0
  38. data/test/fixtures/no_cert_response.xml +2 -0
  39. data/test/fixtures/sample_metadata.xml +7 -0
  40. data/test/fixtures/sample_response.xml +2 -0
  41. data/test/test_helper.rb +55 -0
  42. data/test/unit/test_assertion.rb +54 -0
  43. data/test/unit/test_condition.rb +71 -0
  44. data/test/unit/test_fingerprint.rb +45 -0
  45. data/test/unit/test_logout_request.rb +39 -0
  46. data/test/unit/test_reference.rb +32 -0
  47. data/test/unit/test_request.rb +34 -0
  48. data/test/unit/test_response.rb +94 -0
  49. data/test/unit/test_response_scenarios.rb +111 -0
  50. data/test/unit/test_signature.rb +54 -0
  51. data/test/unit/test_timestamp.rb +58 -0
  52. data/test/unit/test_tools.rb +100 -0
  53. data/test/unit/tools/test_certificate_builder.rb +41 -0
  54. data/test/unit/tools/test_logout_request_builder.rb +26 -0
  55. data/test/unit/tools/test_metadata_builder.rb +26 -0
  56. data/test/unit/tools/test_request_builder.rb +35 -0
  57. data/test/unit/tools/test_response_builder.rb +19 -0
  58. metadata +184 -0
@@ -0,0 +1,23 @@
1
+ module Samlr
2
+ class Certificate
3
+ attr_reader :x509
4
+
5
+ def initialize(value)
6
+ @x509 = if value.is_a?(OpenSSL::X509::Certificate)
7
+ value
8
+ elsif value.is_a?(IO)
9
+ OpenSSL::X509::Certificate.new(value.read)
10
+ else
11
+ OpenSSL::X509::Certificate.new(value)
12
+ end
13
+ end
14
+
15
+ def fingerprint
16
+ @fingerprint ||= Fingerprint.new(@x509)
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(Certificate) && fingerprint == other.fingerprint
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,41 @@
1
+ require "samlr"
2
+ require "logger"
3
+
4
+ module Samlr
5
+ # Helper module for command line options
6
+ module Command
7
+ COMMANDS = [ :verify, :schema_validate, :print ]
8
+
9
+ def self.execute(options, path = nil)
10
+ Samlr.logger.level = Logger::DEBUG if options[:verbose]
11
+ Samlr.validation_mode = :log if options[:skip_validation]
12
+
13
+ if options[:verify]
14
+ if File.directory?(path)
15
+ result = []
16
+ Dir.glob("#{path}/*.*").each do |file|
17
+ result << execute_verify(file, options)
18
+ end
19
+ result.join("\n")
20
+ else
21
+ execute_verify(path, options)
22
+ end
23
+ elsif options[:schema_validate]
24
+ Samlr::Tools.validate(:path => path)
25
+ elsif options[:print]
26
+ Samlr::Response.parse(File.read(path)).to_xml
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def self.execute_verify(path, options)
33
+ begin
34
+ Samlr::Response.new(File.read(path), options).verify!
35
+ "Verification passed for #{path}"
36
+ rescue Samlr::SamlrError => e
37
+ "Verification failed for #{path}: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ module Samlr
2
+ class Condition
3
+ attr_reader :not_before, :not_on_or_after
4
+
5
+ def initialize(condition)
6
+ @not_before = (condition || {})["NotBefore"]
7
+ @not_on_or_after = (condition || {})["NotOnOrAfter"]
8
+ end
9
+
10
+ def verify!
11
+ unless not_before_satisfied?
12
+ raise Samlr::ConditionsError.new("Not before violation, now #{Samlr::Tools::Timestamp.stamp} vs. earliest #{not_before}")
13
+ end
14
+
15
+ unless not_on_or_after_satisfied?
16
+ raise Samlr::ConditionsError.new("Not on or after violation, now #{Samlr::Tools::Timestamp.stamp} vs. at latest #{not_on_or_after}")
17
+ end
18
+
19
+ true
20
+ end
21
+
22
+ def not_before_satisfied?
23
+ not_before.nil? || Samlr::Tools::Timestamp.not_before?(Samlr::Tools::Timestamp.parse(not_before))
24
+ end
25
+
26
+ def not_on_or_after_satisfied?
27
+ not_on_or_after.nil? || Samlr::Tools::Timestamp.not_on_or_after?(Samlr::Tools::Timestamp.parse(not_on_or_after))
28
+ end
29
+ end
30
+
31
+ end
@@ -0,0 +1,22 @@
1
+ module Samlr
2
+ class SamlrError < StandardError
3
+ attr_reader :details
4
+
5
+ def initialize(*args)
6
+ super(args.shift)
7
+ @details = args.shift unless args.empty?
8
+ end
9
+ end
10
+
11
+ class FormatError < SamlrError
12
+ end
13
+
14
+ class SignatureError < SamlrError
15
+ end
16
+
17
+ class FingerprintError < SamlrError
18
+ end
19
+
20
+ class ConditionsError < SamlrError
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ module Samlr
2
+ class Fingerprint
3
+ attr_accessor :value
4
+
5
+ def initialize(value)
6
+ if value.is_a?(OpenSSL::X509::Certificate)
7
+ @value = Fingerprint.x509(value)
8
+ else
9
+ @value = Fingerprint.normalize(value)
10
+ end
11
+ end
12
+
13
+ # Fingerprints compare if their values are equal and not blank
14
+ def ==(other)
15
+ other.is_a?(Fingerprint) && other.valid? && valid? && other.to_s == to_s
16
+ end
17
+
18
+ def compare!(other)
19
+ if self != other
20
+ raise FingerprintError.new("Fingerprint mismatch", "#{self} vs. #{other}")
21
+ else
22
+ true
23
+ end
24
+ end
25
+
26
+ def valid?
27
+ value =~ /([A-F0-9]:?)+/
28
+ end
29
+
30
+ def to_s
31
+ value
32
+ end
33
+
34
+ # Extracts a fingerprint for an x509 certificate
35
+ def self.x509(certificate)
36
+ normalize(OpenSSL::Digest::SHA1.new.hexdigest(certificate.to_der))
37
+ end
38
+
39
+ # Converts a string to fingerprint normal form
40
+ def self.normalize(value)
41
+ value.to_s.upcase.gsub(/[^A-F0-9]/, "").scan(/../).join(":")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ module Samlr
2
+ class LogoutRequest < Request
3
+ def body
4
+ @body ||= Samlr::Tools::LogoutRequestBuilder.build(options)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ require "base64"
2
+
3
+ module Samlr
4
+ class Reference
5
+ attr_reader :uri, :node
6
+
7
+ def initialize(node)
8
+ @node = node
9
+ @uri = node["URI"][1..-1]
10
+ end
11
+
12
+ def digest_method
13
+ @digest_method ||= Samlr::Tools.algorithm(node.at("./ds:DigestMethod/@Algorithm", NS_MAP).try(:value))
14
+ end
15
+
16
+ def digest_value
17
+ @digest_value ||= node.at("./ds:DigestValue", NS_MAP).text
18
+ end
19
+
20
+ def decoded_digest_value
21
+ @decoded_digest_value ||= Base64.decode64(digest_value)
22
+ end
23
+
24
+ def namespaces
25
+ @namespaces ||= begin
26
+ attribute = node.at("./ds:Transforms/ds:Transform/c14n:InclusiveNamespaces/@PrefixList", NS_MAP).try(:value)
27
+ attribute ? attribute.split(" ") : []
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ require "cgi"
2
+
3
+ module Samlr
4
+ class Request
5
+ attr_reader :options
6
+
7
+ def initialize(options = {})
8
+ @options = options
9
+ end
10
+
11
+ # The encoded SAML request
12
+ def param
13
+ @param ||= Samlr::Tools.encode(body)
14
+ end
15
+
16
+ # The XML payload body
17
+ def body
18
+ @body ||= Samlr::Tools::RequestBuilder.build(options)
19
+ end
20
+
21
+ # Utility method to get the full redirect destination, Request#url("https://idp.example.com/saml", { :RelayState => "https://sp.example.com/saml" })
22
+ def url(root, params = {})
23
+ dest = root.dup
24
+ if dest.include?("?")
25
+ dest << "&SAMLRequest=#{param}"
26
+ else
27
+ dest << "?SAMLRequest=#{param}"
28
+ end
29
+
30
+ params.each_pair do |key, value|
31
+ dest << "&#{key}=#{CGI.escape(value.to_s)}"
32
+ end
33
+
34
+ dest
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ require "forwardable"
2
+ require "nokogiri"
3
+
4
+ module Samlr
5
+
6
+ # This is the object interface to the XML response object.
7
+ class Response
8
+ extend Forwardable
9
+
10
+ def_delegators :assertion, :name_id, :attributes
11
+ attr_reader :document, :options
12
+
13
+ def initialize(data, options)
14
+ @options = options
15
+ @document = Response.parse(data)
16
+ end
17
+
18
+ # The verification process assumes that all signatures are enveloped. Since this process
19
+ # is destructive the document needs to verify itself first, and then any signed assertions
20
+ def verify!
21
+ if signature.missing? && assertion.signature.missing?
22
+ raise Samlr::SignatureError.new("Neither response nor assertion signed")
23
+ end
24
+
25
+ signature.verify! unless signature.missing?
26
+ assertion.verify!
27
+
28
+ true
29
+ end
30
+
31
+ def location
32
+ "/samlp:Response"
33
+ end
34
+
35
+ def signature
36
+ @signature ||= Samlr::Signature.new(document, location, options)
37
+ end
38
+
39
+ # Returns the assertion element. Only supports a single assertion.
40
+ def assertion
41
+ @assertion ||= Samlr::Assertion.new(document, options)
42
+ end
43
+
44
+ # Tries to parse the SAML response. First, it assumes it to be Base64 encoded
45
+ # If this fails, it subsequently attempts to parse the raw input as select IdP's
46
+ # send that rather than a Base64 encoded value
47
+ def self.parse(data)
48
+ begin
49
+ document = Nokogiri::XML(Base64.decode64(data)) { |config| config.strict }
50
+ rescue Nokogiri::XML::SyntaxError => e
51
+ begin
52
+ document = Nokogiri::XML(data) { |config| config.strict }
53
+ rescue
54
+ raise Samlr::FormatError.new(e.message)
55
+ end
56
+ end
57
+
58
+ begin
59
+ Samlr::Tools.validate!(:document => document)
60
+ rescue Samlr::SamlrError => e
61
+ Samlr.logger.warn("Accepting non schema conforming response: #{e.message}, #{e.details}")
62
+ raise e unless Samlr.validation_mode == :log
63
+ end
64
+
65
+ document
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,129 @@
1
+ require "openssl"
2
+ require "base64"
3
+ require "samlr/certificate"
4
+ require "samlr/reference"
5
+
6
+ module Samlr
7
+ # A SAML specific implementation http://en.wikipedia.org/wiki/XML_Signature
8
+ class Signature
9
+ attr_reader :original, :document, :prefix, :options, :signature, :fingerprint
10
+
11
+ # Is initialized with the source document and a path to the element embedding the signature
12
+ def initialize(original, prefix, options)
13
+ # Signature validations require document alterations
14
+ @original = original
15
+ @document = original.dup
16
+ @prefix = prefix
17
+ @options = options
18
+
19
+ if @signature = document.at("#{prefix}/ds:Signature", NS_MAP)
20
+ @signature.remove # enveloped signatures only
21
+ end
22
+
23
+ @fingerprint = if options[:fingerprint]
24
+ Fingerprint.new(options[:fingerprint])
25
+ elsif options[:certificate]
26
+ Certificate.new(options[:certificate]).fingerprint
27
+ end
28
+ end
29
+
30
+ def present?
31
+ !missing?
32
+ end
33
+
34
+ def missing?
35
+ signature.nil?
36
+ end
37
+
38
+ def verify!
39
+ raise SignatureError.new("No signature at #{prefix}/ds:Signature") unless present?
40
+
41
+ verify_fingerprint! unless options[:skip_fingerprint]
42
+ verify_digests!
43
+ verify_signature!
44
+
45
+ true
46
+ end
47
+
48
+ def references
49
+ @references ||= [].tap do |refs|
50
+ original.xpath("#{prefix}/ds:Signature/ds:SignedInfo/ds:Reference[@URI]", NS_MAP).each do |ref|
51
+ refs << Samlr::Reference.new(ref)
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def x509
59
+ @x509 ||= certificate.x509
60
+ end
61
+
62
+ # Establishes trust that the remote party is who you think
63
+ def verify_fingerprint!
64
+ fingerprint.compare!(certificate.fingerprint)
65
+ end
66
+
67
+ # Tests that the document content has not been edited
68
+ def verify_digests!
69
+ references.each do |reference|
70
+ node = referenced_node(reference.uri)
71
+ canoned = node.canonicalize(C14N, reference.namespaces)
72
+ digest = reference.digest_method.digest(canoned)
73
+
74
+ if digest != reference.decoded_digest_value
75
+ raise SignatureError.new("Reference validation error: Digest mismatch for #{reference.uri}")
76
+ end
77
+ end
78
+ end
79
+
80
+ # Tests correctness of the signature (and hence digests)
81
+ def verify_signature!
82
+ node = original.at("#{prefix}/ds:Signature/ds:SignedInfo", NS_MAP)
83
+ canoned = node.canonicalize(C14N)
84
+
85
+ unless x509.public_key.verify(signature_method.new, decoded_signature_value, canoned)
86
+ raise SignatureError.new("Signature validation error: Possible canonicalization mismatch", "This canonicalizer returns #{canoned}")
87
+ end
88
+ end
89
+
90
+ # Looks up node by id, checks that there's only a single node with a given id
91
+ def referenced_node(id)
92
+ nodes = document.xpath("//*[@ID='#{id}']")
93
+
94
+ if nodes.size != 1
95
+ raise SignatureError.new("Reference validation error: Invalid element references", "Expected 1 element with id #{id}, found #{nodes.size}")
96
+ end
97
+
98
+ nodes.first
99
+ end
100
+
101
+ def signature_method
102
+ @signature_method ||= Samlr::Tools.algorithm(signature.at("./ds:SignedInfo/ds:SignatureMethod/@Algorithm", NS_MAP).try(:value))
103
+ end
104
+
105
+ def signature_value
106
+ @signature_value ||= signature.at("./ds:SignatureValue", NS_MAP).text
107
+ end
108
+
109
+ def decoded_signature_value
110
+ @decoded_signature_value = Base64.decode64(signature_value)
111
+ end
112
+
113
+ def certificate
114
+ @certificate ||= begin
115
+ if node = certificate_node
116
+ Certificate.new(Base64.decode64(node.text))
117
+ elsif cert = options[:certificate]
118
+ Certificate.new(cert)
119
+ else
120
+ raise SignatureError.new("No X509Certificate element in response signature. Cannot validate signature.")
121
+ end
122
+ end
123
+ end
124
+
125
+ def certificate_node
126
+ signature.at("./ds:KeyInfo/ds:X509Data/ds:X509Certificate", NS_MAP)
127
+ end
128
+ end
129
+ end