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.
- checksums.yaml +7 -0
- data/.gitignore +3 -0
- data/.travis.yml +5 -0
- data/Gemfile +8 -0
- data/LICENSE +176 -0
- data/README.md +182 -0
- data/Rakefile +12 -0
- data/bin/samlr +46 -0
- data/config/schemas/XMLSchema.xsd +2534 -0
- data/config/schemas/saml-schema-assertion-2.0.xsd +283 -0
- data/config/schemas/saml-schema-metadata-2.0.xsd +337 -0
- data/config/schemas/saml-schema-protocol-2.0.xsd +302 -0
- data/config/schemas/xenc-schema.xsd +146 -0
- data/config/schemas/xml.xsd +287 -0
- data/config/schemas/xmldsig-core-schema.xsd +318 -0
- data/lib/samlr.rb +52 -0
- data/lib/samlr/assertion.rb +91 -0
- data/lib/samlr/certificate.rb +23 -0
- data/lib/samlr/command.rb +41 -0
- data/lib/samlr/condition.rb +31 -0
- data/lib/samlr/errors.rb +22 -0
- data/lib/samlr/fingerprint.rb +44 -0
- data/lib/samlr/logout_request.rb +7 -0
- data/lib/samlr/reference.rb +32 -0
- data/lib/samlr/request.rb +37 -0
- data/lib/samlr/response.rb +68 -0
- data/lib/samlr/signature.rb +129 -0
- data/lib/samlr/tools.rb +108 -0
- data/lib/samlr/tools/certificate_builder.rb +74 -0
- data/lib/samlr/tools/logout_request_builder.rb +27 -0
- data/lib/samlr/tools/metadata_builder.rb +41 -0
- data/lib/samlr/tools/request_builder.rb +44 -0
- data/lib/samlr/tools/response_builder.rb +157 -0
- data/lib/samlr/tools/timestamp.rb +26 -0
- data/samlr.gemspec +19 -0
- data/test/fixtures/default_samlr_certificate.pem +11 -0
- data/test/fixtures/default_samlr_private_key.pem +9 -0
- data/test/fixtures/no_cert_response.xml +2 -0
- data/test/fixtures/sample_metadata.xml +7 -0
- data/test/fixtures/sample_response.xml +2 -0
- data/test/test_helper.rb +55 -0
- data/test/unit/test_assertion.rb +54 -0
- data/test/unit/test_condition.rb +71 -0
- data/test/unit/test_fingerprint.rb +45 -0
- data/test/unit/test_logout_request.rb +39 -0
- data/test/unit/test_reference.rb +32 -0
- data/test/unit/test_request.rb +34 -0
- data/test/unit/test_response.rb +94 -0
- data/test/unit/test_response_scenarios.rb +111 -0
- data/test/unit/test_signature.rb +54 -0
- data/test/unit/test_timestamp.rb +58 -0
- data/test/unit/test_tools.rb +100 -0
- data/test/unit/tools/test_certificate_builder.rb +41 -0
- data/test/unit/tools/test_logout_request_builder.rb +26 -0
- data/test/unit/tools/test_metadata_builder.rb +26 -0
- data/test/unit/tools/test_request_builder.rb +35 -0
- data/test/unit/tools/test_response_builder.rb +19 -0
- 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
|
data/lib/samlr/errors.rb
ADDED
@@ -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,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
|