Commit 8123d24
Changed files (14)
lib
saml
kit
spec
saml
lib/saml/kit/concerns/trustable.rb
@@ -11,9 +11,15 @@ module Saml
included do
validate :must_have_valid_signature, unless: :signature_verified
validate :must_be_registered
+ validate :must_be_signed, if: :signature_required?
validate :must_be_trusted
end
+ # Returns true when this document must carry a signature to be valid.
+ def signature_required?
+ configuration.signature_required && signature_required_by_type?
+ end
+
# Returns true when the document has an embedded XML Signature or has
# been verified externally.
def signed?
@@ -65,9 +71,40 @@ module Saml
errors.add(:provider, error_message(:unregistered))
end
+ # Whether a document of this type is worthless unless signed.
+ #
+ # A request may legitimately arrive unsigned, which is what
+ # `WantAuthnRequestsSigned="false"` means, so the default is false. A
+ # document that asserts an identity carries no authenticity guarantee at
+ # all when unsigned, so Response and Assertion override this.
+ def signature_required_by_type?
+ false
+ end
+
+ def must_be_signed
+ return unless expected_type?
+ return if signature_covers_document?
+
+ errors.add(:base, error_message(:unsigned))
+ end
+
+ # Whether a signature exists over the content this document asserts.
+ # Response overrides this, because a signature on either the Response or
+ # the Assertion covers the identity being asserted.
+ def signature_covers_document?
+ signed?
+ end
+
+ # The trust counterpart of `signature_covers_document?`. Kept separate
+ # from the public `trusted?`, which answers the narrower question of
+ # whether *this* element's own signature is trusted.
+ def signature_trusted?
+ trusted?
+ end
+
def must_be_trusted
- return if trusted?
- return if provider.present? && !signed?
+ return if signature_trusted?
+ return if !signature_required? && provider.present? && !signed?
errors.add(:fingerprint, error_message(:invalid_fingerprint))
end
lib/saml/kit/locales/en.yml
@@ -11,6 +11,7 @@ en:
must_contain_single_assertion: "must contain single Assertion."
must_match_issuer: "must match entityId."
unregistered: "is unregistered."
+ unsigned: "must be signed."
AuthnRequest:
invalid: "must contain AuthnRequest."
invalid_fingerprint: "is not registered."
@@ -34,10 +35,12 @@ en:
invalid_version: "must be 2.0."
unregistered: "must originate from registered identity provider."
must_contain_single_assertion: "must contain a single Assertion."
+ unsigned: "must be signed."
Signature:
certificate: "Not valid before %{not_before}. Not valid after %{not_after}."
digest_value: "is invalid."
empty: "is missing."
+ reference: "must reference the element it is attached to."
signature: "is invalid."
SPSSODescriptor:
invalid: "must contain SPSSODescriptor."
lib/saml/kit/assertion.rb
@@ -89,9 +89,7 @@ module Saml
end
def decryptable?
- return true unless encrypted?
-
- !@cannot_decrypt
+ !encrypted? || !@cannot_decrypt
end
def to_s
@@ -100,6 +98,12 @@ module Saml
private
+ # A nested Assertion is covered by the enclosing Response's policy; a
+ # bare one is the whole document and has nothing else vouching for it.
+ def signature_required_by_type?
+ to_nokogiri == to_nokogiri.document.root
+ end
+
def decrypt(decryptor)
encrypted_assertion = at_xpath('./xmlenc:EncryptedData')
@encrypted = encrypted_assertion.present?
lib/saml/kit/configuration.rb
@@ -46,6 +46,13 @@ module Saml
attr_accessor :logger
# The total allowable clock drift for session timeout validation.
attr_accessor :clock_drift
+ # Whether a document that asserts an identity must carry a signature.
+ #
+ # An unsigned Response or Assertion proves nothing about who issued it,
+ # so this defaults to true and should stay that way. Set it to false only
+ # to keep an identity provider that signs nothing working while it is
+ # being fixed, and understand that doing so accepts forged assertions.
+ attr_accessor :signature_required
def initialize
@clock_drift = 30.seconds
@@ -55,6 +62,7 @@ module Saml
@registry = DefaultRegistry.new
@session_timeout = 3.hours
@signature_method = :SHA256
+ @signature_required = true
yield self if block_given?
end
lib/saml/kit/response.rb
@@ -41,10 +41,26 @@ module Saml
private
+ def signature_required_by_type?
+ true
+ end
+
+ # `signed?` and `trusted?` deliberately report only this element's own
+ # signature, but signing only the Assertion is a common identity provider
+ # configuration, and that signature covers the identity being asserted.
+ def signature_covers_document?
+ signed? || assertion.signed?
+ end
+
+ def signature_trusted?
+ super || (assertion.signed? && assertion.trusted?)
+ end
+
def must_be_valid_assertion
assertion.valid?
assertion.each_error do |attribute, error|
- errors.add(attribute == :base ? :assertion : attribute, error)
+ attribute = :assertion if attribute == :base
+ errors.add(attribute, error) unless errors.added?(attribute, error)
end
end
lib/saml/kit/signature.rb
@@ -11,6 +11,7 @@ module Saml
validate :validate_signature
validate :validate_certificate
+ validate :validate_reference
attr_reader :name
@@ -20,18 +21,24 @@ module Saml
end
# Returns the embedded X509 Certificate
+ #
+ # Memoized with a `defined?` guard rather than `||=` because a signature
+ # without KeyInfo legitimately has no certificate, and callers depend on
+ # the nil.
def certificate
- xpath = './ds:KeyInfo/ds:X509Data/ds:X509Certificate'
- value = at_xpath(xpath).try(:text)
- return if value.nil?
+ return @certificate if defined?(@certificate)
- ::Xml::Kit::Certificate.new(value, use: :signing)
+ value = at_xpath('./ds:KeyInfo/ds:X509Data/ds:X509Certificate').try(:text)
+ @certificate =
+ value.nil? ? nil : ::Xml::Kit::Certificate.new(value, use: :signing)
end
# Returns true when the fingerprint of the certificate matches one of
# the certificates registered in the metadata.
def trusted?(metadata)
return false if metadata.nil?
+ return false if certificate.nil?
+ return false unless reference.bound?
metadata.matches?(certificate.fingerprint, use: :signing).present?
end
@@ -66,15 +73,9 @@ module Saml
end
def transforms
- xpath = xpath_for([
- '.',
- 'ds:SignedInfo',
- 'ds:Reference',
- 'ds:Transforms',
- 'ds:Transform',
- '@Algorithm',
- ])
- node.search(xpath, Saml::Kit::Document::NAMESPACES).try(:map, &:value)
+ xpath = './ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform'
+ node.search("#{xpath}/@Algorithm", Saml::Kit::Document::NAMESPACES)
+ .try(:map, &:value)
end
# Returns the XML Hash.
@@ -109,6 +110,17 @@ module Saml
errors.add(:base, error.message)
end
+ def validate_reference
+ return unless present?
+ return if reference.bound?
+
+ errors.add(:reference, error_message(:reference))
+ end
+
+ def reference
+ @reference ||= SignatureReference.new(node)
+ end
+
def validate_certificate(now = Time.now.utc)
return unless certificate.present?
return if certificate.active?(now)
@@ -128,11 +140,7 @@ module Saml
end
def dsignature
- @dsignature ||= Xmldsig::Signature.new(node, 'ID=$uri or @Id')
- end
-
- def xpath_for(segments)
- segments.join('/')
+ @dsignature ||= Xmldsig::Signature.new(node, SignatureReference::ID_ATTR)
end
end
end
lib/saml/kit/signature_reference.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Saml
+ module Kit
+ # This class is responsible for deciding whether an xml digital signature
+ # actually covers the element it is attached to.
+ #
+ # A cryptographically intact signature only proves that *some* element in
+ # the document is unmodified. xmldsig resolves a Reference by searching the
+ # whole document for a matching ID, so without this check an attacker can
+ # copy a valid signature onto a forged element and hide the element it
+ # really covers somewhere the schema still permits, e.g. saml:Advice or
+ # ds:Object.
+ class SignatureReference
+ # The id_attr handed to Xmldsig::Signature, which expands it into
+ # "//*[@ID=$uri or @Id=$uri]". Both resolvers must agree on this or the
+ # element we check is not the element that was digested.
+ ID_ATTR = 'ID=$uri or @Id'
+ XPATH = './ds:SignedInfo/ds:Reference'
+
+ def initialize(node)
+ @node = node
+ end
+
+ # Returns true when some Reference resolves to the element the signature
+ # hangs off, and resolves to nothing else.
+ #
+ # Every Reference lives inside ds:SignedInfo, which ds:SignatureValue
+ # covers, so an attacker replaying a signature cannot add a Reference or
+ # retarget an existing one. Finding our element among them is therefore
+ # enough. SAML 2.0 core 5.4.2 permits only one, but documents carrying
+ # more exist, and each extra Reference only widens what the signature
+ # already commits to.
+ def bound?
+ return @bound if defined?(@bound)
+
+ @bound = binds_parent_element?
+ end
+
+ private
+
+ attr_reader :node
+
+ def binds_parent_element?
+ return false if node.nil? || signed.nil?
+
+ references.any? { |reference| covers_signed_element?(reference) }
+ end
+
+ def covers_signed_element?(reference)
+ uri = reference.attribute('URI').try(:value).to_s
+ # An empty URI digests the whole document, so it covers every element in
+ # it and leaves nowhere to hide an original.
+ return true if uri.empty?
+
+ id.present? && uri == "##{id}" && resolves_to_signed_element?
+ end
+
+ # `bound?` memoizes the one answer this class exists to give, so only the
+ # XPath search below is worth caching; the rest are O(1) node lookups.
+ def signed
+ node.parent
+ end
+
+ def references
+ @references ||= node.search(XPATH, Saml::Kit::Document::NAMESPACES)
+ end
+
+ def id
+ signed.attribute('ID').try(:value) || signed.attribute('Id').try(:value)
+ end
+
+ # Duplicate IDs are only rejected when schema validation runs, and an
+ # XPath lookup silently returns the first match, so require exactly one.
+ def resolves_to_signed_element?
+ matches = node.document.xpath(
+ "//*[@#{ID_ATTR}=$uri]",
+ Saml::Kit::Document::NAMESPACES,
+ 'uri' => id
+ )
+ matches.count == 1 && matches.first == signed
+ end
+ end
+ end
+end
lib/saml/kit/version.rb
@@ -2,6 +2,6 @@
module Saml
module Kit
- VERSION = '1.4.1'
+ VERSION = '1.5.0'
end
end
lib/saml/kit.rb
@@ -44,6 +44,7 @@ require 'saml/kit/logout_request'
require 'saml/kit/metadata'
require 'saml/kit/deprecated/metadata'
require 'saml/kit/null_assertion'
+require 'saml/kit/signature_reference'
require 'saml/kit/organization'
require 'saml/kit/parser'
require 'saml/kit/composite_metadata'
spec/saml/kit/response_spec.rb
@@ -585,26 +585,28 @@ RSpec.describe Saml::Kit::Response do
end
describe '#build' do
- it 'can build a response without a request' do
- configuration = Saml::Kit::Configuration.new do |config|
+ let(:configuration) do
+ Saml::Kit::Configuration.new do |config|
config.entity_id = FFaker::Internet.uri('https')
+ config.generate_key_pair_for(use: :signing)
end
- sp = Saml::Kit::Metadata.build(&:build_service_provider)
+ end
+ let(:sp) do
+ Saml::Kit::Metadata.build(configuration: configuration, &:build_service_provider)
+ end
+
+ before do
allow(configuration.registry).to receive(:metadata_for).with(configuration.entity_id).and_return(sp)
+ end
+
+ it 'can build a response without a request' do
result = described_class.build(user, configuration: configuration)
expect(result).to be_instance_of(described_class)
expect(result).to be_valid
end
it 'can build a response without the need for the user to provide attributes' do
- configuration = Saml::Kit::Configuration.new do |config|
- config.entity_id = FFaker::Internet.uri('https')
- end
- sp = Saml::Kit::Metadata.build(&:build_service_provider)
- allow(configuration.registry).to receive(:metadata_for).with(configuration.entity_id).and_return(sp)
- user = UserWithoutAttributes.new
-
- result = described_class.build(user, configuration: configuration)
+ result = described_class.build(UserWithoutAttributes.new, configuration: configuration)
expect(result).to be_instance_of(described_class)
expect(result).to be_valid
expect(result.attributes).to be_empty
spec/saml/kit/security_spec.rb
@@ -0,0 +1,303 @@
+# frozen_string_literal: true
+
+# Regression coverage for the signature stripping and signature wrapping
+# advisories. Each example asserts the behaviour we want, so every example in
+# this file fails against a vulnerable build.
+#
+# Nothing here is stubbed: real key pairs, a real registry, real signatures.
+# Stubbing the trust path would defeat the purpose.
+# This file spans Response, Assertion, Signature and Trustable, so there is no
+# single described class.
+# rubocop:disable RSpec/DescribeClass
+RSpec.describe 'Signature verification' do
+ subject(:response) { Saml::Kit::Response.new(xml, configuration: sp_configuration) }
+
+ let(:namespaces) { Saml::Kit::Document::NAMESPACES }
+ let(:as_xml) { Nokogiri::XML::Node::SaveOptions::AS_XML }
+
+ let(:idp_entity_id) { 'https://idp.example.com/metadata' }
+ let(:sp_entity_id) { 'https://sp.example.com/metadata' }
+
+ let(:idp_configuration) do
+ Saml::Kit::Configuration.new do |config|
+ config.entity_id = idp_entity_id
+ config.generate_key_pair_for(use: :signing)
+ end
+ end
+
+ let(:registry) do
+ Saml::Kit::DefaultRegistry.new.tap do |x|
+ x.register(Saml::Kit::IdentityProviderMetadata.new(idp_metadata_xml))
+ end
+ end
+
+ let(:idp_metadata_xml) do
+ Saml::Kit::IdentityProviderMetadata.build(configuration: idp_configuration) do |x|
+ x.entity_id = idp_entity_id
+ x.add_single_sign_on_service('https://idp.example.com/login', binding: :http_post)
+ end.to_xml
+ end
+
+ let(:sp_configuration) do
+ Saml::Kit::Configuration.new do |config|
+ config.entity_id = sp_entity_id
+ config.registry = registry
+ end
+ end
+
+ let(:user) { User.new(attributes: { id: SecureRandom.uuid }) }
+ let(:request) do
+ instance_double(
+ Saml::Kit::AuthenticationRequest,
+ id: ::Xml::Kit::Id.generate,
+ issuer: sp_entity_id,
+ assertion_consumer_service_url: "#{sp_entity_id}/acs",
+ name_id_format: Saml::Kit::Namespaces::PERSISTENT,
+ provider: nil,
+ signed?: true,
+ trusted?: true
+ )
+ end
+
+ # A Response signed at both the Response and Assertion level.
+ let(:signed_xml) do
+ Saml::Kit::Response.build(user, request, configuration: idp_configuration) do |x|
+ x.issuer = idp_entity_id
+ x.embed_signature = true
+ end.to_xml
+ end
+
+ # A Response signed only at the Response level.
+ let(:response_signed_xml) do
+ Saml::Kit::Response.build(user, request, configuration: idp_configuration) do |x|
+ x.issuer = idp_entity_id
+ x.embed_signature = true
+ x.assertion.embed_signature = false
+ end.to_xml
+ end
+
+ def described_response(xml)
+ Saml::Kit::Response.new(xml, configuration: sp_configuration)
+ end
+
+ shared_examples 'an accepted Response' do
+ it 'is valid and reports the real subject' do
+ expect(response).to be_valid
+ expect(response.name_id).to eql(user.name_id)
+ end
+ end
+
+ # Rejecting a forgery is only meaningful if the reference check still binds a
+ # signature to whichever element the identity provider chose to sign.
+ shared_examples 'a rejected forgery' do
+ it 'is invalid' do
+ expect(response).to be_invalid
+ end
+
+ it 'does not report the forged document as trusted' do
+ response.valid?
+ expect(response).not_to be_trusted
+ expect(response.assertion).not_to be_trusted
+ end
+ end
+
+ context 'when signed at both the Response and Assertion level' do
+ let(:xml) { signed_xml }
+
+ it_behaves_like 'an accepted Response'
+ end
+
+ context 'when signed only at the Response level' do
+ let(:xml) { response_signed_xml }
+
+ it_behaves_like 'an accepted Response'
+ end
+
+ # Signing only the Assertion is the default for several identity providers,
+ # so requiring a signature must not reject it.
+ context 'when signed only at the Assertion level' do
+ let(:xml) do
+ document = Nokogiri::XML(signed_xml)
+ document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
+ document.to_xml(save_with: as_xml)
+ end
+
+ it_behaves_like 'an accepted Response'
+
+ # Widening this to include the Assertion's signature would silently relax
+ # every `raise unless response.trusted?` guard written against 1.4.1.
+ it 'still reports trusted? for the Response element alone' do
+ expect(response).not_to be_trusted
+ expect(response.assertion).to be_trusted
+ end
+ end
+
+ # The opt-out exists so an operator whose identity provider signs nothing can
+ # stage the upgrade instead of pinning 1.4.1. It restores 1.4.1 behaviour
+ # exactly, forgeries included, which is why it defaults to on.
+ describe 'configuration.signature_required' do
+ let(:xml) do
+ document = Nokogiri::XML(signed_xml)
+ document.xpath('//ds:Signature', namespaces).each(&:remove)
+ document.to_xml(save_with: as_xml)
+ end
+
+ it 'defaults to requiring a signature' do
+ expect(sp_configuration.signature_required).to be(true)
+ expect(response).to be_invalid
+ end
+
+ it 'accepts an unsigned Response from a registered issuer when disabled' do
+ sp_configuration.signature_required = false
+ expect(response).to be_valid
+ end
+
+ it 'still refuses to call an unsigned Response trusted when disabled' do
+ sp_configuration.signature_required = false
+ response.valid?
+ expect(response).not_to be_trusted
+ end
+ end
+
+ describe 'error reporting' do
+ let(:xml) { signed_xml }
+
+ # The signature requirement has nothing to say about a document that is not
+ # a Response at all, and prepending to :base changes errors[:base].first.
+ it 'does not demand a signature from a document with no Response element' do
+ document = Saml::Kit::Response.new('', configuration: sp_configuration)
+ document.valid?
+ expect(document.errors[:base]).not_to include('must be signed.')
+ end
+
+ it 'reports a fingerprint mismatch once, not once per element' do
+ foreign = Saml::Kit::Configuration.new do |config|
+ config.entity_id = idp_entity_id
+ config.generate_key_pair_for(use: :signing)
+ end
+ document = Nokogiri::XML(
+ Saml::Kit::Response.build(user, request, configuration: foreign) do |x|
+ x.issuer = idp_entity_id
+ x.embed_signature = true
+ end.to_xml
+ )
+ document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
+ response = described_response(document.to_xml(save_with: as_xml))
+ response.valid?
+ expect(response.errors[:fingerprint].count).to be(1)
+ end
+ end
+
+ describe 'signature stripping' do
+ let(:xml) do
+ document = Nokogiri::XML(signed_xml)
+ document.xpath('//ds:Signature', namespaces).each(&:remove)
+ document.at_xpath('//saml:NameID', namespaces).content = 'attacker@evil.com'
+ document.to_xml(save_with: as_xml)
+ end
+
+ it_behaves_like 'a rejected forgery'
+
+ it 'reports the missing signature' do
+ response.valid?
+ expect(response.errors[:base]).to include('must be signed.')
+ end
+ end
+
+ describe 'a bare Assertion delivered as the whole document' do
+ subject(:assertion) do
+ Saml::Kit::Document.to_saml_document(xml, configuration: sp_configuration)
+ end
+
+ let(:bare) do
+ Nokogiri::XML(signed_xml)
+ .at_xpath('/samlp:Response/saml:Assertion', namespaces)
+ end
+
+ context 'when its signature is intact' do
+ let(:xml) { bare.to_xml(save_with: as_xml) }
+
+ it 'is accepted' do
+ expect(assertion).to be_a(Saml::Kit::Assertion)
+ expect(assertion).to be_valid
+ end
+ end
+
+ context 'when its signature has been stripped' do
+ let(:xml) do
+ bare.at_xpath('./ds:Signature', namespaces).remove
+ bare.at_xpath('.//saml:NameID', namespaces).content = 'attacker@evil.com'
+ bare.to_xml(save_with: as_xml)
+ end
+
+ it 'is invalid' do
+ expect(assertion).to be_invalid
+ end
+
+ # A missing locale key would surface here as "Translation missing".
+ it 'reports the missing signature' do
+ assertion.valid?
+ expect(assertion.errors[:base]).to include('must be signed.')
+ end
+ end
+ end
+
+ describe 'signature wrapping' do
+ context 'when the original assertion is hidden in saml:Advice' do
+ let(:xml) do
+ document = Nokogiri::XML(signed_xml)
+ document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
+ original = document.at_xpath('/samlp:Response/saml:Assertion', namespaces)
+
+ evil = original.dup
+ evil['ID'] = "_#{SecureRandom.uuid}"
+ evil.at_xpath('./saml:Subject/saml:NameID', namespaces).content = 'attacker@evil.com'
+
+ advice = Nokogiri::XML::Node.new('Advice', document)
+ advice.namespace = original.namespace
+ advice.add_child(original.dup)
+ evil.at_xpath('./saml:Conditions', namespaces).add_next_sibling(advice)
+
+ original.replace(evil)
+ document.to_xml(save_with: as_xml)
+ end
+
+ it_behaves_like 'a rejected forgery'
+
+ it 'reports the reference as unbound' do
+ response.valid?
+ expect(response.errors[:reference]).to be_present
+ end
+ end
+
+ context 'when the original response is hidden in ds:Object' do
+ let(:xml) do
+ document = Nokogiri::XML(response_signed_xml)
+ original = document.root
+ hidden = original.dup
+
+ evil = original.dup
+ evil['ID'] = "_#{SecureRandom.uuid}"
+ evil.at_xpath('.//saml:Assertion', namespaces)['ID'] = "_#{SecureRandom.uuid}"
+ evil.at_xpath('.//saml:NameID', namespaces).content = 'attacker@evil.com'
+
+ signature = evil.at_xpath('./ds:Signature', namespaces)
+ object = Nokogiri::XML::Node.new('Object', document)
+ object.namespace = signature.namespace
+ object.add_child(hidden)
+ signature.add_child(object)
+
+ document.root = evil
+ document.to_xml(save_with: as_xml)
+ end
+
+ it_behaves_like 'a rejected forgery'
+
+ it 'reports the reference as unbound' do
+ response.valid?
+ expect(response.errors[:reference]).to be_present
+ end
+ end
+ end
+end
+# rubocop:enable RSpec/DescribeClass
.rubocop_todo.yml
@@ -57,6 +57,11 @@ Lint/DeprecatedOpenSSLConstant:
Metrics/AbcSize:
Max: 19
+# Offense count: 1
+# Configuration parameters: CountComments, CountAsOne.
+Metrics/ClassLength:
+ Max: 102
+
# Offense count: 5
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
CHANGELOG.md
@@ -1,4 +1,4 @@
-Version 1.4.1
+Version 1.5.0
# Changelog
All notable changes to this project will be documented in this file.
@@ -8,6 +8,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.5.0] - 2026-08-17
+### Security
+- Reject a `Response` or `Assertion` that carries no signature. A registered
+ issuer was previously enough to make an unsigned document valid, so anyone
+ who could reach the assertion consumer service could forge an assertion for
+ any user. (GHSA-hx7c-98jm-7r55)
+- Require an xml signature to reference the element it is attached to. A
+ signature was verified without checking which element it covered, so a valid
+ signature could be copied onto a forged element with the element it really
+ covered hidden in `saml:Advice` or `ds:Object`. (GHSA-q726-45rx-qvrw)
+
+### Changed
+This release rejects documents that 1.4.1 accepted. Each case below was
+exploitable or is a side effect of closing one that was.
+
+- An unsigned `Response` or `Assertion` is now invalid, even when its issuer is
+ registered. Set `configuration.signature_required = false` to restore the old
+ behaviour while an identity provider is being fixed; doing so accepts forged
+ assertions. Signing only the `Assertion` is still accepted, as is an unsigned
+ `AuthnRequest`, `LogoutRequest` or `LogoutResponse`.
+- `Response.build` on a `Configuration` with no signing key pair emits an
+ unsigned document, which now fails `valid?`. Call
+ `generate_key_pair_for(use: :signing)` or `add_key_pair`.
+- `Assertion.new` with an unsigned assertion as a `String` is now invalid,
+ because a bare assertion is the whole document and nothing else vouches for
+ it. Passing the same assertion as a node nested in a `Response` is unchanged.
+- A signature whose `ds:Reference` does not resolve to the element it hangs
+ off, or resolves to more than one element, adds an error on `:reference`.
+
+### Added
+- `Saml::Kit::Configuration#signature_required`, defaulting to `true`.
+
## [1.4.1] - 2026-08-14
### Changed
- fix: decode encoded signature
@@ -110,7 +142,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
- Removed optional SessionNotOnOrAfter attribute from AuthnStatement.
-[Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.4.1...HEAD
+[Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.5.0...HEAD
+[1.5.0]: https://github.com/xlgmokha/saml-kit/compare/v1.4.1...v1.5.0
[1.4.1]: https://github.com/xlgmokha/saml-kit/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/xlgmokha/saml-kit/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/xlgmokha/saml-kit/compare/v1.2.0...v1.3.0
Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- saml-kit (1.4.1)
+ saml-kit (1.5.0)
activemodel (>= 5.1)
base64 (~> 0.1)
cgi (~> 0.1)