~/src/www.mokhan.ca/xlgmokha [main]
cat xml-kit.md
xml-kit.md 60910 bytes | 2018-11-12 12:00
symlink: /opt/ruby/xml-kit.md

XML-Kit - Ruby Library for Secure XML Processing

XML digital signatures and encryption are fundamental to secure document exchange, particularly in SAML, XML-RPC, and enterprise integration scenarios. Let’s explore xml-kit, a Ruby library that simplifies these complex cryptographic operations.

Let’s pretend that I need to generate an XML represenation of some sort of policy to be distributed to clients. This policy has one piece of configuration called name.

# policy.rb
class Policy
  attr_accessor :id, :name
end

Using this ruby class, I want to generate the following XML representation.

<Policy ID="_baf8cc51-2133-4312-87ee-76ccda7a8fbe">
  <Name>Audit</Name>
</Policy>

To accomplish this we can use builder and tilt.

The following changes should help us accomplish the goal of generating an xml document.

# policy.rb
class Policy
  attr_accessor :id, :name

  def template_path
    File.join(__dir__, 'policy.builder')
  end

  def to_xml(options = {})
    Tilt.new(template_path).render(self, options)
  end
end

# policy.builder
xml.instruct!
xml.Policy ID: id do
  xml.Name name
end

# app.rb
policy = Policy.new
policy.id = SecureRandom.uuid
policy.name = "Audit"
IO.write("policy.xml", policy.to_xml)

XML Digital Signature

Now we need a mechanism for clients to verify that the generated XML file has not been tampered with and was issued by the party that they trust. To accomplish this we can generate a XML digital signature.

We can use xml-kit to help sign our xml document. There are a few changes that we need to make to the code to generate a signed xml document.

  • include the Xml::Kit::Templatable module.
  • use the signature_for helper method in the xml builder template.
  • specify a key pair to use for generating the signature.
# policy.rb
class Policy
  include Xml::Kit::Templatable
  attr_accessor :id, :name

  def template_path
    File.join(__dir__, 'policy.builder')
  end
end

# policy.builder
xml.instruct!
xml.Policy ID: id do
  signature_for reference_id: id, xml: xml
  xml.Name name
end

# app.rb
policy = Policy.new
policy.id = Xml::Kit::Id.generate
policy.name = "Audit"
policy.sign_with(Xml::Kit::KeyPair.generate(use: :signing))
IO.write("policy.xml", policy.to_xml)

This will generate the same XML document with an added XML digital signature. The above example code uses xml-kit to generate a self signed certificate and key pair. This is helpful for development but not recommended in a production like environment.

The output of the above code is:

<Policy ID="_329d2b29-6a82-4fc9-927f-5575ecc633e5">
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
      <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
      <Reference URI="#_329d2b29-6a82-4fc9-927f-5575ecc633e5">
        <Transforms>
          <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
          <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        </Transforms>
        <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
        <DigestValue>...</DigestValue>
      </Reference>
    </SignedInfo>
    <SignatureValue>...</SignatureValue>
    <KeyInfo>
      <X509Data>
        <X509Certificate>...</X509Certificate>
      </X509Data>
    </KeyInfo>
  </Signature>
  <Name>Audit</Name>
</Policy>

Clients of this policy should receive the X509 certificate via a trusted transport mechanism so that it can verify that the document was signed with a key pair associated with a trusted certificate. The client can verify the signature to ensure that the document was not altered.

The current version of xml-kit only supports enveloped signatures.

XML Encryption

Next we can look at encrypting the <Name>..</Name> element using the XML Encryption standard.

To do this we can use the encrypt_with method to specify the x509 certificate of the client so that we can encrypt the data for a specific client. The xml builder template uses the encrypt_data_for helper to create an XML encryption element.

The revised version of the code looks like:

# policy.rb
class Policy
  include Xml::Kit::Templatable
  attr_accessor :id, :name

  def template_path
    File.join(__dir__, 'policy.builder')
  end
end

# policy.builder
xml.instruct!
xml.Policy ID: id do
  signature_for reference_id: id, xml: xml
  encrypt_data_for xml: xml do |xml|
    xml.Name name
  end
end

# app.rb
policy = Policy.new
policy.id = Xml::Kit::Id.generate
policy.name = "Audit"
policy.sign_with(Xml::Kit::KeyPair.generate(use: :signing))
policy.encrypt_with(Xml::Kit::KeyPair.generate(use: :encryption).certificate)
IO.write("policy.xml", policy.to_xml)

The final version of the signed and encrypted document looks like:

<?xml version="1.0"?>
<Policy ID="_3fe07010-a5d4-4114-b919-4219a2d5bb0c">
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
      <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
      <Reference URI="#_3fe07010-a5d4-4114-b919-4219a2d5bb0c">
        <Transforms>
          <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
          <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        </Transforms>
        <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
        <DigestValue>...</DigestValue>
      </Reference>
    </SignedInfo>
    <SignatureValue>...</SignatureValue>
    <KeyInfo>
      <X509Data>
        <X509Certificate>...</X509Certificate>
      </X509Data>
    </KeyInfo>
  </Signature>
  <EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#">
    <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
    <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
      <EncryptedKey xmlns="http://www.w3.org/2001/04/xmlenc#">
        <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
        <CipherData>
          <CipherValue>...</CipherValue>
        </CipherData>
      </EncryptedKey>
    </KeyInfo>
    <CipherData>
      <CipherValue>...</CipherValue>
    </CipherData>
  </EncryptedData>
</Policy>

Real-World Implementation: SAML Document Security

Here’s how xml-kit is used in production SAML implementations. This example is from my OAuth2 and SAML server:

SAML Response Signing

# app/models/saml_response.rb - Production SAML response generation
class SamlResponse
  include Xml::Kit::Templatable
  
  attr_accessor :id, :destination, :assertion_id, :subject, :attributes

  def initialize(authn_request)
    @id = Xml::Kit::Id.generate
    @assertion_id = Xml::Kit::Id.generate
    @destination = authn_request.assertion_consumer_service_url
    @issue_instant = Time.current.iso8601
    @not_before = Time.current.iso8601
    @not_on_or_after = 30.minutes.from_now.iso8601
    @authn_instant = Time.current.iso8601
  end

  def template_path
    Rails.root.join('app/templates/saml_response.builder')
  end

  def to_xml
    sign_with(saml_kit_configuration.signing_key_pair)
    super
  end

  private

  def saml_kit_configuration
    Saml::Kit.configuration
  end
end

SAML Response Template

# app/templates/saml_response.builder - Signed SAML response template
xml.instruct!
xml.Response(
  'xmlns:saml' => 'urn:oasis:names:tc:SAML:2.0:assertion',
  'xmlns:samlp' => 'urn:oasis:names:tc:SAML:2.0:protocol',
  ID: id,
  InResponseTo: in_response_to,
  Version: '2.0',
  IssueInstant: issue_instant,
  Destination: destination
) do
  xml.Issuer 'xmlns' => 'urn:oasis:names:tc:SAML:2.0:assertion' do
    xml.text! Saml::Kit.configuration.entity_id
  end
  
  # XML digital signature using xml-kit
  signature_for reference_id: id, xml: xml
  
  xml.Status do
    xml.StatusCode Value: 'urn:oasis:names:tc:SAML:2.0:status:Success'
  end
  
  xml.Assertion(
    'xmlns' => 'urn:oasis:names:tc:SAML:2.0:assertion',
    ID: assertion_id,
    Version: '2.0',
    IssueInstant: issue_instant
  ) do
    xml.Issuer Saml::Kit.configuration.entity_id
    
    xml.Subject do
      xml.NameID(
        Format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
        NameQualifier: Saml::Kit.configuration.entity_id
      ) do
        xml.text! subject
      end
      
      xml.SubjectConfirmation Method: 'urn:oasis:names:tc:SAML:2.0:cm:bearer' do
        xml.SubjectConfirmationData(
          InResponseTo: in_response_to,
          NotOnOrAfter: not_on_or_after,
          Recipient: destination
        )
      end
    end
    
    xml.Conditions(
      NotBefore: not_before,
      NotOnOrAfter: not_on_or_after
    ) do
      xml.AudienceRestriction do
        xml.Audience audience
      end
    end
    
    xml.AuthnStatement(
      AuthnInstant: authn_instant,
      SessionIndex: session_index
    ) do
      xml.AuthnContext do
        xml.AuthnContextClassRef 'urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified'
      end
    end
    
    # Encrypted attribute statement
    if attributes.present?
      xml.AttributeStatement do
        attributes.each do |name, value|
          xml.Attribute Name: name do
            xml.AttributeValue value
          end
        end
      end
    end
  end
end

SAML Service Provider Integration

# app/controllers/saml_controller.rb - SAML response processing
class SamlController < ApplicationController
  def acs
    # Parse and validate signed SAML response
    saml_response = Saml::Kit::Response.deserialize(params[:SAMLResponse])
    
    # Verify XML digital signature
    unless saml_response.valid?
      Rails.logger.error "Invalid SAML response: #{saml_response.errors.full_messages}"
      redirect_to new_session_path, alert: 'Authentication failed'
      return
    end
    
    # Extract user information from verified assertion
    assertion = saml_response.assertion
    user_email = assertion.name_id
    user_attributes = assertion.attribute_statements.first&.attributes || {}
    
    # Process authenticated user
    user = User.find_or_initialize_by(email: user_email)
    user.name = user_attributes['name']
    user.save!
    
    sign_in(user)
    redirect_to root_path, notice: 'Successfully authenticated via SAML'
  end
end

XML Encryption for Sensitive Data

# app/models/encrypted_saml_assertion.rb - Encrypted SAML assertions
class EncryptedSamlAssertion
  include Xml::Kit::Templatable
  
  attr_accessor :id, :assertion_data, :recipient_certificate

  def initialize(assertion_data, recipient_cert)
    @id = Xml::Kit::Id.generate
    @assertion_data = assertion_data
    @recipient_certificate = recipient_cert
  end

  def template_path
    Rails.root.join('app/templates/encrypted_assertion.builder')
  end

  def to_xml
    encrypt_with(recipient_certificate)
    super
  end
end
# app/templates/encrypted_assertion.builder - XML encryption template
xml.instruct!
xml.EncryptedAssertion 'xmlns' => 'urn:oasis:names:tc:SAML:2.0:assertion' do
  # XML encryption using xml-kit
  encrypt_data_for xml: xml do |encrypted_xml|
    encrypted_xml.Assertion(
      'xmlns' => 'urn:oasis:names:tc:SAML:2.0:assertion',
      ID: assertion_id,
      Version: '2.0',
      IssueInstant: Time.current.iso8601
    ) do
      encrypted_xml.Issuer Saml::Kit.configuration.entity_id
      
      encrypted_xml.Subject do
        encrypted_xml.NameID assertion_data[:subject]
      end
      
      encrypted_xml.AttributeStatement do
        assertion_data[:attributes].each do |name, value|
          encrypted_xml.Attribute Name: name do
            encrypted_xml.AttributeValue value
          end
        end
      end
    end
  end
end

Certificate and Key Management

# config/initializers/xml_kit.rb - Production XML-Kit configuration
Xml::Kit.configure do |config|
  # Configure signing certificates
  config.signing_certificate = ENV.fetch('SAML_SIGNING_CERTIFICATE')
  config.signing_private_key = ENV.fetch('SAML_SIGNING_PRIVATE_KEY')
  
  # Configure encryption certificates
  config.encryption_certificate = ENV.fetch('SAML_ENCRYPTION_CERTIFICATE')
  config.encryption_private_key = ENV.fetch('SAML_ENCRYPTION_PRIVATE_KEY')
  
  # Security settings
  config.default_digest_algorithm = :sha256
  config.default_signature_algorithm = :rsa_sha256
  config.default_canonicalization_algorithm = :exclusive
end

# Key pair generation for development
class XmlKitSetup
  def self.generate_development_keys!
    # Generate signing key pair
    signing_key = Xml::Kit::KeyPair.generate(use: :signing)
    Rails.application.credentials.xml_kit[:signing_certificate] = signing_key.certificate
    Rails.application.credentials.xml_kit[:signing_private_key] = signing_key.private_key
    
    # Generate encryption key pair
    encryption_key = Xml::Kit::KeyPair.generate(use: :encryption)
    Rails.application.credentials.xml_kit[:encryption_certificate] = encryption_key.certificate
    Rails.application.credentials.xml_kit[:encryption_private_key] = encryption_key.private_key
    
    puts "XML-Kit development keys generated successfully"
  end
end

Advanced XML Processing Patterns

Custom XML Signature Validation

# app/services/xml_signature_validator.rb - Custom signature validation
class XmlSignatureValidator
  def initialize(trusted_certificates)
    @trusted_certificates = trusted_certificates
  end

  def validate_signature(xml_document)
    doc = Nokogiri::XML(xml_document)
    signature_element = doc.at_xpath('//ds:Signature', 'ds' => 'http://www.w3.org/2000/09/xmldsig#')
    
    return { valid: false, error: 'No signature found' } unless signature_element
    
    # Extract certificate from signature
    cert_data = signature_element.at_xpath('.//ds:X509Certificate', 'ds' => 'http://www.w3.org/2000/09/xmldsig#')&.text
    return { valid: false, error: 'No certificate in signature' } unless cert_data
    
    certificate = OpenSSL::X509::Certificate.new(Base64.decode64(cert_data))
    
    # Verify certificate is trusted
    unless @trusted_certificates.any? { |trusted| trusted.to_pem == certificate.to_pem }
      return { valid: false, error: 'Certificate not trusted' }
    end
    
    # Verify signature using xml-kit
    signature = Xml::Kit::Signature.new(signature_element)
    if signature.valid?
      { valid: true, certificate: certificate }
    else
      { valid: false, error: 'Invalid signature' }
    end
  rescue => e
    { valid: false, error: e.message }
  end
end

XML Canonicalization and Transformation

# app/services/xml_canonicalizer.rb - XML canonicalization service
class XmlCanonicalizer
  def self.canonicalize(xml_content, algorithm = :exclusive)
    doc = Nokogiri::XML(xml_content)
    
    case algorithm
    when :exclusive
      doc.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)
    when :inclusive
      doc.canonicalize(Nokogiri::XML::XML_C14N_1_0)
    else
      raise ArgumentError, "Unsupported canonicalization algorithm: #{algorithm}"
    end
  end
  
  def self.transform_for_signature(element)
    # Apply enveloped signature transform
    transformed = element.dup
    signature_elements = transformed.xpath('.//ds:Signature', 'ds' => 'http://www.w3.org/2000/09/xmldsig#')
    signature_elements.remove
    
    canonicalize(transformed.to_xml, :exclusive)
  end
end

Performance and Security Considerations

XML Processing Security

# app/services/secure_xml_processor.rb - Secure XML processing
class SecureXmlProcessor
  MAX_DOCUMENT_SIZE = 10.megabytes
  MAX_ENTITY_EXPANSIONS = 1000
  
  def self.parse_safely(xml_content)
    # Validate document size
    if xml_content.bytesize > MAX_DOCUMENT_SIZE
      raise SecurityError, "XML document too large: #{xml_content.bytesize} bytes"
    end
    
    # Configure Nokogiri for security
    options = Nokogiri::XML::ParseOptions::STRICT |
              Nokogiri::XML::ParseOptions::NONET |
              Nokogiri::XML::ParseOptions::NOENT
    
    doc = Nokogiri::XML(xml_content, nil, nil, options)
    
    # Check for entity expansion attacks
    if doc.to_xml.bytesize > xml_content.bytesize * 10
      raise SecurityError, "Potential XML entity expansion attack detected"
    end
    
    doc
  rescue Nokogiri::XML::SyntaxError => e
    raise SecurityError, "Invalid XML syntax: #{e.message}"
  end
end

Complete Implementation

The examples above are from a production SAML and OAuth2 server implementation. You can explore the complete codebase:

GitHub Repository: https://github.com/xlgmokha/proof

Key XML-Kit implementation files:

Run the complete XML-Kit implementation:

git clone https://github.com/xlgmokha/proof.git
cd proof
bundle install
rails db:setup
rails server

# XML endpoints with digital signatures:
# GET  /metadata           - Signed SAML metadata
# POST /saml/acs           - SAML response validation
# GET  /sessions/new       - Signed authentication requests

Test XML signature operations:

# Get signed SAML metadata
curl -X GET "http://localhost:3000/metadata" | xmllint --format -

# Generate signed SAML authentication request
curl -X GET "http://localhost:3000/sessions/new?SAMLRequest=..."

# Validate signed SAML response
curl -X POST "http://localhost:3000/saml/acs" \
  -d "SAMLResponse=base64_encoded_signed_response"

Key Benefits of XML-Kit

  1. Simplified API - Complex XML cryptographic operations made simple
  2. Standards Compliance - Full W3C XML Signature and Encryption support
  3. SAML Integration - Seamless integration with saml-kit library
  4. Production Ready - Used in enterprise SAML implementations
  5. Security Focused - Built-in protection against XML attacks

Conclusion

XML is verbose and difficult to read. However, generating a signed and encrypted xml document is a little bit easier with xml-kit.

XML-Kit provides a Ruby-friendly interface to complex XML security standards, making it practical to implement secure document exchange in production applications. Whether you’re building SAML identity providers, secure API endpoints, or enterprise integration systems, xml-kit simplifies the cryptographic heavy lifting while maintaining standards compliance.

The library continues to evolve with new features and security enhancements. Please feel free to contribute to its development.