hanko_xml 0.1.0-arm64-darwin

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.bundle +0 -0
  8. data/lib/hanko_xml/3.2/hanko_xml.bundle +0 -0
  9. data/lib/hanko_xml/3.3/hanko_xml.bundle +0 -0
  10. data/lib/hanko_xml/3.4/hanko_xml.bundle +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,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module HankoXml
6
+ # A frozen allowlist, enforced in Ruby before anything reaches
7
+ # libxmlsec1, on both signing and verification. See API_CONTRACT.md §5.8.
8
+ class Policy # rubocop:disable Metrics/ClassLength
9
+ FIELDS = %i[
10
+ signature_methods digest_methods canonicalization_methods transforms
11
+ allow_external_references allow_retrieval_method allow_same_document_xpointer
12
+ max_references require_references
13
+ require_signed_root reject_duplicate_ids max_document_size max_transforms_per_reference
14
+ min_rsa_key_bits min_ec_key_bits min_dsa_key_bits allow_hmac check_certificate_validity clock_skew
15
+ require_signing_key_usage schema
16
+ ].freeze
17
+
18
+ STRICT_DEFAULTS = {
19
+ signature_methods: %i[rsa_sha256 rsa_sha384 rsa_sha512 ecdsa_sha256 ecdsa_sha384 ecdsa_sha512],
20
+ digest_methods: %i[sha256 sha384 sha512],
21
+ canonicalization_methods: %i[c14n exc_c14n c14n11 c14n_with_comments exc_c14n_with_comments],
22
+ transforms: %i[enveloped_signature base64 c14n exc_c14n c14n11 c14n_with_comments
23
+ exc_c14n_with_comments],
24
+
25
+ allow_external_references: false,
26
+ allow_retrieval_method: false,
27
+ allow_same_document_xpointer: false,
28
+ max_references: 8,
29
+ require_references: true,
30
+
31
+ require_signed_root: true,
32
+ reject_duplicate_ids: true,
33
+ max_document_size: 16 * 1024 * 1024,
34
+ max_transforms_per_reference: 4,
35
+
36
+ min_rsa_key_bits: 2048,
37
+ min_ec_key_bits: 256,
38
+ min_dsa_key_bits: 2048,
39
+ allow_hmac: false,
40
+ check_certificate_validity: true,
41
+ clock_skew: 60,
42
+ # store: only -- the signing certificate's KeyUsage, when present, must
43
+ # allow digitalSignature or nonRepudiation (#63).
44
+ require_signing_key_usage: true,
45
+
46
+ schema: :default
47
+ }.freeze
48
+
49
+ attr_reader(*FIELDS)
50
+
51
+ def initialize(**overrides)
52
+ unknown = overrides.keys - FIELDS
53
+ raise ArgumentError, "unknown Policy field(s): #{unknown.inspect}" unless unknown.empty?
54
+
55
+ values = STRICT_DEFAULTS.merge(overrides)
56
+ FIELDS.each { |f| instance_variable_set(:"@#{f}", values[f]) }
57
+ @allowed_uris = snapshot_allowed_uris
58
+ freeze
59
+ end
60
+
61
+ def with(**overrides)
62
+ self.class.new(**to_h.merge(overrides))
63
+ end
64
+
65
+ def to_h
66
+ FIELDS.each_with_object({}) { |f, h| h[f] = public_send(f) }
67
+ end
68
+
69
+ class << self
70
+ def strict
71
+ @strict ||= new
72
+ end
73
+
74
+ # strict, minus require_signed_root -- for protocols that sign a
75
+ # fragment (SAML assertions).
76
+ def fragment
77
+ @fragment ||= strict.with(require_signed_root: false)
78
+ end
79
+
80
+ # strict + SHA-1 digest and RSA-SHA1 signature.
81
+ def legacy_sha1
82
+ @legacy_sha1 ||= strict.with(
83
+ digest_methods: strict.digest_methods + [:sha1],
84
+ signature_methods: strict.signature_methods + [:rsa_sha1]
85
+ )
86
+ end
87
+
88
+ # strict + RSA-PSS (RFC 6931 MGF1) and SHA-3 digest / ECDSA-SHA3
89
+ # signature methods, the algorithms EU XAdES profiles often want (#60).
90
+ # Kept out of Policy.strict the same way SHA-1 is. xmlsec 1.3 (vendored
91
+ # / platform gems) implements them; xmlsec 1.2 (Ubuntu CI) does not —
92
+ # Native.sign/verify then fails closed.
93
+ def xades
94
+ @xades ||= strict.with(
95
+ # EU DSS's enveloped form, in the one shape Policy accepts (#85).
96
+ transforms: strict.transforms + [:xpath_filter2],
97
+ digest_methods: strict.digest_methods + %i[sha3_256 sha3_384 sha3_512],
98
+ signature_methods: strict.signature_methods + %i[
99
+ rsa_pss_sha256 rsa_pss_sha384 rsa_pss_sha512
100
+ ecdsa_sha3_256 ecdsa_sha3_384 ecdsa_sha3_512
101
+ ]
102
+ )
103
+ end
104
+
105
+ # fragment + what the W3C interop signatures (merlin-xmldsig-twenty-three,
106
+ # phaos-xmldsig-three) need: SHA-1 with RSA or DSA, and 1024-bit RSA and
107
+ # DSA keys.
108
+ # For verifying legacy signatures from other implementations (#62), never
109
+ # as a default. External references, RetrievalMethod, HMAC and
110
+ # XPath/XSLT stay off, as in strict.
111
+ def interop
112
+ @interop ||= fragment.with(
113
+ transforms: fragment.transforms + [:xpath_filter2],
114
+ digest_methods: fragment.digest_methods + [:sha1],
115
+ signature_methods: fragment.signature_methods + %i[rsa_sha1 dsa_sha1],
116
+ min_rsa_key_bits: 1024,
117
+ min_dsa_key_bits: 1024
118
+ )
119
+ end
120
+
121
+ # Allows everything the Ruby checks cover. Documented as unsafe. The
122
+ # native layer still refuses XPath and XSLT transforms (S-10) and reads
123
+ # an external URI only from referenced_documents: (#74), whatever this
124
+ # says. XPath Filter 2.0 still passes only in the subtract-the-signatures
125
+ # shape (#85).
126
+ def permissive
127
+ @permissive ||= new(
128
+ signature_methods: Algorithms.signature.names,
129
+ digest_methods: Algorithms.digest.names,
130
+ canonicalization_methods: Algorithms.canonicalization.names,
131
+ transforms: Algorithms.transform.names,
132
+ allow_external_references: true,
133
+ allow_retrieval_method: true,
134
+ allow_same_document_xpointer: true,
135
+ max_references: Float::INFINITY,
136
+ require_references: false,
137
+ require_signed_root: false,
138
+ reject_duplicate_ids: false,
139
+ max_document_size: Float::INFINITY,
140
+ max_transforms_per_reference: Float::INFINITY,
141
+ min_rsa_key_bits: 0,
142
+ min_ec_key_bits: 0,
143
+ min_dsa_key_bits: 0,
144
+ allow_hmac: true,
145
+ check_certificate_validity: false,
146
+ clock_skew: 0,
147
+ require_signing_key_usage: false,
148
+ schema: nil
149
+ )
150
+ end
151
+ end
152
+
153
+ # Validates +signature_info+ (a SignatureInfo) against this policy.
154
+ # Returns an Array<HankoXml::PolicyError>, empty when compliant. Never
155
+ # raises for a policy violation -- that is #enforce!'s job.
156
+ #
157
+ # +signature_info+ alone is enough for every algorithm/structural check
158
+ # (works without a DOM, per API_CONTRACT.md §6.1). The keyword
159
+ # arguments are optional context that only Signer/Verifier -- which
160
+ # always have a DOM and trust material -- can supply, for the handful
161
+ # of fields that genuinely need it (S-06):
162
+ #
163
+ # @param document [Nokogiri::XML::Document, nil] the document, when the
164
+ # caller has only a DOM: it is serialized for the checks below.
165
+ # @param sig_node [Nokogiri::XML::Node, nil] internal (@api private):
166
+ # accepted for compatibility; no check needs it any more (#102).
167
+ # @param id_attributes [Array<String>, nil] the caller's `id_attributes:`,
168
+ # needed by +reject_duplicate_ids+ and +require_signed_root+.
169
+ # @param xml_bytes [String, nil] the document's bytes, for
170
+ # +max_document_size+, +require_signed_root+ and +reject_duplicate_ids+.
171
+ # Both structural checks read the native layer's parse of these bytes
172
+ # (Native.scan), so they run identically with or without Nokogiri
173
+ # (#102). Without +xml_bytes+ or +document+ an enabled check reports a
174
+ # violation rather than being skipped (#96).
175
+ # @param check_root_signed [Boolean] internal (@api private), for
176
+ # {Signer}: whether to apply
177
+ # +require_signed_root+ here. `require_signed_root` (API_CONTRACT.md
178
+ # §5.8.1) is a *verification*-time XSW defence -- it says nothing
179
+ # about what is legitimate to produce, so {Signer} deliberately signs
180
+ # a caller-chosen fragment (opted into via `references:`, R5) without
181
+ # tripping it; only {Verifier} checks it.
182
+ # @param referenced_uris [Array<String>] internal (@api private), for
183
+ # {Verifier}: URIs the caller serves through
184
+ # `referenced_documents:` (#74). An external reference to one of them
185
+ # is allowed whatever +allow_external_references+ says: it is read from
186
+ # the caller's map, never fetched.
187
+ #
188
+ # +min_rsa_key_bits+ / +min_ec_key_bits+ are not checked here -- they
189
+ # need the actual signing/trust key material, which is only available
190
+ # once Signer/Verifier have resolved it. See #enforce_key_size!.
191
+ # sig_node: stays in the signature for callers that pass it (#102).
192
+ def violations(signature_info, document: nil, sig_node: nil, id_attributes: nil, xml_bytes: nil, # rubocop:disable Lint/UnusedMethodArgument
193
+ check_root_signed: true, referenced_uris: [])
194
+ errors = []
195
+
196
+ errors << algorithm_violation(:signature, signature_info.signature_method)
197
+ errors << algorithm_violation(:canonicalization, signature_info.canonicalization_method)
198
+ errors << hmac_violation(signature_info)
199
+ errors << retrieval_method_violation(signature_info)
200
+
201
+ if require_references && signature_info.references.empty?
202
+ errors << HankoXml::PolicyError.new(
203
+ "this policy requires at least one <Reference>, but <SignedInfo> has none " \
204
+ "(require_references)"
205
+ )
206
+ end
207
+
208
+ if signature_info.references.size > max_references
209
+ errors << HankoXml::PolicyError.new(
210
+ "<SignedInfo> has #{signature_info.references.size} references, more than this " \
211
+ "policy's max_references (#{max_references})"
212
+ )
213
+ end
214
+
215
+ errors.concat(reference_violations(signature_info.references, referenced_uris))
216
+
217
+ # The structural checks parse the document natively, so an oversized
218
+ # one is reported and never parsed (S-12). A DOM is measured by its
219
+ # serialized bytes, as Signer and Verifier measure it.
220
+ xml_bytes ||= HankoXml.serialize(document) if document
221
+ size_error = document_size_violation(xml_bytes&.bytesize)
222
+ if size_error
223
+ errors << size_error
224
+ else
225
+ structure = DocumentStructure.new(xml_bytes, id_attributes)
226
+ errors << root_signed_violation(structure, signature_info) if check_root_signed
227
+ errors << duplicate_id_violation(structure)
228
+ end
229
+
230
+ errors.compact
231
+ end
232
+
233
+ # Raises HankoXml::PolicyError (with all violations joined into the
234
+ # message) if +signature_info+ violates this policy. Accepts the same
235
+ # optional context keywords as #violations.
236
+ def enforce!(signature_info, document: nil, sig_node: nil, id_attributes: nil, xml_bytes: nil,
237
+ check_root_signed: true, referenced_uris: [])
238
+ errors = violations(signature_info, document: document, sig_node: sig_node, id_attributes: id_attributes,
239
+ xml_bytes: xml_bytes, check_root_signed: check_root_signed,
240
+ referenced_uris: referenced_uris)
241
+ return if errors.empty?
242
+
243
+ raise HankoXml::PolicyError, errors.map(&:message).join("; ")
244
+ end
245
+
246
+ # Raises HankoXml::PolicyError if +bytesize+ exceeds
247
+ # +max_document_size+. Separate from #enforce! so Signer/Verifier can
248
+ # apply it before parsing and before any native call -- #enforce! needs
249
+ # Native.inspect_sig output, which already means a native parse (S-12).
250
+ # Raises HankoXml::PolicyError unless +uri_or_name+ is in this policy's
251
+ # allowlist for +slot+ (:signature, :digest, :canonicalization,
252
+ # :transform). For algorithms named outside <SignedInfo>, such as a XAdES
253
+ # certificate digest.
254
+ # Whether +uri_or_name+ is in this policy's allowlist for +slot+.
255
+ def allows?(slot, uri_or_name)
256
+ algorithm_violation(slot, uri_or_name).nil?
257
+ end
258
+
259
+ def enforce_algorithm!(slot, uri_or_name)
260
+ error = algorithm_violation(slot, uri_or_name)
261
+ raise error if error
262
+ end
263
+
264
+ def enforce_document_size!(bytesize)
265
+ error = document_size_violation(bytesize)
266
+ raise error if error
267
+ end
268
+
269
+ # Raises HankoXml::PolicyError if any of +keys+
270
+ # (OpenSSL::PKey::PKey / OpenSSL::X509::Certificate) is smaller than
271
+ # this policy's min_rsa_key_bits / min_ec_key_bits / min_dsa_key_bits (S-06). Separate from
272
+ # #enforce! because the real signing/trust key is only known once
273
+ # Signer/Verifier have resolved it (Signer's own key; Verifier's
274
+ # resolved trust material) -- after SignatureInfo has already been
275
+ # policy-checked.
276
+ def enforce_key_size!(keys)
277
+ errors = Array(keys).filter_map { |key| key_size_violation(key) }
278
+ return if errors.empty?
279
+
280
+ raise HankoXml::PolicyError, errors.map(&:message).join("; ")
281
+ end
282
+
283
+ private
284
+
285
+ def reference_violations(references, referenced_uris)
286
+ references.flat_map do |ref|
287
+ errors = [
288
+ algorithm_violation(:digest, ref.digest_method, reference_uri: ref.uri)
289
+ ]
290
+
291
+ if ref.transforms.size > max_transforms_per_reference
292
+ errors << HankoXml::PolicyError.new(
293
+ "reference #{ref.uri.inspect} has #{ref.transforms.size} transforms, more than this " \
294
+ "policy's max_transforms_per_reference (#{max_transforms_per_reference})"
295
+ )
296
+ end
297
+
298
+ ref.transforms.each do |transform_uri|
299
+ errors << algorithm_violation(:transform, transform_uri, reference_uri: ref.uri)
300
+ end
301
+ errors << xpath_filter2_violation(ref)
302
+
303
+ if !allow_external_references && ref.external? && !referenced_uris.include?(ref.document_uri)
304
+ errors << HankoXml::PolicyError.new(
305
+ "reference URI #{ref.uri.inspect} is external, and allow_external_references is false"
306
+ )
307
+ end
308
+
309
+ if !allow_same_document_xpointer && ref.same_document_xpointer?
310
+ errors << HankoXml::PolicyError.new(
311
+ "reference URI #{ref.uri.inspect} is a same-document XPointer, and " \
312
+ "allow_same_document_xpointer is false"
313
+ )
314
+ end
315
+
316
+ errors
317
+ end
318
+ end
319
+
320
+ # An allowed XPath Filter 2.0 transform must be the standard enveloped
321
+ # form, "the whole document minus the signatures" (#85): arbitrary
322
+ # filters can select a node set #signed? does not model (signature
323
+ # wrapping), and are a denial-of-service vector (S-10). xmlsec evaluates
324
+ # the accepted shape; nothing here evaluates XPath.
325
+ def xpath_filter2_violation(ref)
326
+ return nil unless ref.xpath_filter2? && allows?(:transform, :xpath_filter2)
327
+ return nil if ref.signature_exclusion
328
+
329
+ expressions = ref.xpath_filters.map { |f| f.transform_keys(&:to_sym).values_at(:filter, :expression).join(" ") }
330
+ found = expressions.empty? ? "no filter <XPath>" : expressions.join("; ").inspect
331
+ HankoXml::PolicyError.new(
332
+ "reference #{ref.uri.inspect} has an XPath Filter 2.0 transform other than a single subtract of " \
333
+ "/descendant::ds:Signature or /*/ds:Signature on the whole document (#{found})"
334
+ )
335
+ end
336
+
337
+ def hmac_violation(signature_info)
338
+ return nil if allow_hmac
339
+
340
+ name = Algorithms.name_for(:signature, signature_info.signature_method)
341
+ return nil unless name.to_s.start_with?("hmac")
342
+
343
+ HankoXml::PolicyError.new(
344
+ "signature method #{signature_info.signature_method} (#{name.inspect}) is HMAC-based, " \
345
+ "and allow_hmac is false"
346
+ )
347
+ end
348
+
349
+ def retrieval_method_violation(signature_info)
350
+ return nil if allow_retrieval_method
351
+ return nil unless signature_info.retrieval_method?
352
+
353
+ HankoXml::PolicyError.new(
354
+ "<KeyInfo> contains a <RetrievalMethod>, and allow_retrieval_method is false"
355
+ )
356
+ end
357
+
358
+ # The document facts +require_signed_root+ and +reject_duplicate_ids+
359
+ # need, from the native layer's parse of the same bytes xmlsec verifies
360
+ # (Native.scan). Scanned at most once, and only when a check asks.
361
+ class DocumentStructure
362
+ def initialize(xml_bytes, id_attributes)
363
+ @xml_bytes = xml_bytes
364
+ @id_attributes = id_attributes
365
+ end
366
+
367
+ def available?
368
+ !@xml_bytes.nil?
369
+ end
370
+
371
+ def id_attributes?
372
+ !@id_attributes.nil?
373
+ end
374
+
375
+ def id_names
376
+ (@id_attributes || []).map { |attr| attr.is_a?(Array) ? attr[1].to_s : attr.to_s }
377
+ end
378
+
379
+ def scan
380
+ @scan ||= Native.scan(@xml_bytes.b, id_names, false)
381
+ end
382
+ end
383
+ private_constant :DocumentStructure
384
+
385
+ def missing_context_violation(field)
386
+ HankoXml::PolicyError.new(
387
+ "#{field} needs the document to check; pass xml_bytes: (or document:) and id_attributes:, " \
388
+ "or use a policy with #{field}: false"
389
+ )
390
+ end
391
+
392
+ # Structural check: does any Reference cover the document root? An empty
393
+ # URI or `#xpointer(/)` is the whole document (#85); a bare `#id` covers the root when xmlsec's
394
+ # ID table resolves it to the root element (Native.scan's root_ids).
395
+ # Runs before any cryptography, and does not wait for a real
396
+ # Verification#covers_root? (S-06).
397
+ def root_signed_violation(structure, signature_info)
398
+ return nil unless require_signed_root
399
+ # Without the document this cannot be checked, and an unchecked
400
+ # structural rule must fail closed, not pass (#96).
401
+ return missing_context_violation(:require_signed_root) unless structure.available?
402
+
403
+ return nil if signature_info.references.any? { |ref| SignatureInfo::WHOLE_DOCUMENT_URIS.include?(ref.uri.to_s) }
404
+
405
+ root_ids = structure.id_attributes? ? structure.scan[:root_ids] : []
406
+ return nil if signature_info.references.any? { |ref| ref.fragment? && root_ids.include?(ref.uri[1..]) }
407
+
408
+ HankoXml::PolicyError.new(
409
+ "this policy requires the root element to be signed (require_signed_root), but no " \
410
+ "<Reference> covers it"
411
+ )
412
+ end
413
+
414
+ # Whole-document duplicate-ID scan (S-06), as coarse as xmlSecAddIDs
415
+ # (S-03): any attribute whose local name is an ID attribute counts,
416
+ # whatever its namespace, because xmlsec registers all of them.
417
+ def duplicate_id_violation(structure)
418
+ return nil unless reject_duplicate_ids
419
+ return missing_context_violation(:reject_duplicate_ids) unless structure.available? && structure.id_attributes?
420
+ return nil if structure.id_names.empty?
421
+ return nil unless structure.scan[:duplicate_ids]
422
+
423
+ HankoXml::PolicyError.new(
424
+ "document has more than one element with the same ID attribute value, and " \
425
+ "reject_duplicate_ids is true"
426
+ )
427
+ end
428
+
429
+ def document_size_violation(bytesize)
430
+ return nil if bytesize.nil?
431
+ return nil if bytesize <= max_document_size
432
+
433
+ HankoXml::PolicyError.new(
434
+ "document is #{bytesize} bytes, more than this policy's max_document_size " \
435
+ "(#{max_document_size})"
436
+ )
437
+ end
438
+
439
+ def key_size_violation(key)
440
+ kind, bits = key_bits(key)
441
+ return nil if kind.nil?
442
+
443
+ field = :"min_#{kind}_key_bits"
444
+ minimum = public_send(field)
445
+ return nil if bits >= minimum
446
+
447
+ HankoXml::PolicyError.new("#{kind} key is #{bits} bits, less than this policy's #{field} (#{minimum})")
448
+ end
449
+
450
+ def key_bits(key)
451
+ public_key = key.is_a?(::OpenSSL::X509::Certificate) ? key.public_key : key
452
+ case public_key
453
+ when ::OpenSSL::PKey::RSA
454
+ [:rsa, public_key.n.num_bits]
455
+ when ::OpenSSL::PKey::EC
456
+ [:ec, public_key.group.degree]
457
+ when ::OpenSSL::PKey::DSA
458
+ [:dsa, public_key.p.num_bits]
459
+ else
460
+ [nil, nil]
461
+ end
462
+ end
463
+
464
+ ALGORITHM_FIELDS = {
465
+ signature: :signature_methods,
466
+ digest: :digest_methods,
467
+ canonicalization: :canonicalization_methods,
468
+ transform: :transforms
469
+ }.freeze
470
+ private_constant :ALGORITHM_FIELDS
471
+
472
+ # The allowlists resolved to URIs once, at construction (S-16), so a
473
+ # later Algorithms.register cannot change what an existing Policy --
474
+ # Policy.strict included -- accepts.
475
+ def snapshot_allowed_uris
476
+ ALGORITHM_FIELDS.to_h do |slot, field|
477
+ uris = Array(public_send(field)).filter_map { |entry| Algorithms.uri_for(slot, entry) }
478
+ [slot, uris.to_h { |uri| [uri, true] }.freeze]
479
+ end.freeze
480
+ end
481
+
482
+ def algorithm_violation(slot, uri_or_name, reference_uri: nil)
483
+ return nil if uri_or_name.nil?
484
+
485
+ field_name = ALGORITHM_FIELDS.fetch(slot)
486
+
487
+ # A String from the document is compared verbatim: resolving it through
488
+ # the registry would let Algorithm="sha256" stand in for the real URI.
489
+ candidate = uri_or_name.is_a?(String) ? uri_or_name : Algorithms.uri_for(slot, uri_or_name)
490
+ return nil if @allowed_uris.fetch(slot).key?(candidate)
491
+
492
+ name = Algorithms.name_for(slot, uri_or_name)
493
+ location = reference_uri ? " on reference #{reference_uri.inspect}" : ""
494
+
495
+ HankoXml::PolicyError.new(
496
+ "#{slot} method #{uri_or_name}#{name ? " (#{name.inspect})" : ""}#{location} is not in " \
497
+ "this policy's #{field_name}. Use a preset such as HankoXml::Policy.legacy_sha1, " \
498
+ "HankoXml::Policy.xades or Policy#with(#{field_name}: …) if this document genuinely requires it."
499
+ )
500
+ end
501
+ end
502
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # A single <ds:Reference> a Signer will emit. Frozen value object. See
5
+ # API_CONTRACT.md §5.2.
6
+ class Reference
7
+ attr_reader :uri, :transforms, :digest_method, :id, :type
8
+
9
+ # +uri+: "" for the whole document (enveloped), "#id" for a fragment,
10
+ # or nil to default to "" when this is the only/default reference.
11
+ # +transforms+: Array of Transform / Symbol / String, in order.
12
+ # +digest_method+: Symbol or URI String, defaults to :sha256.
13
+ def initialize(uri: "", transforms: [:enveloped_signature], digest_method: :sha256, id: nil, type: nil)
14
+ @uri = uri.nil? ? "" : uri.to_s
15
+ @transforms = Array(transforms).map { |t| Transform.coerce(t) }
16
+ @digest_method = digest_method
17
+ @id = id
18
+ @type = type
19
+ freeze
20
+ end
21
+
22
+ def digest_method_uri
23
+ Algorithms.uri_for(:digest, digest_method)
24
+ end
25
+
26
+ def enveloped?
27
+ transforms.any?(&:enveloped_signature?)
28
+ end
29
+
30
+ def fragment?
31
+ uri.start_with?("#")
32
+ end
33
+
34
+ def target_id
35
+ fragment? ? uri.sub(/\A#/, "") : nil
36
+ end
37
+
38
+ def self.coerce(value)
39
+ case value
40
+ when Reference then value
41
+ when String then new(uri: value)
42
+ when Hash then new(**value)
43
+ else
44
+ raise ArgumentError, "expected a HankoXml::Reference, URI String, or Hash of Reference " \
45
+ "keywords, got #{value.class}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # DOM-free, unauthenticated view of a <ds:Signature>'s <SignedInfo> --
5
+ # exactly the shape HankoXml::Native.inspect returns (§7 of
6
+ # API_CONTRACT.md), and nothing more. Policy is checked against this, not
7
+ # against Nokogiri nodes, so Policy enforcement works identically whether
8
+ # or not Nokogiri is loaded (§6.1).
9
+ #
10
+ # Everything here is "what the document claims" -- it runs before any
11
+ # cryptography, and is not proof of anything until a Verification says so.
12
+ #
13
+ # The library builds these; callers only read them, through
14
+ # Verification#signed_info / #signature_info. The constructors are not
15
+ # part of the public API.
16
+ class SignatureInfo
17
+ XPATH_FILTER2 = "http://www.w3.org/2002/06/xmldsig-filter2"
18
+ # The whole document: without comments (""), and with them ("#xpointer(/)",
19
+ # which allow_same_document_xpointer still governs).
20
+ WHOLE_DOCUMENT_URIS = ["", "#xpointer(/)"].freeze
21
+ DSIG_NS = "http://www.w3.org/2000/09/xmldsig#"
22
+
23
+ ReferenceInfo = Struct.new(:uri, :digest_method, :transforms, :id, :type, :xpath_filters, keyword_init: true) do
24
+ def initialize(uri:, digest_method:, transforms: [], id: nil, type: nil, xpath_filters: [])
25
+ super
26
+ end
27
+
28
+ # Which <ds:Signature> elements an XPath Filter 2.0 transform takes out
29
+ # of this Reference, for the one shape HankoXml accepts (#85): a
30
+ # whole-document reference (WHOLE_DOCUMENT_URIS) whose first transform is a single `subtract` of
31
+ # `/descendant::P:Signature` (every signature, :all) or `/*/P:Signature`
32
+ # (the root's signature children, :root_children), with P bound to the
33
+ # XML-DSig namespace on the <XPath> element. nil for anything else, which
34
+ # Policy refuses. The expression is matched token for token (XPath
35
+ # allows whitespace between tokens), never by its prefix's spelling:
36
+ # `name()='ds:Signature'` would match any namespace bound to `ds`.
37
+ def signature_exclusion
38
+ return nil unless WHOLE_DOCUMENT_URIS.include?(uri.to_s) && transforms.first == XPATH_FILTER2
39
+ return nil unless transforms.count(XPATH_FILTER2) == 1 && xpath_filters.size == 1
40
+
41
+ filter = xpath_filters.first.transform_keys(&:to_sym)
42
+ return nil unless filter[:filter] == "subtract"
43
+
44
+ match = filter[:expression].to_s.gsub(/\s+/, "").match(%r{\A/(descendant::|\*/)([A-Za-z_][\w.-]*):Signature\z})
45
+ return nil unless match && filter[:namespaces].to_h[match[2]] == DSIG_NS
46
+
47
+ match[1] == "descendant::" ? :all : :root_children
48
+ end
49
+
50
+ def xpath_filter2?
51
+ transforms.include?(XPATH_FILTER2)
52
+ end
53
+
54
+ def digest_method_name
55
+ Algorithms.name_for(:digest, digest_method)
56
+ end
57
+
58
+ def transform_names
59
+ transforms.map { |uri| Algorithms.name_for(:transform, uri) }
60
+ end
61
+
62
+ def fragment?
63
+ uri.to_s.start_with?("#")
64
+ end
65
+
66
+ def external?
67
+ !(uri.to_s.empty? || fragment?)
68
+ end
69
+
70
+ # The URI xmlsec reads an external reference from: everything before
71
+ # any `#` fragment.
72
+ def document_uri
73
+ uri.to_s.split("#", 2).first.to_s
74
+ end
75
+
76
+ # Same-document XPointer, e.g. `#xpointer(/)` or `#xpointer(id('x'))` --
77
+ # not a bare `#id` fragment (S-06, `allow_same_document_xpointer`).
78
+ def same_document_xpointer?
79
+ fragment? && uri[1..].to_s.include?("(")
80
+ end
81
+ end
82
+
83
+ attr_reader :signature_method, :canonicalization_method, :signature_count,
84
+ :key_names, :certificates, :references
85
+
86
+ # @api private
87
+ def initialize(signature_method:, canonicalization_method:, references:,
88
+ signature_count: 1, key_names: [], certificates: [], has_retrieval_method: false)
89
+ @signature_method = signature_method
90
+ @canonicalization_method = canonicalization_method
91
+ @signature_count = signature_count
92
+ @key_names = key_names.freeze
93
+ @certificates = certificates.freeze
94
+ @references = references.map { |r| r.is_a?(ReferenceInfo) ? r : ReferenceInfo.new(**r) }.freeze
95
+ @has_retrieval_method = has_retrieval_method
96
+ freeze
97
+ end
98
+
99
+ # Whether `<KeyInfo>` contains a `<RetrievalMethod>` (S-06,
100
+ # `allow_retrieval_method`).
101
+ def retrieval_method?
102
+ @has_retrieval_method
103
+ end
104
+
105
+ def signature_method_name
106
+ Algorithms.name_for(:signature, signature_method)
107
+ end
108
+
109
+ def canonicalization_method_name
110
+ Algorithms.name_for(:canonicalization, canonicalization_method)
111
+ end
112
+
113
+ # Builds a SignatureInfo from the native Native.inspect Hash shape
114
+ # (String keys/URIs throughout).
115
+ #
116
+ # @api private
117
+ def self.from_native(hash)
118
+ new(
119
+ signature_method: fetch(hash, :signature_method),
120
+ canonicalization_method: fetch(hash, :canonicalization_method),
121
+ signature_count: fetch(hash, :signature_count) || 1,
122
+ key_names: fetch(hash, :key_names) || [],
123
+ certificates: fetch(hash, :certificates) || [],
124
+ references: fetch(hash, :references) || [],
125
+ has_retrieval_method: fetch(hash, :has_retrieval_method) || false
126
+ )
127
+ end
128
+
129
+ def self.fetch(hash, key)
130
+ hash[key] || hash[key.to_s]
131
+ end
132
+ private_class_method :fetch
133
+ end
134
+ end