diff --git a/lib/akami/wsse/verify_signature.rb b/lib/akami/wsse/verify_signature.rb index 298a452..c23258d 100644 --- a/lib/akami/wsse/verify_signature.rb +++ b/lib/akami/wsse/verify_signature.rb @@ -11,17 +11,24 @@ class VerifySignature class InvalidDigest < RuntimeError; end class InvalidSignedValue < RuntimeError; end + class MissingDecryptedAttachment < RuntimeError; end attr_reader :document - def initialize(xml) + # @param xml [String] The XML document to verify + # @param decrypted_attachments [Hash] A hash of decrypted attachments: { 'id' => 'decrypted_string' } + # For example: the decrypted_attachments of a gzipped xml is the gzipped base64 string, the result of the decryption + # { 'phase4-att-1f34-4d68a..' => 'kZ\xB4\xCD}\xCB..' } + def initialize(xml, decrypted_attachments: {}) @document = Nokogiri::XML(xml.to_s, &:noblanks) + @decrypted_attachments = decrypted_attachments end # Returns XML namespaces that are used internally for document querying. def namespaces @namespaces ||= { wse: Akami::WSSE::WSE_NAMESPACE, + wsse: Akami::WSSE::WSE_NAMESPACE, ds: 'http://www.w3.org/2000/09/xmldsig#', wsu: Akami::WSSE::WSU_NAMESPACE, ec: Akami::WSSE::Signature::ExclusiveXMLCanonicalizationAlgorithm, @@ -33,21 +40,31 @@ def namespaces # Returns signer's certificate, bundled in signed document def certificate - certificate_value = document.at_xpath('//wse:Security/wse:BinarySecurityToken', namespaces).text.strip - OpenSSL::X509::Certificate.new Base64.decode64(certificate_value) + binary_security_tokens = document.xpath('//wse:Security/wse:BinarySecurityToken', namespaces) + if binary_security_tokens.size > 1 + signature_certificate_id = document.at_xpath( + '//wse:Security/ds:Signature/ds:KeyInfo/wsse:SecurityTokenReference/wsse:Reference', + namespaces + )['URI'][1..-1] # strip leading '#' + certificate_value = document.at_xpath("//wse:Security/wse:BinarySecurityToken[@wsu:Id=\"#{signature_certificate_id}\"]", namespaces) + else + certificate_value = binary_security_tokens.first + end + + OpenSSL::X509::Certificate.new Base64.decode64(certificate_value.text.strip) end # Validates document signature, returns +true+ on success, +false+ otherwise. def valid? verify - rescue InvalidDigest, InvalidSignedValue + rescue InvalidDigest, InvalidSignedValue, MissingDecryptedAttachment return false end # Validates document signature and digests and raises if anything mismatches. def verify! verify - rescue InvalidDigest, InvalidSignedValue => e + rescue InvalidDigest, InvalidSignedValue, MissingDecryptedAttachment => e raise InvalidSignature, e.message end @@ -71,9 +88,19 @@ def verify transform_inclusive_ns = inclusive_namespaces(ref, './/ds:Transforms/ds:Transform/ec:InclusiveNamespaces') - element_id = ref.attributes['URI'].value[1..-1] # strip leading '#' - element = document.at_xpath(%(//*[@wsu:Id="#{element_id}"]), namespaces) - unless supplied_digest(element) == generate_digest(element, digest_algorithm, transform_inclusive_ns) + ref_uri = ref.attributes['URI'].value + if ref_uri.start_with?("#") + element_id = ref_uri.sub(/^#/, '') + element = document.at_xpath(%(//*[@wsu:Id="#{element_id}"]), namespaces) + generated_digest = generate_digest(element, digest_algorithm, transform_inclusive_ns) + else + element_id = ref_uri.sub(/^cid:/, '') + element = @decrypted_attachments[element_id] + raise MissingDecryptedAttachment, "Missing decrypted attachment for #{element_id}" if element.nil? + generated_digest = digest(element, digest_algorithm).strip + end + + unless supplied_digest(ref) == generated_digest raise InvalidDigest, "Invalid Digest for #{element_id}" end end @@ -105,8 +132,7 @@ def generate_digest(element, algorithm, inclusive_namespaces = nil) end def supplied_digest(element) - element = document.at_xpath(element, namespaces) if element.is_a? String - find_digest_value element.attributes['Id'].value + element.at_xpath('.//ds:DigestValue', namespaces).text end def signature_value @@ -114,10 +140,6 @@ def signature_value element ? element.text : "" end - def find_digest_value(id) - document.at_xpath(%(//wse:Security/ds:Signature/ds:SignedInfo/ds:Reference[@URI="##{id}"]/ds:DigestValue), namespaces).text - end - # Calculate digest for string with given algorithm URL and Base64 encodes it. def digest(string, algorithm) Base64.encode64 digester(algorithm).digest(string) @@ -139,6 +161,7 @@ def digester_for_signature_method(algorithm_url) 'http://www.w3.org/2000/09/xmldsig#sha1' => lambda { OpenSSL::Digest::SHA1.new }, # SHA 256 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' => lambda { OpenSSL::Digest::SHA256.new }, + 'http://www.w3.org/2001/04/xmlenc#sha256' => lambda { OpenSSL::Digest::SHA256.new }, # GOST R 34.11-94 # You need correctly configured gost engine in your system OpenSSL, requires OpenSSL >= 1.0.0 # see https://github.com/openssl/openssl/blob/master/engines/ccgost/README.gost diff --git a/spec/akami/wsse/verify_signature_spec.rb b/spec/akami/wsse/verify_signature_spec.rb index 5e49828..f22111b 100644 --- a/spec/akami/wsse/verify_signature_spec.rb +++ b/spec/akami/wsse/verify_signature_spec.rb @@ -8,6 +8,12 @@ expect(validator.verify!).to eq(true) end + it 'validates correctly signed XML messages with multiple binary security token' do + xml = fixture('akami/wsse/verify_signature/valid_multiple_binary_security_token.xml') + validator = described_class.new(xml) + expect(validator.verify!).to eq(true) + end + it 'validates correctly signed XML messages with differently named namespaces' do xml = fixture('akami/wsse/verify_signature/valid_namespaces.xml') validator = described_class.new(xml) diff --git a/spec/fixtures/akami/wsse/verify_signature/valid_multiple_binary_security_token.xml b/spec/fixtures/akami/wsse/verify_signature/valid_multiple_binary_security_token.xml new file mode 100644 index 0000000..5c80aea --- /dev/null +++ b/spec/fixtures/akami/wsse/verify_signature/valid_multiple_binary_security_token.xml @@ -0,0 +1,3 @@ + + + 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8yHW2c0jdon+cADkxk47/gLo0ps=eRsb4CWXD17hl5exQvaZYDnOQOM=1aznRVYGR81veFFG2lNU9WjUhDs=KXKU6ZFziN415Hd2K6WevzUihYs=YrKqrE99N7hNGYEvrhifL/LaxKQ=VevrJZBe3aVif18GuHIrSZz5my8=NEJTWOxr2IdWOyV+b1XjRU1Koaa0OYDbz0MqErcqjEgLt3rgK2YyZpg2yMBB++YmlwhS2Gm/Iqnyv6U909hvF4Hg+9/kw/FiwqhavcW+/N9HZKo0vGww/rU4qcKrNdU/lETQhxfk5DpKAoAUWV6yGxnbP8GzTXWGtP4sfLlcFjfOkTnePEM7QLjJLk9l2YkvbmyaRClj3psYrh0Fo1G+LWZ7W8UpaPzoo8e+s2EkKDAbchWoQJp2vEIhLnRRWDMuweRpsURigjbIkJCKnawmZ8SG1nA68nYa9jTh6824XVepxbkvtvNzEFC6dmZAjwAWhADf1+7lpqUyml/wZyHWRw==SomeActionhttps://strict.endpoint/pathhttp://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymousSRV-0f687bbd-62503e99-926a4d3c-5dde443c-dbf6d68fcodemessage