hanko_xml 0.1.0

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/Cargo.lock +401 -0
  4. data/Cargo.toml +7 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +222 -0
  7. data/THIRD_PARTY_LICENSES.md +392 -0
  8. data/dependencies.yml +87 -0
  9. data/ext/hanko_xml/Cargo.toml +20 -0
  10. data/ext/hanko_xml/build/vendor.rs +817 -0
  11. data/ext/hanko_xml/build.rs +298 -0
  12. data/ext/hanko_xml/extconf.rb +6 -0
  13. data/ext/hanko_xml/src/dsig.rs +1851 -0
  14. data/ext/hanko_xml/src/errors.rs +151 -0
  15. data/ext/hanko_xml/src/lib.rs +490 -0
  16. data/ext/hanko_xml/src/sys.rs +43 -0
  17. data/lib/hanko_xml/algorithms.rb +228 -0
  18. data/lib/hanko_xml/dom.rb +137 -0
  19. data/lib/hanko_xml/errors.rb +132 -0
  20. data/lib/hanko_xml/exc-c14n.xsd +26 -0
  21. data/lib/hanko_xml/input.rb +50 -0
  22. data/lib/hanko_xml/key.rb +213 -0
  23. data/lib/hanko_xml/key_info.rb +67 -0
  24. data/lib/hanko_xml/native_version.rb +75 -0
  25. data/lib/hanko_xml/nokogiri_ext.rb +34 -0
  26. data/lib/hanko_xml/placement.rb +127 -0
  27. data/lib/hanko_xml/policy.rb +502 -0
  28. data/lib/hanko_xml/reference.rb +49 -0
  29. data/lib/hanko_xml/signature_info.rb +134 -0
  30. data/lib/hanko_xml/signed_document.rb +155 -0
  31. data/lib/hanko_xml/signer.rb +572 -0
  32. data/lib/hanko_xml/template.rb +150 -0
  33. data/lib/hanko_xml/transform.rb +87 -0
  34. data/lib/hanko_xml/verification.rb +377 -0
  35. data/lib/hanko_xml/verifier.rb +694 -0
  36. data/lib/hanko_xml/version.rb +5 -0
  37. data/lib/hanko_xml/xades/verifier.rb +319 -0
  38. data/lib/hanko_xml/xades.rb +287 -0
  39. data/lib/hanko_xml/xml.rb +93 -0
  40. data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
  41. data/lib/hanko_xml.rb +122 -0
  42. data/sig/hanko_xml/key.rbs +48 -0
  43. data/sig/hanko_xml/native.rbs +12 -0
  44. data/sig/hanko_xml/native_version.rbs +13 -0
  45. data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
  46. data/sig/hanko_xml/placement.rbs +27 -0
  47. data/sig/hanko_xml/policy.rbs +83 -0
  48. data/sig/hanko_xml/reference.rbs +36 -0
  49. data/sig/hanko_xml/signed_document.rbs +66 -0
  50. data/sig/hanko_xml/signer.rbs +50 -0
  51. data/sig/hanko_xml/template.rbs +41 -0
  52. data/sig/hanko_xml/verification.rbs +100 -0
  53. data/sig/hanko_xml/verifier.rbs +45 -0
  54. data/sig/hanko_xml/xades.rbs +70 -0
  55. data/sig/hanko_xml.rbs +62 -0
  56. metadata +105 -0
@@ -0,0 +1,694 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # Orchestrates XML Digital Signature verification.
5
+ # Handles fetching signatures, validating against a schema, verifying cryptographic integrity,
6
+ # and enforcing security policies.
7
+ class Verifier # rubocop:disable Metrics/ClassLength
8
+ attr_reader :policy, :id_attributes, :namespaces, :signature, :verification_time, :schema, :referenced_documents,
9
+ :resolver
10
+
11
+ # Distinguishes "caller did not pass schema:" from "caller explicitly
12
+ # passed schema: nil" so schema: can default to policy.schema (S-06)
13
+ # without losing the ability to explicitly opt out.
14
+ UNSET_SCHEMA = Object.new.freeze
15
+ private_constant :UNSET_SCHEMA
16
+
17
+ # Creates a new Verifier with the given trust anchors and configuration.
18
+ # Exactly one trust source must be specified.
19
+ #
20
+ # @param certificate [Key, OpenSSL::X509::Certificate, String, Pathname] A single trusted certificate.
21
+ # @param certificates [Array<Key, OpenSSL::X509::Certificate, String, Pathname>] A list of trusted certificates.
22
+ # @param store [OpenSSL::X509::Store] An OpenSSL store of trusted root certificates.
23
+ # @param key [Key, OpenSSL::PKey::PKey, String, Pathname] A trusted public key.
24
+ # @param keys [Hash<String, Key>] A mapping of `<KeyName>` to trusted keys.
25
+ # @param policy [Policy] The security policy to enforce (defaults to {Policy.strict}).
26
+ # @param id_attributes [Array<String>] Attributes to consider as IDs for `#foo` references.
27
+ # @param resolver [Proc, nil] Optional callable `(doc, uri_fragment) -> node` for fragment resolution in
28
+ # `Verification#references` / `#signed?`.
29
+ # @param namespaces [Hash<String, String>] Namespaces for XPath signature selection.
30
+ # @param signature [Symbol, Integer, String, nil] Which signature to verify:
31
+ # `:first` (explicitly take the first signature even if more exist), `:only`
32
+ # (require exactly one, raising {MultipleSignatures} otherwise), an
33
+ # integer index (document order, matching {Signer#sign!}'s `signature:`), or
34
+ # an XPath. Defaults to `nil`, which behaves like `:only`: with a single
35
+ # `<Signature>` it is verified, but with more than one {MultipleSignatures}
36
+ # is raised rather than silently verifying just the first (S-11) --
37
+ # pass `:first` to opt into the old unsafe default explicitly.
38
+ # @param verification_time [Time, nil] The time to use for checking certificate expiration bounds.
39
+ # @param schema [Symbol, String, Nokogiri::XML::Schema, nil] An XSD schema to
40
+ # pre-validate the signature node (e.g. `:default`). Defaults to
41
+ # `policy.schema` (S-06): `Policy.strict.schema == :default` turns on
42
+ # XSD validation without the caller passing `schema:` explicitly. Pass
43
+ # `schema: nil` explicitly to opt out regardless of the policy.
44
+ # @param referenced_documents [Hash<String, String | Nokogiri::XML::Document>] The only source an external
45
+ # reference is read from, keyed by its URI (without any `#fragment`). Pass the exact bytes that were signed
46
+ # as a String; a Document is serialized first, which only matches if that reproduces those bytes. Nothing
47
+ # is ever fetched, and a URI not in the map fails verification with a {ReferenceError} in
48
+ # {Verification#errors}; only {#verify!} raises it (#74).
49
+ def initialize(certificate: nil, certificates: nil, store: nil, key: nil, keys: nil,
50
+ policy: HankoXml::Policy.strict, id_attributes: %w[Id ID id], resolver: nil,
51
+ namespaces: {}, signature: nil, verification_time: nil,
52
+ schema: UNSET_SCHEMA, referenced_documents: {}, certificate_subject: nil)
53
+ supplied_trusts = [certificate, certificates, store, key, keys].compact
54
+ if !@without_trust && supplied_trusts.size != 1
55
+ raise HankoXml::ConfigurationError,
56
+ "Specify exactly one trust source: certificate, certificates, store, key, or keys"
57
+ end
58
+
59
+ @certificate = certificate && HankoXml::Key.coerce(certificate)
60
+ @certificates = certificates&.map { |c| HankoXml::Key.coerce(c) }
61
+ @store = store
62
+ @key = key && HankoXml::Key.coerce(key)
63
+ @keys = keys
64
+
65
+ # resolver: is called with the caller's document, so it needs one.
66
+ Dom.require!("Verifier resolver:") if resolver
67
+
68
+ @policy = policy
69
+ @id_attributes = id_attributes
70
+ @resolver = resolver
71
+ @namespaces = namespaces
72
+ @signature = signature
73
+ @verification_time = verification_time
74
+ @schema = schema.equal?(UNSET_SCHEMA) ? policy.schema : schema
75
+ @referenced_documents = coerce_referenced_documents(referenced_documents)
76
+ @certificate_subject = coerce_certificate_subject(certificate_subject)
77
+
78
+ freeze
79
+ end
80
+
81
+ # The keywords that name a trust source.
82
+ # @api private
83
+ TRUST_KEYWORDS = %i[certificate certificates store key keys certificate_subject].freeze
84
+
85
+ # An integrity-only Verifier (#103): it checks that the document was not
86
+ # changed since it was signed by the key in its own <X509Certificate>,
87
+ # and proves NOTHING about who signed it. Never sufficient on its own.
88
+ #
89
+ # The key is still pinned, never chosen by xmlsec from <KeyInfo> (S-01):
90
+ # it is the public key of the one end-entity certificate <KeyInfo>
91
+ # carries. Its Verification has #certificate set, #trusted? false and
92
+ # #certificate_chain empty, and #verify! raises UntrustedCertificate
93
+ # unless called with allow_untrusted: true -- two explicit steps to the
94
+ # dangerous outcome. Takes every Verifier keyword except the trust
95
+ # sources.
96
+ #
97
+ # @return [Verifier]
98
+ def self.without_trust_verification(**options)
99
+ conflicting = options.keys & TRUST_KEYWORDS
100
+ unless conflicting.empty?
101
+ raise HankoXml::ConfigurationError,
102
+ "without_trust_verification takes no trust source (got #{conflicting.join(", ")}): " \
103
+ "use Verifier.new with one to verify who signed"
104
+ end
105
+
106
+ allocate.tap { |verifier| verifier.send(:initialize_without_trust, **options) }
107
+ end
108
+
109
+ # Whether this Verifier checks integrity only (.without_trust_verification).
110
+ def without_trust?
111
+ @without_trust == true
112
+ end
113
+
114
+ # Verifies the target signature(s). Returns a single {Verification} object.
115
+ # Does not raise cryptographic errors; instead, captures them in the returned object.
116
+ #
117
+ # @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
118
+ # @return [HankoXml::Verification] The verification result.
119
+ def verify(xml)
120
+ input = HankoXml::Input.read(xml, policy: @policy)
121
+ count = signature_count(input)
122
+
123
+ # A missing signature is an error state: a Verification with
124
+ # valid=false and a SignatureMissing error, never a raise.
125
+ return missing_signature(input, "No <Signature> element found") if count.zero?
126
+
127
+ # For :first, :only, integer, or xpath
128
+ index = resolve_signature_target(input, count, @signature)
129
+ return missing_signature(input, "Target signature not found") if index.nil?
130
+
131
+ verify_single(input, index)
132
+ end
133
+
134
+ # Verifies the target signature(s) and raises an error if verification fails.
135
+ #
136
+ # @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
137
+ # @param allow_untrusted [Boolean] Accept an integrity-only result from a
138
+ # {.without_trust_verification} Verifier instead of raising (#103).
139
+ # @raise [HankoXml::VerificationError] If the signature is invalid or violates policy.
140
+ # @raise [HankoXml::UntrustedCertificate] If it is valid but untrusted, without allow_untrusted: true.
141
+ # @return [HankoXml::Verification] The valid verification result.
142
+ def verify!(xml, allow_untrusted: false)
143
+ result = verify(xml)
144
+ unless result.valid?
145
+ # Raise the first error, or a generic VerificationError
146
+ error = result.errors.first || HankoXml::VerificationError.new("Signature verification failed")
147
+ raise error
148
+ end
149
+ # The second explicit step for an integrity-only Verifier (#103).
150
+ if !result.trusted? && !allow_untrusted
151
+ raise HankoXml::UntrustedCertificate,
152
+ "the document was not modified, but nothing established trust in its signer " \
153
+ "(without_trust_verification); pass allow_untrusted: true to accept that"
154
+ end
155
+ result
156
+ end
157
+
158
+ # Returns true if the document signature is valid.
159
+ #
160
+ # @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
161
+ # @return [Boolean] True if valid, false otherwise.
162
+ def valid?(xml)
163
+ verify(xml).valid?
164
+ end
165
+
166
+ # Verifies all signatures in the document and returns an array of {Verification} results.
167
+ #
168
+ # @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
169
+ # @return [Array<HankoXml::Verification>] An array of verification results.
170
+ def verify_all(xml)
171
+ input = HankoXml::Input.read(xml, policy: @policy)
172
+ count = signature_count(input)
173
+
174
+ Array.new(count) { |index| verify_single(input, index) }
175
+ end
176
+
177
+ # The bundled XML-DSig schema `schema: :default` validates against.
178
+ DEFAULT_SCHEMA_PATH = File.expand_path("xmldsig-core-schema.xsd", __dir__).freeze
179
+
180
+ # The bundled XML-DSig schema as a Nokogiri::XML::Schema, for callers who
181
+ # validate other documents with it. `schema: :default` itself validates
182
+ # natively and does not use this (#102). Parsed with its own path as the
183
+ # base URL so its relative import of exc-c14n.xsd resolves (#61), and
184
+ # only once: a racing first call just parses it twice.
185
+ def self.default_schema
186
+ @default_schema ||= Dom.schema_from_file(DEFAULT_SCHEMA_PATH)
187
+ end
188
+
189
+ private
190
+
191
+ # .without_trust_verification's entry point: the flag is set before
192
+ # #initialize, which then accepts no trust source.
193
+ def initialize_without_trust(**options)
194
+ @without_trust = true
195
+ initialize(**options)
196
+ end
197
+
198
+ # A HankoXml::Error from the native layer already has its fixed message,
199
+ # the class Rust chose, and #xmlsec_errors: keep it. Anything else gets a
200
+ # fixed NativeError, never its own message (#96, S-13).
201
+ def native_failure(error, message)
202
+ return error if error.is_a?(HankoXml::Error)
203
+
204
+ HankoXml::NativeError.new(message)
205
+ end
206
+
207
+ # xmlSecAddIDs (the native ID table verification resolves "#id" against)
208
+ # matches by unqualified attribute name only -- it has no concept of the
209
+ # namespace-qualified [ns, name] entries Verification's Ruby-side XPath
210
+ # lookup accepts. Passing one of those arrays straight through would fail
211
+ # Native.verify's String-array conversion, so only the local name crosses
212
+ # the FFI boundary; namespace-qualified matching stays a Ruby-side-only
213
+ # refinement over the same, broader native registration.
214
+ def native_id_attributes
215
+ @id_attributes.map { |attr| attr.is_a?(Array) ? attr[1].to_s : attr.to_s }
216
+ end
217
+
218
+ # The index of the <Signature> to verify among +count+ in document
219
+ # order, or nil when the selection matches none.
220
+ def resolve_signature_target(input, count, signature_option)
221
+ case signature_option
222
+ when nil, :only
223
+ # No explicit selection: fail closed on ambiguity rather than
224
+ # silently verifying only the first of several signatures (S-11).
225
+ # Pass `signature: :first` to opt into the old, unsafe default.
226
+ raise HankoXml::MultipleSignatures, "Expected exactly 1 signature, found #{count}" if count > 1
227
+
228
+ 0
229
+ when :first
230
+ 0
231
+ when Integer
232
+ index = signature_option.negative? ? count + signature_option : signature_option
233
+ index if index.between?(0, count - 1)
234
+ when String
235
+ xpath_signature_index(input, signature_option)
236
+ when :all
237
+ raise HankoXml::ConfigurationError,
238
+ "signature: :all is not supported; use Verifier#verify_all for one Verification per <Signature> (#105)"
239
+ else
240
+ raise HankoXml::ConfigurationError, "Invalid signature: option"
241
+ end
242
+ end
243
+
244
+ # XPath selection needs a DOM. It must pick exactly one <ds:Signature>:
245
+ # taking the first of several matches would be `:first` again under
246
+ # another name (S-11, #95).
247
+ def xpath_signature_index(input, xpath)
248
+ Dom.require!("Verifier signature: XPath selection")
249
+
250
+ sigs = signature_nodes(input)
251
+ namespaces = HankoXml::NS.merge(@namespaces.transform_keys(&:to_s))
252
+ matches = input.document.xpath(xpath, namespaces).to_a & sigs
253
+ if matches.size > 1
254
+ raise HankoXml::MultipleSignatures,
255
+ "signature: XPath must select exactly 1 <Signature>, selected #{matches.size}"
256
+ end
257
+
258
+ matches.first && sigs.index(matches.first)
259
+ end
260
+
261
+ # How many <ds:Signature> the document holds, from the native scan. A
262
+ # caller's DOM over max_document_size is counted on the DOM instead, so
263
+ # nothing native parses it (S-12); verify_single then fails each one on
264
+ # size before any native call.
265
+ def signature_count(input)
266
+ return signature_nodes(input).size if input.dom? && input.bytes.bytesize > @policy.max_document_size
267
+
268
+ input.scan(native_id_attributes)[:signature_count]
269
+ end
270
+
271
+ # The caller's <ds:Signature> nodes in document order, or [] without a
272
+ # DOM. The same libxml2 walk as the native count, so indexes agree.
273
+ def signature_nodes(input)
274
+ return [] unless input.document
275
+
276
+ input.document.xpath(".//ds:Signature", ds: NS["ds"]).to_a
277
+ end
278
+
279
+ def missing_signature(input, message)
280
+ failed_verification(input, nil, [HankoXml::SignatureMissing.new(message)])
281
+ end
282
+
283
+ # The <Signature> errors run against: the XML-DSig schema natively for
284
+ # `schema: :default`, or a caller's own Nokogiri::XML::Schema on the DOM.
285
+ def schema_errors(input, sig_node, index)
286
+ return Native.validate_schema(input.bytes, index, DEFAULT_SCHEMA_PATH) if @schema == :default
287
+
288
+ Dom.require!("Verifier schema: with a custom schema")
289
+ Dom.schema_errors(@schema, sig_node).map(&:message)
290
+ end
291
+
292
+ # One linear sequence of interdependent checks -- schema, policy,
293
+ # certificate chain, time bounds, then crypto -- accumulating shared
294
+ # state as it goes. Splitting it would thread that state through helpers
295
+ # rather than genuinely simplify it, so the metrics cops are waived here
296
+ # pending a deliberate refactor.
297
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
298
+ # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
299
+ def verify_single(input, index)
300
+ sig_node = signature_nodes(input)[index]
301
+
302
+ # 1. max_document_size is checked before any native parse (S-12). An
303
+ # unparsed input was already checked while it was read; this covers a
304
+ # caller's DOM, measured by its serialized bytes.
305
+ xml_bytes = input.bytes
306
+ begin
307
+ @policy.enforce_document_size!(xml_bytes.bytesize)
308
+ rescue HankoXml::PolicyError => e
309
+ return failed_verification(input, sig_node, [e], signature_info: nil)
310
+ end
311
+
312
+ # Schema validation before any crypto
313
+ if @schema
314
+ xsd_errors = schema_errors(input, sig_node, index)
315
+ if xsd_errors.any?
316
+ error = HankoXml::SchemaError.new("<Signature> does not validate against the XML-DSig schema",
317
+ xmlsec_errors: xsd_errors)
318
+ return failed_verification(input, sig_node, [error])
319
+ end
320
+ end
321
+
322
+ # 2. Call Native.inspect_sig
323
+ begin
324
+ inspect_result = Native.inspect_sig(xml_bytes, index)
325
+ rescue StandardError => e
326
+ return failed_verification(input, sig_node,
327
+ [native_failure(e, "the native layer could not read <SignedInfo>")])
328
+ end
329
+
330
+ sig_info = HankoXml::SignatureInfo.from_native(inspect_result)
331
+
332
+ # Policy is a structural allowlist over inspect_sig output. Enforce it
333
+ # before Native.verify so a file:// or http:// Reference is never
334
+ # handed to libxml2 I/O (S-04). Native.verify also restricts
335
+ # enabledReferenceUris as defense in depth. document:/sig_node:/
336
+ # id_attributes:/xml_bytes: additionally enable require_signed_root,
337
+ # reject_duplicate_ids, and max_document_size (S-06).
338
+ begin
339
+ @policy.enforce!(sig_info, id_attributes: @id_attributes,
340
+ xml_bytes: xml_bytes, referenced_uris: @referenced_documents.keys)
341
+ rescue HankoXml::PolicyError => e
342
+ return failed_verification(input, sig_node, [e], signature_info: sig_info)
343
+ end
344
+
345
+ unresolved = unreferenced_external_uri(sig_info)
346
+ if unresolved
347
+ # The URI is document text, so it goes in #reference_uri, never the
348
+ # message (§5.10).
349
+ error = HankoXml::ReferenceError.new(
350
+ "an external reference URI is not in referenced_documents:, and nothing is ever fetched",
351
+ reference_uri: unresolved
352
+ )
353
+ return failed_verification(input, sig_node, [error], signature_info: sig_info)
354
+ end
355
+
356
+ # 3. Resolve trust material and call Native.verify
357
+ trusted, untrusted, trust_error, store_chain = collect_trust(sig_info)
358
+ return failed_verification(input, sig_node, [trust_error], signature_info: sig_info) if trust_error
359
+
360
+ # min_rsa_key_bits / min_ec_key_bits (S-06): only the actually
361
+ # resolved trust material can be checked, so this runs after
362
+ # collect_trust rather than as part of the sig_info-only enforce!
363
+ # above.
364
+ begin
365
+ @policy.enforce_key_size!(parsed_trust_keys(trusted))
366
+ rescue HankoXml::PolicyError => e
367
+ return failed_verification(input, sig_node, [e], signature_info: sig_info)
368
+ end
369
+
370
+ # Certificate expiration on the certificate:/certificates:/key: trust
371
+ # paths (S-07). signKey pinning (S-01) means xmlsec never reads
372
+ # KeyInfo to verify the embedded certificate, so it never checks these
373
+ # dates either -- this Ruby-side check is the only one that runs here
374
+ # (the store: path gets its own, equivalent check from
375
+ # OpenSSL::X509::StoreContext above). check_certificate_validity, not
376
+ # verification_time's presence, gates it -- an unset verification_time
377
+ # means "now", not "skip the check" (S-07); Policy.permissive sets
378
+ # check_certificate_validity: false to opt out entirely, matching the
379
+ # store: path's semantics. Key PEMs are skipped: they have no
380
+ # notBefore/notAfter. clock_skew (S-06) widens the window on both
381
+ # ends, the same way NTP-adjacent clock drift is usually tolerated.
382
+ if @policy.check_certificate_validity
383
+ verification_time = @verification_time || Time.now
384
+ skew = @policy.clock_skew
385
+ trusted.each do |pem|
386
+ next unless certificate_pem?(pem)
387
+
388
+ cert = OpenSSL::X509::Certificate.new(pem)
389
+ if verification_time < (cert.not_before - skew) || verification_time > (cert.not_after + skew)
390
+ return failed_verification(
391
+ input,
392
+ sig_node,
393
+ [HankoXml::CertificateExpired.new("Certificate expired at #{verification_time}")],
394
+ signature_info: sig_info
395
+ )
396
+ end
397
+ end
398
+ end
399
+
400
+ begin
401
+ verify_result = Native.verify(xml_bytes, trusted, untrusted, native_id_attributes, index,
402
+ @referenced_documents.to_a, @policy.allows?(:transform, :xpath_filter2))
403
+ rescue StandardError => e
404
+ return failed_verification(input, sig_node,
405
+ [native_failure(e, "the native layer failed unexpectedly while verifying")],
406
+ signature_info: sig_info)
407
+ end
408
+
409
+ valid = (verify_result[:status] == :succeeded)
410
+ errors = []
411
+
412
+ unless valid
413
+ # Fixed message: the xmlsec log can quote document text (S-13), so it
414
+ # is only exposed through #xmlsec_errors.
415
+ errors << HankoXml::VerificationError.new("Signature verification failed (#{verify_result[:status]})",
416
+ xmlsec_errors: verify_result[:errors], signature_node: sig_node)
417
+ end
418
+
419
+ HankoXml::Verification.new(
420
+ valid: valid,
421
+ errors: errors,
422
+ signature_info: sig_info,
423
+ signature_node: sig_node,
424
+ document: input.document,
425
+ namespaces: @namespaces,
426
+ id_attributes: @id_attributes,
427
+ resolver: @resolver,
428
+ reference_element_indexes: verify_result[:reference_element_indexes],
429
+ reference_valid: verify_result[:reference_valid],
430
+ signature_element_index: input.scan(native_id_attributes)[:signature_element_indexes][index],
431
+ certificate: valid ? verifying_certificate(trusted, verify_result[:trusted_index], store_chain&.first) : nil,
432
+ certificate_chain: valid ? store_chain : nil,
433
+ trusted: !without_trust?,
434
+ root_signature: input.scan(native_id_attributes)[:signature_element_indexes].include?(0)
435
+ )
436
+ end
437
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize
438
+ # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
439
+
440
+ # A Hash of URI => bytes, frozen. Keys drop any #fragment, the way xmlsec
441
+ # opens the URI.
442
+ def coerce_referenced_documents(documents)
443
+ unless documents.is_a?(Hash)
444
+ raise HankoXml::ConfigurationError, "referenced_documents: must be a Hash of URI => String or Document"
445
+ end
446
+
447
+ documents.to_h do |uri, document|
448
+ bytes = if document.is_a?(String) then document.b
449
+ elsif Dom.document?(document) then HankoXml.serialize(document).b
450
+ else
451
+ raise HankoXml::ConfigurationError,
452
+ "referenced_documents: #{uri.to_s.inspect} must be a String of bytes or a " \
453
+ "Nokogiri::XML::Document, got #{document.class}"
454
+ end
455
+ [uri.to_s.split("#", 2).first.to_s.freeze, bytes.freeze]
456
+ end.freeze
457
+ end
458
+
459
+ def unreferenced_external_uri(sig_info)
460
+ sig_info.references.find { |ref| ref.external? && !@referenced_documents.key?(ref.document_uri) }&.uri
461
+ end
462
+
463
+ def failed_verification(input, sig_node, errors, signature_info: nil)
464
+ HankoXml::Verification.new(
465
+ trusted: !without_trust?,
466
+ valid: false,
467
+ errors: errors,
468
+ signature_info: signature_info,
469
+ signature_node: sig_node,
470
+ document: input.document,
471
+ namespaces: @namespaces,
472
+ id_attributes: @id_attributes,
473
+ resolver: @resolver
474
+ )
475
+ end
476
+
477
+ def collect_trust(sig_info)
478
+ return embedded_trust(sig_info) if without_trust?
479
+ return keys_trust(sig_info) if @keys
480
+ return store_trust(sig_info) if @store
481
+
482
+ trusted = []
483
+ trusted << @key.to_pem if @key
484
+ trusted << @certificate.to_pem if @certificate
485
+ trusted.concat(@certificates.map(&:to_pem)) if @certificates
486
+ [trusted, [], nil]
487
+ end
488
+
489
+ def keys_trust(sig_info)
490
+ key_name = sig_info.key_names.first
491
+ unless key_name && @keys.key?(key_name)
492
+ return [nil, nil,
493
+ HankoXml::UntrustedCertificate.new("the document's <KeyName> is missing or not in the keys: map",
494
+ xmlsec_errors: ["KeyName: #{key_name.inspect}"])]
495
+ end
496
+
497
+ [[HankoXml::Key.coerce(@keys[key_name]).to_pem], [], nil]
498
+ end
499
+
500
+ def store_trust(sig_info)
501
+ begin
502
+ embedded = load_embedded_certificates(sig_info)
503
+ rescue OpenSSL::X509::CertificateError => e
504
+ return [nil, nil,
505
+ HankoXml::UntrustedCertificate.new("Invalid X509Certificate in KeyInfo", xmlsec_errors: [e.message])]
506
+ end
507
+
508
+ if embedded.empty?
509
+ return [nil, nil,
510
+ HankoXml::UntrustedCertificate.new("No X509Certificate found in KeyInfo for store verification")]
511
+ end
512
+
513
+ chain, error = verified_chain_from_store(embedded)
514
+ leaf = chain&.first
515
+ error ||= signing_certificate_violation(leaf)
516
+ return [nil, nil, error] if error
517
+
518
+ # xmlsec treats trusted cert PEMs as CA anchors and still X509-verifies
519
+ # every KeyInfo cert. A CA:FALSE leaf therefore cannot be "pinned" that
520
+ # way. After Ruby has enforced chain policy, pin the leaf public key as
521
+ # signKey (same path as `key:`) so KeyInfo is ignored. The chain OpenSSL
522
+ # validated, leaf first, is what trust resolved to, for
523
+ # Verification#certificate and #certificate_chain (#104).
524
+ [[leaf.public_key.to_pem], [], nil, chain]
525
+ end
526
+
527
+ # .without_trust_verification: pin the public key of the single
528
+ # end-entity certificate <KeyInfo> carries, as store: pins its validated
529
+ # leaf, so xmlsec still never picks the key itself (S-01). Nothing about
530
+ # it is trusted.
531
+ def embedded_trust(sig_info)
532
+ begin
533
+ embedded = load_embedded_certificates(sig_info)
534
+ rescue OpenSSL::X509::CertificateError => e
535
+ return [nil, nil,
536
+ HankoXml::UntrustedCertificate.new("Invalid X509Certificate in KeyInfo", xmlsec_errors: [e.message])]
537
+ end
538
+ leaves = end_entity_certificates(embedded)
539
+ unless leaves.size == 1
540
+ return [nil, nil, HankoXml::UntrustedCertificate.new(
541
+ "without_trust_verification needs exactly one end-entity X509Certificate in KeyInfo"
542
+ )]
543
+ end
544
+
545
+ [[leaves.first.public_key.to_pem], [], nil, [leaves.first]]
546
+ end
547
+
548
+ def load_embedded_certificates(sig_info)
549
+ sig_info.certificates.map { |pem| OpenSSL::X509::Certificate.new(pem) }
550
+ end
551
+
552
+ # The chain OpenSSL built and verified for the one end-entity certificate
553
+ # that chains to the store, leaf first, or an error.
554
+ def verified_chain_from_store(certificates)
555
+ candidates = end_entity_certificates(certificates)
556
+ candidates = certificates if candidates.empty?
557
+
558
+ verified = []
559
+ last_error = "Certificate chain validation failed"
560
+
561
+ candidates.each do |leaf|
562
+ chain = certificates.reject { |cert| cert.equal?(leaf) }
563
+ ctx = OpenSSL::X509::StoreContext.new(@store, leaf, chain)
564
+ if @policy.check_certificate_validity
565
+ ctx.time = @verification_time if @verification_time
566
+ else
567
+ ctx.flags = OpenSSL::X509::V_FLAG_NO_CHECK_TIME
568
+ end
569
+
570
+ if ctx.verify
571
+ verified << ctx.chain
572
+ else
573
+ message = ctx.error_string.to_s
574
+ last_error = message.empty? ? last_error : "Certificate chain validation failed: #{message}"
575
+ end
576
+ end
577
+
578
+ case verified.size
579
+ when 1
580
+ [verified.first, nil]
581
+ when 0
582
+ [nil, HankoXml::UntrustedCertificate.new(last_error)]
583
+ else
584
+ [nil, HankoXml::UntrustedCertificate.new(
585
+ "Ambiguous KeyInfo: multiple certificates chain to the trust store"
586
+ )]
587
+ end
588
+ end
589
+
590
+ # End-entity certs in this KeyInfo set: nobody else in the set was issued
591
+ # by them. Document order is not a leaf indicator (XML-DSig does not
592
+ # require leaf-first X509Certificate).
593
+ # KeyUsage bits (RFC 5280 §4.2.1.3), numbered from the most significant
594
+ # bit of the BIT STRING.
595
+ DIGITAL_SIGNATURE_BIT = 0
596
+ NON_REPUDIATION_BIT = 1
597
+ private_constant :DIGITAL_SIGNATURE_BIT, :NON_REPUDIATION_BIT
598
+
599
+ def coerce_certificate_subject(subject)
600
+ return nil if subject.nil?
601
+ unless @store
602
+ raise HankoXml::ConfigurationError,
603
+ "certificate_subject: only applies to store: (the other trust sources already pin the key)"
604
+ end
605
+ return subject.dup.freeze if subject.is_a?(String)
606
+ return subject if subject.is_a?(OpenSSL::X509::Name)
607
+
608
+ raise HankoXml::ConfigurationError,
609
+ "certificate_subject: must be a String (the CN) or an OpenSSL::X509::Name, got #{subject.class}"
610
+ end
611
+
612
+ # Certificate-usage policy on the leaf store: already validated (#63):
613
+ # KeyUsage and the optional certificate_subject:.
614
+ def signing_certificate_violation(leaf)
615
+ if @policy.require_signing_key_usage && !signing_key_usage?(leaf)
616
+ return HankoXml::UntrustedCertificate.new(
617
+ "signing certificate's KeyUsage allows neither digitalSignature nor nonRepudiation " \
618
+ "(require_signing_key_usage)"
619
+ )
620
+ end
621
+ return nil if @certificate_subject.nil? || certificate_subject_matches?(leaf)
622
+
623
+ # The subject comes from the document's certificate: detail, not message.
624
+ HankoXml::UntrustedCertificate.new(
625
+ "signing certificate subject does not match certificate_subject:",
626
+ xmlsec_errors: ["subject: #{leaf.subject}", "certificate_subject: #{@certificate_subject}"]
627
+ )
628
+ end
629
+
630
+ # No KeyUsage extension means no restriction (RFC 5280). When present,
631
+ # either bit is enough: qualified signing certificates (eIDAS/XAdES) often
632
+ # carry nonRepudiation alone.
633
+ def signing_key_usage?(cert)
634
+ extension = cert.extensions.find { |ext| ext.oid == "keyUsage" }
635
+ return true if extension.nil?
636
+
637
+ bits = OpenSSL::ASN1.decode(extension.value_der).value.to_s.unpack1("B*").to_s
638
+ [DIGITAL_SIGNATURE_BIT, NON_REPUDIATION_BIT].any? { |bit| bits[bit] == "1" }
639
+ rescue OpenSSL::ASN1::ASN1Error
640
+ false
641
+ end
642
+
643
+ # A String must be the certificate's only CN; an X509::Name must equal
644
+ # the whole subject.
645
+ def certificate_subject_matches?(cert)
646
+ return cert.subject.cmp(@certificate_subject).zero? if @certificate_subject.is_a?(OpenSSL::X509::Name)
647
+
648
+ common_names = cert.subject.to_a.filter_map { |name, value, _type| value if name == "CN" }
649
+ common_names == [@certificate_subject]
650
+ end
651
+
652
+ def end_entity_certificates(certificates)
653
+ certificates.select do |candidate|
654
+ certificates.none? { |other| issued_by?(other, candidate) }
655
+ end
656
+ end
657
+
658
+ def issued_by?(cert, issuer)
659
+ !cert.equal?(issuer) && cert.issuer.to_der == issuer.subject.to_der
660
+ end
661
+
662
+ # API_CONTRACT.md §5.4 `v.certificate`: the certificate whose key verified
663
+ # the signature. Native reports which trusted PEM that was; under store:
664
+ # it is the chain leaf Ruby validated. nil for a bare key (key:, or a
665
+ # public key in keys:).
666
+ def verifying_certificate(trusted, trusted_index, store_leaf)
667
+ return store_leaf if store_leaf
668
+ return nil if trusted_index.nil?
669
+
670
+ pem = trusted[trusted_index]
671
+ pem && certificate_pem?(pem) ? OpenSSL::X509::Certificate.new(pem) : nil
672
+ end
673
+
674
+ def certificate_pem?(pem)
675
+ pem.include?("BEGIN CERTIFICATE")
676
+ end
677
+
678
+ # Parses collect_trust's PEM Strings into OpenSSL objects for
679
+ # Policy#enforce_key_size! (S-06). A PEM that fails to parse here would
680
+ # already have failed identically inside Native.verify; skip it rather
681
+ # than raise a second, redundant error.
682
+ def parsed_trust_keys(trusted)
683
+ trusted.filter_map do |pem|
684
+ if certificate_pem?(pem)
685
+ OpenSSL::X509::Certificate.new(pem)
686
+ else
687
+ HankoXml::Key.read_pkey(pem, nil)
688
+ end
689
+ rescue OpenSSL::OpenSSLError
690
+ nil
691
+ end
692
+ end
693
+ end
694
+ end