hanko_xml 0.1.0-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +222 -0
  5. data/THIRD_PARTY_LICENSES.md +392 -0
  6. data/dependencies.yml +87 -0
  7. data/lib/hanko_xml/3.1/hanko_xml.so +0 -0
  8. data/lib/hanko_xml/3.2/hanko_xml.so +0 -0
  9. data/lib/hanko_xml/3.3/hanko_xml.so +0 -0
  10. data/lib/hanko_xml/3.4/hanko_xml.so +0 -0
  11. data/lib/hanko_xml/algorithms.rb +228 -0
  12. data/lib/hanko_xml/dom.rb +137 -0
  13. data/lib/hanko_xml/errors.rb +132 -0
  14. data/lib/hanko_xml/exc-c14n.xsd +26 -0
  15. data/lib/hanko_xml/input.rb +50 -0
  16. data/lib/hanko_xml/key.rb +213 -0
  17. data/lib/hanko_xml/key_info.rb +67 -0
  18. data/lib/hanko_xml/native_version.rb +75 -0
  19. data/lib/hanko_xml/nokogiri_ext.rb +34 -0
  20. data/lib/hanko_xml/placement.rb +127 -0
  21. data/lib/hanko_xml/policy.rb +502 -0
  22. data/lib/hanko_xml/reference.rb +49 -0
  23. data/lib/hanko_xml/signature_info.rb +134 -0
  24. data/lib/hanko_xml/signed_document.rb +155 -0
  25. data/lib/hanko_xml/signer.rb +572 -0
  26. data/lib/hanko_xml/template.rb +150 -0
  27. data/lib/hanko_xml/transform.rb +87 -0
  28. data/lib/hanko_xml/verification.rb +377 -0
  29. data/lib/hanko_xml/verifier.rb +694 -0
  30. data/lib/hanko_xml/version.rb +5 -0
  31. data/lib/hanko_xml/xades/verifier.rb +319 -0
  32. data/lib/hanko_xml/xades.rb +287 -0
  33. data/lib/hanko_xml/xml.rb +93 -0
  34. data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
  35. data/lib/hanko_xml.rb +122 -0
  36. data/sig/hanko_xml/key.rbs +48 -0
  37. data/sig/hanko_xml/native.rbs +12 -0
  38. data/sig/hanko_xml/native_version.rbs +13 -0
  39. data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
  40. data/sig/hanko_xml/placement.rbs +27 -0
  41. data/sig/hanko_xml/policy.rbs +83 -0
  42. data/sig/hanko_xml/reference.rbs +36 -0
  43. data/sig/hanko_xml/signed_document.rbs +66 -0
  44. data/sig/hanko_xml/signer.rbs +50 -0
  45. data/sig/hanko_xml/template.rbs +41 -0
  46. data/sig/hanko_xml/verification.rbs +100 -0
  47. data/sig/hanko_xml/verifier.rbs +45 -0
  48. data/sig/hanko_xml/xades.rbs +70 -0
  49. data/sig/hanko_xml.rbs +62 -0
  50. metadata +104 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # Builds the unsigned <ds:Signature> Nokogiri tree for a Signer's
5
+ # configuration -- DigestValue/SignatureValue/certificate leaves are left
6
+ # empty for the native layer (Phase 2+) to fill in. See API_CONTRACT.md
7
+ # §5.2, §6.4.
8
+ #
9
+ # Needs a DOM: this is the one thing that cannot be built DOM-free,
10
+ # because doing it by string concatenation would put attacker-influenced
11
+ # values into hand-assembled markup (§6.1).
12
+ class Template
13
+ def self.build(doc, config)
14
+ new(doc, config).build
15
+ end
16
+
17
+ def initialize(doc, config)
18
+ Dom.require!("HankoXml::Template")
19
+
20
+ @doc = doc
21
+ @config = config
22
+ end
23
+
24
+ def build
25
+ signature = element("Signature")
26
+ signature.default_namespace = HankoXml::NS["ds"]
27
+ signature["Id"] = @config.signature_id.to_s if @config.signature_id
28
+
29
+ signature.add_child(build_signed_info)
30
+ signature.add_child(element("SignatureValue"))
31
+
32
+ key_info = build_key_info
33
+ signature.add_child(key_info) if key_info
34
+
35
+ signature
36
+ end
37
+
38
+ private
39
+
40
+ def build_signed_info
41
+ signed_info = element("SignedInfo")
42
+
43
+ c14n = element("CanonicalizationMethod")
44
+ c14n["Algorithm"] = Algorithms.uri_for(:canonicalization, @config.canonicalization_method)
45
+ signed_info.add_child(c14n)
46
+
47
+ signature_method = element("SignatureMethod")
48
+ signature_method["Algorithm"] = Algorithms.uri_for(:signature, @config.signature_method)
49
+ signed_info.add_child(signature_method)
50
+
51
+ effective_references.each { |ref| signed_info.add_child(build_reference(ref)) }
52
+
53
+ signed_info
54
+ end
55
+
56
+ def effective_references
57
+ refs = @config.references
58
+ refs = [Reference.new(uri: "", digest_method: @config.digest_method)] if refs.empty?
59
+ refs
60
+ end
61
+
62
+ def build_reference(ref)
63
+ reference = element("Reference")
64
+ reference["URI"] = ref.uri
65
+ reference["Id"] = ref.id.to_s if ref.id
66
+ reference["Type"] = ref.type.to_s if ref.type
67
+
68
+ unless ref.transforms.empty?
69
+ transforms = element("Transforms")
70
+ ref.transforms.each { |t| transforms.add_child(build_transform(t)) }
71
+ reference.add_child(transforms)
72
+ end
73
+
74
+ digest_method = element("DigestMethod")
75
+ digest_method["Algorithm"] = ref.digest_method_uri
76
+ reference.add_child(digest_method)
77
+
78
+ reference.add_child(element("DigestValue"))
79
+
80
+ reference
81
+ end
82
+
83
+ def build_transform(transform)
84
+ node = element("Transform")
85
+ node["Algorithm"] = transform.uri
86
+
87
+ if transform.xpath
88
+ xpath_node = element("XPath")
89
+ xpath_node.content = transform.xpath
90
+ node.add_child(xpath_node)
91
+ end
92
+
93
+ prefixes = transform.inclusive_prefixes
94
+ node.add_child(build_inclusive_namespaces(prefixes)) unless prefixes.empty?
95
+
96
+ node
97
+ end
98
+
99
+ def build_inclusive_namespaces(prefixes)
100
+ node = element("InclusiveNamespaces")
101
+ node.namespace = node.add_namespace_definition("ec", Transform::EXC_C14N_NS)
102
+ node["PrefixList"] = prefixes.join(" ")
103
+ node
104
+ end
105
+
106
+ X509_CHILDREN = {
107
+ x509_certificate: "X509Certificate",
108
+ x509_subject_name: "X509SubjectName",
109
+ x509_issuer_serial: "X509IssuerSerial",
110
+ x509_ski: "X509SKI"
111
+ }.freeze
112
+ private_constant :X509_CHILDREN
113
+
114
+ def build_key_info
115
+ clauses = @config.key_info
116
+ return nil if clauses.empty?
117
+
118
+ key_info = element("KeyInfo")
119
+
120
+ x509_children = clauses.filter_map { |c| X509_CHILDREN[c.name] }.uniq
121
+ unless x509_children.empty?
122
+ # xmlsec writes only the X509Data children the template names (an
123
+ # empty <X509Data/> means "certificate"), so each clause needs its
124
+ # own placeholder (#52 E8).
125
+ x509_data = element("X509Data")
126
+ x509_children.each { |name| x509_data.add_child(element(name)) }
127
+ key_info.add_child(x509_data)
128
+ end
129
+
130
+ clauses.each do |c|
131
+ case c.name
132
+ when :key_name
133
+ # xmlsec writes the xmlSecKey's own name, and a key loaded from
134
+ # PEM has none, so the name comes from the clause (#67).
135
+ key_name = element("KeyName")
136
+ key_name.content = c.value if c.value
137
+ key_info.add_child(key_name)
138
+ when :key_value
139
+ key_info.add_child(element("KeyValue"))
140
+ end
141
+ end
142
+
143
+ key_info
144
+ end
145
+
146
+ def element(name)
147
+ @doc.create_element(name)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # A single <ds:Transform>, resolved to a URI at construction. Frozen value
5
+ # object. See API_CONTRACT.md §5.2, §5.7.
6
+ class Transform
7
+ EXC_C14N_NS = "http://www.w3.org/2001/10/xml-exc-c14n#"
8
+ EXCLUSIVE = %i[exc_c14n exc_c14n_with_comments].freeze
9
+ # An NCName, or the "#default" token for the default namespace
10
+ # (Exclusive XML Canonicalization §3).
11
+ PREFIX = /\A(?:#default|[A-Za-z_][A-Za-z0-9_.-]*)\z/
12
+
13
+ attr_reader :name, :uri, :xpath, :inclusive_prefixes
14
+
15
+ # +algorithm+ is a Symbol looked up in Algorithms.transform, or a String
16
+ # URI used verbatim. +xpath+ is required for :xpath / :xpath_filter2
17
+ # transforms and carried through as the transform's textual content.
18
+ # +inclusive_prefixes+ (exclusive c14n only) becomes an
19
+ # <ec:InclusiveNamespaces PrefixList="..."/> child: namespace prefixes
20
+ # canonicalized as in inclusive c14n, e.g. %w[xs] for SAML xsi:type values.
21
+ def initialize(algorithm, xpath: nil, inclusive_prefixes: [])
22
+ @name = algorithm.is_a?(Symbol) ? algorithm : Algorithms.name_for(:transform, algorithm)
23
+ @uri = Algorithms.uri_for(:transform, algorithm)
24
+
25
+ raise HankoXml::ConfigurationError, "unknown transform #{algorithm.inspect}" if @uri.nil?
26
+
27
+ @xpath = xpath
28
+ @inclusive_prefixes = coerce_inclusive_prefixes(inclusive_prefixes)
29
+ freeze
30
+ end
31
+
32
+ # Exclusive c14n, optionally with an InclusiveNamespaces PrefixList.
33
+ def self.exc_c14n(inclusive_prefixes: [], with_comments: false)
34
+ new(with_comments ? :exc_c14n_with_comments : :exc_c14n, inclusive_prefixes: inclusive_prefixes)
35
+ end
36
+
37
+ def self.c14n
38
+ new(:c14n)
39
+ end
40
+
41
+ def self.enveloped_signature
42
+ new(:enveloped_signature)
43
+ end
44
+
45
+ def enveloped_signature?
46
+ name == :enveloped_signature
47
+ end
48
+
49
+ def secure?
50
+ Algorithms.secure?(:transform, uri)
51
+ end
52
+
53
+ def self.coerce(value)
54
+ value.is_a?(Transform) ? value : new(value)
55
+ end
56
+
57
+ def ==(other)
58
+ other.is_a?(Transform) && other.uri == uri && other.xpath == xpath &&
59
+ other.inclusive_prefixes == inclusive_prefixes
60
+ end
61
+ alias eql? ==
62
+
63
+ def hash
64
+ [uri, xpath, inclusive_prefixes].hash
65
+ end
66
+
67
+ private
68
+
69
+ def coerce_inclusive_prefixes(prefixes)
70
+ prefixes = Array(prefixes).map(&:to_s)
71
+ return prefixes.freeze if prefixes.empty?
72
+
73
+ unless EXCLUSIVE.include?(name)
74
+ raise HankoXml::ConfigurationError,
75
+ "inclusive_prefixes: applies only to exclusive c14n transforms, not #{uri}"
76
+ end
77
+
78
+ invalid = prefixes.grep_v(PREFIX)
79
+ unless invalid.empty?
80
+ raise HankoXml::ConfigurationError,
81
+ "inclusive_prefixes: #{invalid.inspect} are not namespace prefixes (NCName or \"#default\")"
82
+ end
83
+
84
+ prefixes.uniq.freeze
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # The result of a signature verification attempt.
5
+ # Provides methods to inspect which nodes were actually signed, defending against
6
+ # XML Signature Wrapping (XSW) attacks.
7
+ # See API_CONTRACT.md §5.4.
8
+ class Verification # rubocop:disable Metrics/ClassLength
9
+ # Details about a specific `<Reference>` inside the verified signature.
10
+ Reference = Struct.new(:uri, :node, :digest_method, :transforms, :valid?, :errors, :signature_exclusion,
11
+ keyword_init: true) do
12
+ # @return [Boolean] True if the reference uses the enveloped-signature transform.
13
+ def enveloped?
14
+ transforms.include?(:enveloped_signature)
15
+ end
16
+
17
+ # Which <ds:Signature> subtrees this Reference leaves out: :this (the
18
+ # enveloped-signature transform), and for the accepted XPath Filter 2.0
19
+ # form (#85) :all (every signature) or :root_children (signatures that
20
+ # are children of the root).
21
+ def signature_exclusions
22
+ [(:this if enveloped?), signature_exclusion].compact
23
+ end
24
+ end
25
+
26
+ attr_reader :valid, :errors, :signature_info, :signature_node, :document, :certificate, :id_attributes, :resolver,
27
+ :references, :reference_element_indexes, :reference_valid, :namespaces,
28
+ :signature_element_index
29
+
30
+ # +document+ is nil when the input was bytes and Nokogiri is not loaded
31
+ # (#102). Everything that needs no DOM still works then; #signed?,
32
+ # #signed_nodes, #at_signed and #signed raise NokogiriRequired.
33
+ def initialize(valid:, errors:, signature_info:, signature_node:, document:, certificate: nil,
34
+ id_attributes: %w[Id ID id], resolver: nil, reference_element_indexes: nil,
35
+ reference_valid: nil, namespaces: {}, signature_element_index: nil, certificate_chain: nil,
36
+ trusted: true, root_signature: nil)
37
+ @valid = valid
38
+ @errors = errors.dup
39
+ @signature_info = signature_info
40
+ @signature_node = signature_node
41
+ @document = document
42
+ @certificate = certificate
43
+ # The chain store: validated, leaf first; nil for the pinned trust sources.
44
+ @certificate_chain = certificate_chain&.dup&.freeze
45
+ # False only for Verifier.without_trust_verification (#103).
46
+ @trusted = trusted
47
+ # Whether the root element is a <ds:Signature>, for #covers_root?
48
+ # without a DOM; nil falls back to this signature being the root.
49
+ @root_signature = root_signature
50
+ @id_attributes = (id_attributes || []).dup.freeze
51
+ @resolver = resolver
52
+ @namespaces = (namespaces || {}).transform_keys(&:to_s).freeze
53
+ # Native.verify returns document-order element indexes from libxml2's ID
54
+ # table (the same table xmlsec used for `#id`). When present, those are
55
+ # authoritative for #references[].node / #signed? — Ruby does not
56
+ # re-resolve IDs (issues #28 / #34 / S-03).
57
+ @reference_element_indexes = reference_element_indexes&.dup&.freeze
58
+ # Native.verify's per-Reference digest status (S-09) -- authoritative
59
+ # when present. See #reference_valid_for.
60
+ @reference_valid = reference_valid&.dup&.freeze
61
+ # The <Signature>'s own document-order element index, in the same
62
+ # numbering, so #covers_root? can apply the enveloped exclusion without
63
+ # a DOM.
64
+ @signature_element_index = signature_element_index
65
+ # Resolution must happen, and #errors/#valid must be able to react to it
66
+ # (see detect_resolution_mismatches!), before we freeze below -- the object
67
+ # is immutable from here on, so #references can't be computed lazily on
68
+ # first access the way it used to be.
69
+ @references = build_references
70
+ detect_resolution_mismatches!
71
+ @errors.freeze
72
+ freeze
73
+ end
74
+
75
+ # @return [Boolean] True if the signature is completely valid.
76
+ def valid?
77
+ @valid
78
+ end
79
+
80
+ # --- Authenticated metadata ---
81
+ # Registry names (:rsa_sha256), as API_CONTRACT.md §5.4 shows, or the URI
82
+ # String for an algorithm the registry does not know (#96).
83
+ def signature_method
84
+ signature_info && (signature_info.signature_method_name || signature_info.signature_method)
85
+ end
86
+
87
+ def canonicalization_method
88
+ signature_info && (signature_info.canonicalization_method_name || signature_info.canonicalization_method)
89
+ end
90
+
91
+ def digest_methods
92
+ # nil signature_info: a verification that failed before inspection (#94).
93
+ return [] unless signature_info
94
+
95
+ signature_info.references.map { |ref| ref.digest_method_name || ref.digest_method }
96
+ end
97
+
98
+ def signed_nodes
99
+ require_document!("Verification#signed_nodes")
100
+ # Same rule as #signed?: nothing is covered unless the signature held (#91).
101
+ return [] unless valid?
102
+
103
+ # For each reference, get the nodes it covers
104
+ nodes = []
105
+ references.each do |ref|
106
+ next unless ref.node
107
+
108
+ # A reference covers the node and all its descendants.
109
+ # But if it's enveloped, it excludes the signature itself.
110
+ nodes << ref.node
111
+ end
112
+ nodes
113
+ end
114
+
115
+ def signed?(node)
116
+ require_document!("Verification#signed?")
117
+ return false unless valid?
118
+
119
+ # True if the node is equal to or a descendant of any reference's target
120
+ # node, EXCEPT inside a <ds:Signature> the reference leaves out: this
121
+ # one (enveloped-signature), or the ones an XPath Filter 2.0 subtract
122
+ # removes (#85).
123
+ references.any? do |ref|
124
+ next false unless ref.node
125
+ next false unless node.ancestors.include?(ref.node) || node == ref.node
126
+
127
+ ref.signature_exclusions.none? { |exclusion| excluded_by?(node, exclusion) }
128
+ end
129
+ end
130
+
131
+ # Whether the signature held AND the key came from trust material the
132
+ # caller supplied. False for every result of
133
+ # Verifier.without_trust_verification, which checks integrity only: its
134
+ # #valid? says the document was not changed, never who signed it (#103).
135
+ def trusted?
136
+ valid? && @trusted
137
+ end
138
+
139
+ # The <SignedInfo> that was verified: #signature_info once the signature
140
+ # held, nil otherwise, so it never stands for unauthenticated claims
141
+ # (#104). #signature_info is the same data before verification.
142
+ def signed_info
143
+ valid? ? signature_info : nil
144
+ end
145
+
146
+ # The certificates that established trust, leaf first, or [] unless
147
+ # trusted?. For store: it is the chain OpenSSL validated, up to the trust
148
+ # anchor; for certificate: / certificates: it is the pinned certificate;
149
+ # key: and keys: with a bare public key have none (#104).
150
+ def certificate_chain
151
+ return [] unless trusted?
152
+ return @certificate_chain if @certificate_chain
153
+
154
+ certificate ? [certificate] : []
155
+ end
156
+
157
+ # What the document's <KeyInfo> claimed, read out but never trusted: the
158
+ # Verifier pins its key from trust material and ignores <KeyInfo> (S-01).
159
+ # Available whether or not the signature held; nil when <SignedInfo> was
160
+ # never read (#104).
161
+ def key_info
162
+ return nil unless signature_info
163
+
164
+ KeyInfo::Parsed.new(key_names: signature_info.key_names, certificates: signature_info.certificates)
165
+ end
166
+
167
+ # Whether a Reference covers the root element. With a DOM this is
168
+ # #signed?(document.root); without one, the same rule over the native
169
+ # element indexes, where the root is index 0 (#102).
170
+ def covers_root?
171
+ return signed?(document.root) if document
172
+ return false unless valid? && reference_element_indexes
173
+
174
+ references.each_with_index.any? do |ref, index|
175
+ next false unless reference_element_indexes[index]&.zero?
176
+
177
+ # Only a <Signature> that is itself the root can take the root out:
178
+ # :this when it is this one, :all when it is any (#85).
179
+ exclusions = ref.signature_exclusions
180
+ !((exclusions.include?(:this) && signature_element_index&.zero?) ||
181
+ (exclusions.include?(:all) && root_signature))
182
+ end
183
+ end
184
+
185
+ def root_signature
186
+ @root_signature.nil? ? signature_element_index&.zero? : @root_signature
187
+ end
188
+ private :root_signature
189
+
190
+ def partial?
191
+ !covers_root?
192
+ end
193
+
194
+ # +ns+ is merged over the Verifier's +namespaces:+, which is merged over
195
+ # HankoXml::NS, so +ds:+ is always bound (#96).
196
+ def at_signed(xpath, ns = {})
197
+ require_document!("Verification#at_signed")
198
+ node = document.at_xpath(xpath, xpath_namespaces(ns))
199
+ raise HankoXml::NotSignedError, "No node found" unless node
200
+ raise HankoXml::NotSignedError, "Node is not signed" unless signed?(node)
201
+
202
+ node
203
+ end
204
+
205
+ def signed(xpath, ns = {})
206
+ require_document!("Verification#signed")
207
+ nodes = document.xpath(xpath, xpath_namespaces(ns)).to_a
208
+ raise HankoXml::NotSignedError, "Some nodes are not signed" unless nodes.all? { |n| signed?(n) }
209
+
210
+ nodes
211
+ end
212
+
213
+ private
214
+
215
+ def excluded_by?(node, exclusion)
216
+ chain = [node, *node.ancestors]
217
+ case exclusion
218
+ when :this then chain.include?(signature_node)
219
+ when :all then chain.any? { |n| dsig_signature?(n) }
220
+ when :root_children then chain.any? { |n| dsig_signature?(n) && n.parent == document.root }
221
+ end
222
+ end
223
+
224
+ def dsig_signature?(node)
225
+ node.respond_to?(:namespace) && node.name == "Signature" && node.namespace&.href == HankoXml::NS["ds"]
226
+ end
227
+
228
+ # Coverage questions are answered on the caller's nodes, so they need
229
+ # the DOM. Without it they raise rather than guess (§6.1).
230
+ def require_document!(capability)
231
+ return if document
232
+
233
+ raise HankoXml::NokogiriRequired,
234
+ "#{capability} needs a DOM: verify a Nokogiri document, or load Nokogiri before verifying, " \
235
+ "to use it (#102)."
236
+ end
237
+
238
+ def xpath_namespaces(ns)
239
+ HankoXml::NS.merge(namespaces).merge((ns || {}).transform_keys(&:to_s))
240
+ end
241
+
242
+ def build_references
243
+ return [] unless signature_info
244
+
245
+ signature_info.references.map.with_index do |ref, index|
246
+ target_node = resolve_reference(ref, index)
247
+
248
+ Reference.new(
249
+ uri: ref.uri,
250
+ node: target_node,
251
+ digest_method: ref.digest_method_name,
252
+ transforms: ref.transform_names,
253
+ valid?: reference_valid_for(index),
254
+ errors: reference_errors_for(ref, index),
255
+ signature_exclusion: ref.signature_exclusion
256
+ ).freeze
257
+ end
258
+ end
259
+
260
+ # Native.verify reports each SignedInfo Reference's own digest status,
261
+ # independent of the overall signature/key outcome (S-09): a
262
+ # harmless-fragment Reference and the payload Reference can genuinely
263
+ # disagree, so "the whole signature is valid" cannot stand in for "this
264
+ # Reference's digest matched". Falls back to the overall result only when
265
+ # there is no real per-reference answer -- a Verification built directly
266
+ # (specs, error paths that never reached crypto) or a reference xmlsec
267
+ # never got to because verification aborted earlier.
268
+ def reference_valid_for(index)
269
+ return valid? unless @reference_valid
270
+
271
+ status = @reference_valid[index]
272
+ status.nil? ? valid? : status
273
+ end
274
+
275
+ # A DigestMismatch only when xmlsec itself reported this Reference's
276
+ # digest as wrong; the fallback in #reference_valid_for proves nothing
277
+ # about one Reference (#96). The URI is document text, so it goes in
278
+ # #reference_uri, not the message.
279
+ def reference_errors_for(ref, index)
280
+ return [] unless @reference_valid && @reference_valid[index] == false
281
+
282
+ [HankoXml::DigestMismatch.new("a <Reference>'s digest does not match", reference_uri: ref.uri).freeze]
283
+ end
284
+
285
+ # When Native.verify supplied element indexes, those are the authority.
286
+ # A caller-supplied resolver: may only *agree* with them — disagreement is
287
+ # ResolutionMismatch (API_CONTRACT §5.9). Without native indexes (unit tests
288
+ # that construct Verification directly), fall back to resolver / id_attributes.
289
+ def detect_resolution_mismatches!
290
+ return unless resolver
291
+ return unless signature_info
292
+
293
+ # A resolver: is a check the caller asked for; without the DOM it
294
+ # cannot run, and it must never be skipped.
295
+ require_document!("resolver:")
296
+
297
+ signature_info.references.each_with_index do |ref, index|
298
+ next unless ref.uri&.start_with?("#")
299
+
300
+ id = ref.uri[1..]
301
+ resolver_node = resolver.call(document, id)
302
+ expected = if reference_element_indexes
303
+ node_at_element_index(reference_element_indexes[index])
304
+ elsif !id_attributes.empty?
305
+ resolve_by_id_attributes(id)
306
+ else
307
+ next
308
+ end
309
+
310
+ record_resolution_mismatch(id) unless same_node?(resolver_node, expected)
311
+ end
312
+ end
313
+
314
+ def record_resolution_mismatch(id)
315
+ @errors << HankoXml::ResolutionMismatch.new(
316
+ "resolver and xmlsec ID resolution disagree on a reference's target",
317
+ xmlsec_errors: ["reference: ##{id}"]
318
+ )
319
+ @valid = false
320
+ end
321
+
322
+ def same_node?(left, right)
323
+ return true if left.nil? && right.nil?
324
+ return false if left.nil? || right.nil?
325
+
326
+ left == right
327
+ end
328
+
329
+ def resolve_reference(ref, index)
330
+ return node_at_element_index(reference_element_indexes[index]) if reference_element_indexes
331
+
332
+ # Fallback for Verification constructed without a Native.verify result
333
+ # (specs / error paths that never reached crypto).
334
+ return nil unless document
335
+
336
+ if ref.uri.nil? || SignatureInfo::WHOLE_DOCUMENT_URIS.include?(ref.uri)
337
+ document.root
338
+ elsif ref.uri.start_with?("#")
339
+ resolve_fragment_reference(ref.uri[1..])
340
+ end
341
+ end
342
+
343
+ def node_at_element_index(index)
344
+ return nil if index.nil? || document.nil?
345
+
346
+ document.xpath("//*")[index]
347
+ end
348
+
349
+ def resolve_fragment_reference(id)
350
+ return resolver.call(document, id) if resolver
351
+
352
+ resolve_by_id_attributes(id)
353
+ end
354
+
355
+ def resolve_by_id_attributes(id)
356
+ predicates = []
357
+ bindings = { id: id }
358
+
359
+ id_attributes.each_with_index do |attr, index|
360
+ name_key = :"attr_name_#{index}"
361
+ if attr.is_a?(Array) && attr.size == 2
362
+ ns_key = :"attr_ns_#{index}"
363
+ bindings[name_key] = attr[1].to_s
364
+ bindings[ns_key] = attr[0].to_s
365
+ predicates << "@*[local-name()=$#{name_key} and namespace-uri()=$#{ns_key} and .=$id]"
366
+ else
367
+ bindings[name_key] = attr.to_s
368
+ predicates << "@*[local-name()=$#{name_key} and namespace-uri()='' and .=$id]"
369
+ end
370
+ end
371
+
372
+ return nil if predicates.empty?
373
+
374
+ document.at_xpath("//*[(#{predicates.join(" or ")})]", nil, bindings)
375
+ end
376
+ end
377
+ end