Commit cea3236
Changed files (20)
lib
spec
fixtures
support
lib/xml/kit/builders/templates/certificate.builder
@@ -0,0 +1,7 @@
+xml.KeyDescriptor use: use do
+ xml.KeyInfo "xmlns": ::Xml::Kit::Namespaces::XMLDSIG do
+ xml.X509Data do
+ xml.X509Certificate stripped
+ end
+ end
+end
lib/xml/kit/builders/templates/nil_class.builder
lib/xml/kit/builders/templates/xml_encryption.builder
@@ -0,0 +1,16 @@
+xml.EncryptedAssertion xmlns: Saml::Kit::Namespaces::ASSERTION do
+ xml.EncryptedData xmlns: ::Xml::Kit::Namespaces::XMLENC do
+ xml.EncryptionMethod Algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
+ xml.KeyInfo xmlns: ::Xml::Kit::Namespaces::XMLDSIG do
+ xml.EncryptedKey xmlns: ::Xml::Kit::Namespaces::XMLENC do
+ xml.EncryptionMethod Algorithm: "http://www.w3.org/2001/04/xmlenc#rsa-1_5"
+ xml.CipherData do
+ xml.CipherValue Base64.encode64(public_key.public_encrypt(key))
+ end
+ end
+ end
+ xml.CipherData do
+ xml.CipherValue Base64.encode64(iv + encrypted)
+ end
+ end
+end
lib/xml/kit/builders/templates/xml_signature.builder
@@ -0,0 +1,20 @@
+xml.Signature "xmlns" => ::Xml::Kit::Namespaces::XMLDSIG do
+ xml.SignedInfo do
+ xml.CanonicalizationMethod Algorithm: "http://www.w3.org/2001/10/xml-exc-c14n#"
+ xml.SignatureMethod Algorithm: signature_method
+ xml.Reference URI: "##{reference_id}" do
+ xml.Transforms do
+ xml.Transform Algorithm: "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
+ xml.Transform Algorithm: "http://www.w3.org/2001/10/xml-exc-c14n#"
+ end
+ xml.DigestMethod Algorithm: digest_method
+ xml.DigestValue ""
+ end
+ end
+ xml.SignatureValue ""
+ xml.KeyInfo do
+ xml.X509Data do
+ xml.X509Certificate certificate.stripped
+ end
+ end
+end
lib/xml/kit/builders/xml_encryption.rb
@@ -0,0 +1,20 @@
+module Xml
+ module Kit
+ module Builders
+ class XmlEncryption
+ attr_reader :public_key
+ attr_reader :key, :iv, :encrypted
+
+ def initialize(raw_xml, public_key)
+ @public_key = public_key
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
+ cipher.encrypt
+ @key = cipher.random_key
+ @iv = cipher.random_iv
+ @encrypted = cipher.update(raw_xml) + cipher.final
+ end
+ end
+ end
+ end
+end
+
lib/xml/kit/builders/xml_signature.rb
@@ -0,0 +1,34 @@
+module Xml
+ module Kit
+ module Builders
+ class XmlSignature
+ SIGNATURE_METHODS = {
+ SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
+ SHA224: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha224",
+ SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
+ SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
+ }.freeze
+ DIGEST_METHODS = {
+ SHA1: "http://www.w3.org/2000/09/xmldsig#SHA1",
+ SHA224: "http://www.w3.org/2001/04/xmldsig-more#sha224",
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512",
+ }.freeze
+
+ attr_reader :certificate
+ attr_reader :digest_method
+ attr_reader :reference_id
+ attr_reader :signature_method
+
+ def initialize(reference_id, signature_method: :SH256, digest_method: :SHA256, certificate:)
+ @certificate = certificate
+ @digest_method = DIGEST_METHODS[digest_method]
+ @reference_id = reference_id
+ @signature_method = SIGNATURE_METHODS[signature_method]
+ end
+ end
+ end
+ end
+end
lib/xml/kit/fingerprint.rb
@@ -2,9 +2,9 @@ module Xml
module Kit
# This generates a fingerprint for an X509 Certificate.
#
- # certificate, _ = Saml::Kit::SelfSignedCertificate.new("password").create
+ # certificate, _ = Xml::Kit::SelfSignedCertificate.new("password").create
#
- # puts Saml::Kit::Fingerprint.new(certificate).to_s
+ # puts Xml::Kit::Fingerprint.new(certificate).to_s
# # B7:AB:DC:BD:4D:23:58:65:FD:1A:99:0C:5F:89:EA:87:AD:F1:D7:83:34:7A:E9:E4:88:12:DD:46:1F:38:05:93
#
# {include:file:spec/saml/fingerprint_spec.rb}
lib/xml/kit/key_pair.rb
@@ -0,0 +1,29 @@
+module Xml
+ module Kit
+ class KeyPair # :nodoc:
+ attr_reader :certificate, :private_key, :use
+
+ def initialize(certificate, private_key, passphrase, use)
+ @use = use
+ @certificate = ::Xml::Kit::Certificate.new(certificate, use: use)
+ @private_key = OpenSSL::PKey::RSA.new(private_key, passphrase)
+ end
+
+ # Returns true if the key pair is the designated use.
+ #
+ # @param use [Symbol] Can be either `:signing` or `:encryption`.
+ def for?(use)
+ @use == use
+ end
+
+ # Returns a generated self signed certificate with private key.
+ #
+ # @param use [Symbol] Can be either `:signing` or `:encryption`.
+ # @param passphrase [String] the passphrase to use to encrypt the private key.
+ def self.generate(use:, passphrase: SecureRandom.uuid)
+ certificate, private_key = ::Xml::Kit::SelfSignedCertificate.new(passphrase).create
+ new(certificate, private_key, passphrase, use)
+ end
+ end
+ end
+end
lib/xml/kit/self_signed_certificate.rb
@@ -0,0 +1,28 @@
+module Xml
+ module Kit
+ class SelfSignedCertificate
+ SUBJECT="/C=CA/ST=Alberta/L=Calgary/O=SamlKit/OU=SamlKit/CN=SamlKit"
+
+ def initialize(passphrase)
+ @passphrase = passphrase
+ end
+
+ def create
+ rsa_key = OpenSSL::PKey::RSA.new(2048)
+ public_key = rsa_key.public_key
+ certificate = OpenSSL::X509::Certificate.new
+ certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse(SUBJECT)
+ certificate.not_before = Time.now.to_i
+ certificate.not_after = (Date.today + 30).to_time.to_i
+ certificate.public_key = public_key
+ certificate.serial = 0x0
+ certificate.version = 2
+ certificate.sign(rsa_key, OpenSSL::Digest::SHA256.new)
+ [
+ certificate.to_pem,
+ rsa_key.to_pem(OpenSSL::Cipher.new('AES-256-CBC'), @passphrase)
+ ]
+ end
+ end
+ end
+end
lib/xml/kit/signatures.rb
@@ -0,0 +1,67 @@
+module Xml
+ module Kit
+ # @!visibility private
+ class Signatures # :nodoc:
+ attr_reader :key_pair, :signature_method, :digest_method
+
+ # @!visibility private
+ def initialize(key_pair:, signature_method:, digest_method:)
+ @digest_method = digest_method
+ @key_pair = key_pair
+ @signature_method = signature_method
+ end
+
+ # @!visibility private
+ def sign_with(key_pair)
+ @key_pair = key_pair
+ end
+
+ # @!visibility private
+ def build(reference_id)
+ return nil if key_pair.nil?
+
+ ::Xml::Kit::Builders::XmlSignature.new(
+ reference_id,
+ certificate: key_pair.certificate,
+ signature_method: signature_method,
+ digest_method: digest_method
+ )
+ end
+
+ # @!visibility private
+ def complete(raw_xml)
+ return raw_xml if key_pair.nil?
+
+ private_key = key_pair.private_key
+ Xmldsig::SignedDocument.new(raw_xml).sign(private_key)
+ end
+
+ # @!visibility private
+ def self.sign(xml: ::Builder::XmlMarkup.new, key_pair:, signature_method: :SHA256, digest_method: :SHA256)
+ signatures = new(
+ key_pair: key_pair,
+ signature_method: signature_method,
+ digest_method: digest_method,
+ )
+ yield xml, XmlSignatureTemplate.new(xml, signatures)
+ signatures.complete(xml.target!)
+ end
+
+ class XmlSignatureTemplate # :nodoc:
+ # @!visibility private
+ attr_reader :signatures, :xml
+
+ # @!visibility private
+ def initialize(xml, signatures)
+ @signatures = signatures
+ @xml = xml
+ end
+
+ # @!visibility private
+ def template(reference_id)
+ Template.new(signatures.build(reference_id)).to_xml(xml: xml)
+ end
+ end
+ end
+ end
+end
lib/xml/kit/templatable.rb
@@ -0,0 +1,72 @@
+module Xml
+ module Kit
+ module Templatable
+ # Can be used to disable embeding a signature.
+ # By default a signature will be embedded if a signing
+ # certificate is available via the configuration.
+ attr_accessor :embed_signature
+
+ # @deprecated Use {#embed_signature=} instead of this method.
+ def sign=(value)
+ Xml::Kit.deprecate("sign= is deprecated. Use embed_signature= instead")
+ self.embed_signature = value
+ end
+
+ # Returns the generated XML document with an XML Digital Signature and XML Encryption.
+ def to_xml(xml: ::Builder::XmlMarkup.new)
+ signatures.complete(render(self, xml: xml))
+ end
+
+ # @!visibility private
+ def signature_for(reference_id:, xml:)
+ return unless sign?
+ render(signatures.build(reference_id), xml: xml)
+ end
+
+ # Allows you to specify which key pair to use for generating an XML digital signature.
+ #
+ # @param key_pair [Xml::Kit::KeyPair] the key pair to use for signing.
+ def sign_with(key_pair)
+ signatures.sign_with(key_pair)
+ end
+
+ # Returns true if an embedded signature is requested and at least one signing certificate is available via the configuration.
+ def sign?
+ return configuration.sign? if embed_signature.nil?
+ embed_signature && configuration.sign?
+ end
+
+ # @!visibility private
+ def signatures
+ @signatures ||= ::Xml::Kit::Signatures.new(
+ key_pair: configuration.key_pairs(use: :signing).last,
+ digest_method: configuration.digest_method,
+ signature_method: configuration.signature_method,
+ )
+ end
+
+ # @!visibility private
+ def encryption_for(xml:)
+ if encrypt?
+ temp = ::Builder::XmlMarkup.new
+ yield temp
+ signed_xml = signatures.complete(temp.target!)
+ xml_encryption = ::Xml::Kit::Builders::XmlEncryption.new(signed_xml, encryption_certificate.public_key)
+ render(xml_encryption, xml: xml)
+ else
+ yield xml
+ end
+ end
+
+ # @!visibility private
+ def encrypt?
+ encrypt && encryption_certificate
+ end
+
+ # @!visibility private
+ def render(model, options)
+ ::Xml::Kit::Template.new(model).to_xml(options)
+ end
+ end
+ end
+end
lib/xml/kit/template.rb
@@ -0,0 +1,32 @@
+module Xml
+ module Kit
+ class Template
+ attr_reader :target
+
+ def initialize(target)
+ @target = target
+ end
+
+ # Returns the compiled template as a [String].
+ #
+ # @param options [Hash] The options hash to pass to the template engine.
+ def to_xml(options = {})
+ template.render(target, options)
+ end
+
+ private
+
+ def template_path
+ return target.template_path if target.respond_to?(:template_path)
+
+ root_path = File.expand_path(File.dirname(__FILE__))
+ template_name = "#{target.class.name.split("::").last.underscore}.builder"
+ File.join(root_path, "builders/templates/", template_name)
+ end
+
+ def template
+ Tilt.new(template_path)
+ end
+ end
+ end
+end
lib/xml/kit/xml.rb
@@ -11,7 +11,7 @@ module Xml
def initialize(raw_xml, namespaces: NAMESPACES)
@raw_xml = raw_xml
@namespaces = namespaces
- @document = Nokogiri::XML(raw_xml)
+ @document = ::Nokogiri::XML(raw_xml)
end
# Returns the first XML node found by searching the document with the provided XPath.
lib/xml/kit.rb
@@ -1,13 +1,25 @@
require "active_model"
+require "active_support/core_ext/numeric/time"
require "base64"
+require "builder"
require "logger"
+require "nokogiri"
require "openssl"
+require "tilt"
+require "xmldsig"
+require "xml/kit/builders/xml_encryption"
+require "xml/kit/builders/xml_signature"
require "xml/kit/certificate"
require "xml/kit/crypto"
require "xml/kit/fingerprint"
require "xml/kit/id"
+require "xml/kit/key_pair"
require "xml/kit/namespaces"
+require "xml/kit/self_signed_certificate"
+require "xml/kit/signatures"
+require "xml/kit/templatable"
+require "xml/kit/template"
require "xml/kit/version"
require "xml/kit/xml"
require "xml/kit/xml_decryption"
spec/fixtures/item.builder
@@ -0,0 +1,4 @@
+xml.instruct!
+xml.Item ID: id do
+ signature_for(reference_id: id, xml: xml)
+end
spec/support/certificate_helper.rb
@@ -1,19 +1,5 @@
module CertificateHelper
def generate_key_pair(passphrase)
- rsa_key = OpenSSL::PKey::RSA.new(2048)
- public_key = rsa_key.public_key
- certificate = OpenSSL::X509::Certificate.new
- subject="/C=CA/ST=Alberta/L=Calgary/O=XmlKit/OU=XmlKit/CN=XmlKit"
- certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse(subject)
- certificate.not_before = Time.now.to_i
- certificate.not_after = (Date.today + 30).to_time.to_i
- certificate.public_key = public_key
- certificate.serial = 0x0
- certificate.version = 2
- certificate.sign(rsa_key, OpenSSL::Digest::SHA256.new)
- [
- certificate.to_pem,
- rsa_key.to_pem(OpenSSL::Cipher.new('AES-256-CBC'), passphrase)
- ]
+ ::Xml::Kit::SelfSignedCertificate.new(passphrase).create
end
end
spec/xml/signatures_spec.rb
@@ -0,0 +1,48 @@
+RSpec.describe ::Xml::Kit::Signatures do
+ let(:reference_id) { Xml::Kit::Id.generate }
+
+ it 'generates a signature' do
+ options = {
+ "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
+ "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion",
+ ID: reference_id,
+ }
+ key_pair = ::Xml::Kit::KeyPair.generate(use: :signing)
+ signed_xml = described_class.sign(key_pair: key_pair) do |xml, signature|
+ xml.tag!('samlp:AuthnRequest', options) do
+ signature.template(reference_id)
+ xml.tag!('saml:Issuer', "MyEntityID")
+ end
+ end
+ result = Hash.from_xml(signed_xml)
+
+ signature = result["AuthnRequest"]["Signature"]
+ expect(signature['xmlns']).to eql("http://www.w3.org/2000/09/xmldsig#")
+ expect(signature['SignedInfo']['CanonicalizationMethod']['Algorithm']).to eql('http://www.w3.org/2001/10/xml-exc-c14n#')
+ expect(signature['SignedInfo']['SignatureMethod']['Algorithm']).to eql("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")
+
+ expect(signature['SignedInfo']['Reference']['URI']).to eql("##{reference_id}")
+ expect(signature['SignedInfo']['Reference']['Transforms']['Transform']).to match_array([
+ { "Algorithm" => "http://www.w3.org/2000/09/xmldsig#enveloped-signature" },
+ { "Algorithm" => "http://www.w3.org/2001/10/xml-exc-c14n#" }
+ ])
+ expect(signature['SignedInfo']['Reference']['DigestMethod']['Algorithm']).to eql("http://www.w3.org/2001/04/xmlenc#sha256")
+ expected_certificate = key_pair.certificate.stripped
+ expect(signature['KeyInfo']['X509Data']['X509Certificate']).to eql(expected_certificate)
+ expect(signature['SignedInfo']['Reference']['DigestValue']).to be_present
+ expect(signature['SignatureValue']).to be_present
+ expect(OpenSSL::X509::Certificate.new(Base64.decode64(signature['KeyInfo']['X509Data']['X509Certificate']))).to be_present
+ end
+
+ it 'does not add a signature' do
+ signed_xml = described_class.sign(key_pair: nil) do |xml, signature|
+ xml.AuthnRequest do
+ signature.template(reference_id)
+ xml.Issuer "MyEntityID"
+ end
+ end
+ result = Hash.from_xml(signed_xml)
+ expect(result['AuthnRequest']).to be_present
+ expect(result["AuthnRequest"]["Signature"]).to be_nil
+ end
+end
spec/xml/xml_spec.rb
@@ -1,22 +1,33 @@
RSpec.describe Xml::Kit::Xml do
+ class Item
+ include ::Xml::Kit::Templatable
+
+ attr_reader :id, :configuration
+
+ def initialize(configuration)
+ @id = ::Xml::Kit::Id.generate
+ @configuration = configuration
+ end
+
+ def template_path
+ current_path = File.expand_path(File.dirname(__FILE__))
+ File.join(current_path, "../fixtures/item.builder")
+ end
+ end
+
describe "#valid_signature?" do
let(:login_url) { "https://#{FFaker::Internet.domain_name}/login" }
let(:logout_url) { "https://#{FFaker::Internet.domain_name}/logout" }
let(:configuration) do
- configuration = Saml::Kit::Configuration.new
- configuration.generate_key_pair_for(use: :signing)
- configuration
- end
-
- let(:signed_xml) do
- Saml::Kit::ServiceProviderMetadata.build(configuration: configuration) do |builder|
- builder.entity_id = FFaker::Movie.title
- builder.add_assertion_consumer_service(login_url, binding: :http_post)
- builder.add_assertion_consumer_service(login_url, binding: :http_redirect)
- builder.add_single_logout_service(logout_url, binding: :http_post)
- builder.add_single_logout_service(logout_url, binding: :http_redirect)
- end.to_xml
+ double(
+ :configuration,
+ sign?: true,
+ key_pairs: [::Xml::Kit::KeyPair.generate(use: :signing)],
+ signature_method: :SHA256,
+ digest_method: :SHA256,
+ )
end
+ let(:signed_xml) { Item.new(configuration).to_xml }
it 'returns true, when the digest and signature is valid' do
subject = described_class.new(signed_xml)
@@ -24,35 +35,25 @@ RSpec.describe Xml::Kit::Xml do
end
it 'returns false, when the SHA1 digest is not valid' do
- subject = described_class.new(signed_xml.gsub("EntityDescriptor", "uhoh"))
+ subject = described_class.new(signed_xml.gsub("Item", "uhoh"))
expect(subject).to_not be_valid
expect(subject.errors[:digest_value]).to be_present
end
it 'it is invalid when digest is incorrect' do
- old_digest = Hash.from_xml(signed_xml)['EntityDescriptor']['Signature']['SignedInfo']['Reference']['DigestValue']
+ old_digest = Hash.from_xml(signed_xml)['Item']['Signature']['SignedInfo']['Reference']['DigestValue']
+
subject = described_class.new(signed_xml.gsub(old_digest, 'sabotage'))
expect(subject).to_not be_valid
expect(subject.errors[:digest_value]).to be_present
end
it 'returns false, when the signature is invalid' do
- old_signature = Hash.from_xml(signed_xml)['EntityDescriptor']['Signature']['SignatureValue']
+ old_signature = Hash.from_xml(signed_xml)['Item']['Signature']['SignatureValue']
signed_xml.gsub!(old_signature, 'sabotage')
subject = described_class.new(signed_xml)
expect(subject).to_not be_valid
expect(subject.errors[:signature]).to be_present
end
-
- it 'is valid' do
- configuration = Saml::Kit::Configuration.new do |config|
- 5.times { config.generate_key_pair_for(use: :signing) }
- end
- signed_xml = Saml::Kit::Metadata.build_xml(configuration: configuration) do |builder|
- builder.build_identity_provider
- builder.build_service_provider
- end
- expect(described_class.new(signed_xml)).to be_valid
- end
end
end
spec/spec_helper.rb
@@ -1,7 +1,7 @@
require "bundler/setup"
require "xml/kit"
require "ffaker"
-require "saml/kit"
+require "active_support/core_ext/hash/conversions"
Xml::Kit.logger.level = Logger::FATAL
xml-kit.gemspec
@@ -22,9 +22,12 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]
spec.add_dependency "activemodel", ">= 4.2.0"
+ spec.add_dependency "builder", "~> 3.2"
+ spec.add_dependency "nokogiri", "~> 1.8"
+ spec.add_dependency "tilt", "~> 2.0"
+ spec.add_dependency "xmldsig", "~> 0.6"
spec.add_development_dependency "bundler", "~> 1.16"
spec.add_development_dependency "ffaker", "~> 2.7"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec", "~> 3.0"
- spec.add_development_dependency "saml-kit", "~> 0.3"
end