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,572 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # Declares the parts of a signature -- references, transforms, KeyInfo
5
+ # clauses, placement -- and builds the <Signature> template from them.
6
+ # See API_CONTRACT.md §5.2.
7
+ #
8
+ # A Signer is frozen after construction: immutable, reusable, safe to
9
+ # share across threads.
10
+ #
11
+ # #template and #to_h inspect what a Signer would produce without signing;
12
+ # #sign and #sign! perform the signature through the native extension.
13
+ class Signer # rubocop:disable Metrics/ClassLength
14
+ TEMPLATE_MODES = %i[adopt build require].freeze
15
+
16
+ # The key class xmlsec needs for each built-in signature method, by the
17
+ # method name's prefix (:rsa_sha256 -> RSA). HMAC takes no asymmetric key
18
+ # and custom registered methods are left to xmlsec.
19
+ SIGNATURE_KEY_TYPES = {
20
+ "rsa" => ::OpenSSL::PKey::RSA,
21
+ "ecdsa" => ::OpenSSL::PKey::EC,
22
+ "dsa" => ::OpenSSL::PKey::DSA
23
+ }.freeze
24
+ private_constant :SIGNATURE_KEY_TYPES
25
+
26
+ # What to suggest for each key class when it does not fit the method.
27
+ SIGNATURE_METHOD_FOR_KEY = {
28
+ ::OpenSSL::PKey::RSA => :rsa_sha256,
29
+ ::OpenSSL::PKey::EC => :ecdsa_sha256,
30
+ ::OpenSSL::PKey::DSA => :dsa_sha1
31
+ }.freeze
32
+ private_constant :SIGNATURE_METHOD_FOR_KEY
33
+
34
+ # KeyInfo leaves xmlsec derives from the signing key. It fills only empty
35
+ # ones, so an adopted template's stale values must be emptied first (#72).
36
+ KEY_DERIVED_LEAVES = %w[
37
+ ds:X509Data/ds:X509Certificate ds:X509Data/ds:X509SubjectName
38
+ ds:X509Data/ds:X509IssuerSerial ds:X509Data/ds:X509SKI ds:KeyValue
39
+ ].map { |path| "./#{path}" }.join(" | ").freeze
40
+ private_constant :KEY_DERIVED_LEAVES
41
+
42
+ # The setters are for the Signer.new block only: the Signer is frozen
43
+ # when #initialize returns, after which they raise FrozenError.
44
+ attr_accessor :key, :certificate, :certificates, :signature_method, :canonicalization_method,
45
+ :digest_method, :references, :placement, :id_attributes, :namespaces,
46
+ :signature_id, :policy
47
+ attr_reader :template_mode
48
+ # Reader only: #key_info below is a dual getter/setter (API_CONTRACT.md
49
+ # §5.2), so an attr_accessor reader here would be dead code shadowed by it.
50
+ attr_writer :key_info
51
+
52
+ # Creates a new Signer with the given configuration.
53
+ # Yields +self+ to a block if provided, allowing block-based configuration before freezing.
54
+ #
55
+ # @param key [Key, OpenSSL::PKey::PKey, String, Pathname] The private key used to sign the document.
56
+ # @param certificate [Key, OpenSSL::X509::Certificate, String, Pathname, nil]
57
+ # The certificate corresponding to the key.
58
+ # @param certificates [Array<Key, OpenSSL::X509::Certificate, String, Pathname>] Extra certificates for the chain.
59
+ # @param signature_method [Symbol] The signature algorithm (e.g., :rsa_sha256).
60
+ # @param digest_method [Symbol] The digest algorithm for references (e.g., :sha256).
61
+ # @param canonicalization_method [Symbol] The canonicalization method for SignedInfo (e.g., :exc_c14n).
62
+ # @param placement [Placement, Symbol] Where to insert the `<Signature>` node (e.g., :append_to_root).
63
+ # @param references [Array<Reference, String, Hash>] The references to sign.
64
+ # @param key_info [Array<Symbol, Hash>] The elements to include in `<KeyInfo>` (e.g., :x509_certificate).
65
+ # `<KeyName>` needs its text: `{ key_name: "idp-1" }`.
66
+ # @param id_attributes [Array<String, Array(String, String)>] The attribute names considered as IDs for
67
+ # `#foo` references, optionally as `[namespace_href, name]` pairs.
68
+ # @param template [Symbol] Either :adopt (reuse existing signature template), :build, or :require.
69
+ # @param signature_id [String, nil] The Id attribute to place on the `<Signature>` element itself.
70
+ def initialize(key:, certificate: nil, certificates: nil, signature_method: :rsa_sha256,
71
+ canonicalization_method: :exc_c14n, digest_method: :sha256, references: [],
72
+ key_info: [:x509_certificate], placement: :append_to_root,
73
+ id_attributes: %w[Id ID id], namespaces: {}, signature_id: nil,
74
+ policy: HankoXml::Policy.strict, template: :adopt)
75
+ unless key.is_a?(HankoXml::Key) || Dom.pkey?(key) || key.is_a?(String) ||
76
+ key.respond_to?(:to_path)
77
+ raise HankoXml::ConfigurationError,
78
+ "key: must be key material or an HankoXml::Key (external signers are not supported " \
79
+ "in v1 -- see API_CONTRACT.md §5.2)"
80
+ end
81
+
82
+ unless TEMPLATE_MODES.include?(template.to_sym)
83
+ raise HankoXml::ConfigurationError, "template: must be one of #{TEMPLATE_MODES.inspect}"
84
+ end
85
+
86
+ @key = HankoXml::Key.coerce(key)
87
+ @certificate = certificate && HankoXml::Key.coerce(certificate)
88
+ @certificates = Array(certificates).map { |c| HankoXml::Key.coerce(c) }
89
+ @signature_method = signature_method
90
+ @canonicalization_method = canonicalization_method
91
+ @digest_method = digest_method
92
+ @references = Array(references).map { |r| HankoXml::Reference.coerce(r) }
93
+ @key_info = HankoXml::KeyInfo.coerce(key_info)
94
+ @placement = HankoXml::Placement.coerce(placement) || HankoXml::Placement.append_to_root
95
+ @id_attributes = id_attributes
96
+ @namespaces = namespaces
97
+ @signature_id = signature_id
98
+ @policy = policy
99
+ @template_mode = template.to_sym
100
+
101
+ yield self if block_given?
102
+
103
+ # After the block, which may reassign key/certificate (S-14, S-15).
104
+ check_key_material!
105
+ check_key_matches_certificate!
106
+
107
+ freeze
108
+ end
109
+
110
+ # Adds a Reference. Block form only -- see API_CONTRACT.md §5.2.
111
+ def reference(uri = "", **kwargs)
112
+ @references << HankoXml::Reference.new(uri: uri, **kwargs)
113
+ end
114
+
115
+ # With no args, the current KeyInfo clauses. With args, replaces them --
116
+ # block-form usage per API_CONTRACT.md §5.2 (`s.key_info :x509_certificate`,
117
+ # `s.key_info :key_name, name: "idp-1"`).
118
+ def key_info(*clauses, name: nil)
119
+ return @key_info if clauses.empty? && name.nil?
120
+
121
+ clauses = clauses.flatten
122
+ if name
123
+ unless clauses.any? { |c| c.to_s == "key_name" }
124
+ raise HankoXml::ConfigurationError, "key_info name: only applies to the :key_name clause"
125
+ end
126
+
127
+ clauses = clauses.map { |c| c.to_s == "key_name" ? { key_name: name } : c }
128
+ end
129
+ @key_info = HankoXml::KeyInfo.coerce(clauses)
130
+ end
131
+
132
+ # The unsigned <Signature> Nokogiri::XML::Element this Signer would
133
+ # build and insert, given its current configuration. Does not insert
134
+ # it into +doc_or_xml+ and performs no cryptography -- for inspection
135
+ # and testing (§5.2).
136
+ def template(doc_or_xml)
137
+ doc = HankoXml.coerce_document(doc_or_xml)
138
+ HankoXml::Template.build(doc, self)
139
+ end
140
+
141
+ def to_h
142
+ {
143
+ key: key, certificate: certificate, certificates: certificates,
144
+ signature_method: signature_method, canonicalization_method: canonicalization_method,
145
+ digest_method: digest_method, references: references, key_info: key_info,
146
+ placement: placement, id_attributes: id_attributes, namespaces: namespaces,
147
+ signature_id: signature_id, policy: policy, template: template_mode
148
+ }
149
+ end
150
+
151
+ # Signs a document without modifying the original.
152
+ # Returns a {SignedDocument} safe for serialization.
153
+ #
154
+ # Without Nokogiri it signs an existing <Signature> template in the
155
+ # bytes, through the native layer only, and the {SignedDocument} holds
156
+ # just the signed bytes (#102). Building a template needs a DOM.
157
+ #
158
+ # @param doc_or_xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The document to sign.
159
+ # @param force [Boolean] Whether to re-sign already signed signatures.
160
+ # @param signature [Integer, nil] The document-order index of the one
161
+ # <Signature> to sign (defaults to every unsigned one).
162
+ # @return [HankoXml::SignedDocument] The new signed document.
163
+ def sign(doc_or_xml, force: false, signature: nil)
164
+ node_signer = signer_for_node(doc_or_xml)
165
+ return node_signer.sign(doc_or_xml.document, force: force, signature: signature) if node_signer
166
+ return sign_bytes(doc_or_xml, force: force, signature: signature) unless Dom.available?
167
+
168
+ doc = HankoXml.coerce_document(doc_or_xml, policy: policy)
169
+ # Deep clone the document to avoid mutating the input if it's already a document.
170
+ cloned = doc.dup
171
+ sign!(cloned, force: force, signature: signature)
172
+ HankoXml::SignedDocument.new(cloned)
173
+ end
174
+
175
+ # Signs a Nokogiri XML document in place.
176
+ # Mutates the provided document by appending/writing back signature bytes.
177
+ #
178
+ # @param doc_or_xml [Nokogiri::XML::Node, Nokogiri::XML::Document] The document to mutate.
179
+ # @param force [Boolean] Whether to re-sign already signed signatures.
180
+ # @param signature [Integer, nil] The document-order index of the one
181
+ # <Signature> to sign (defaults to every unsigned one), matching
182
+ # {Verifier}'s `signature:` Integer index (S-11).
183
+ # @return [Nokogiri::XML::Node, Nokogiri::XML::Document] The mutated document.
184
+ def sign!(doc_or_xml, force: false, signature: nil)
185
+ node_signer = signer_for_node(doc_or_xml)
186
+ return node_signer.sign!(doc_or_xml.document, force: force, signature: signature) if node_signer
187
+
188
+ doc = HankoXml.coerce_document(doc_or_xml, policy: policy)
189
+ Dom.require!("HankoXml::Signer#sign!")
190
+
191
+ signatures, adopted = find_or_build_signatures(doc)
192
+ all_sigs_in_order = doc.xpath(".//ds:Signature", ds: NS["ds"])
193
+
194
+ signatures_to_sign = target_signatures(signatures, all_sigs_in_order, signature)
195
+ signatures_to_sign.reject! { |node| node_signed?(node) } unless force
196
+
197
+ return doc if signatures_to_sign.empty?
198
+
199
+ xml_bytes = HankoXml.serialize(doc)
200
+ enforce_document_and_key_size!(xml_bytes)
201
+
202
+ # Policy + reference-URI checks run on every template before any
203
+ # Native.sign (S-05). inspect_sig does not fetch; Native.sign would.
204
+ # Two loops on purpose: a later template must not be signed after an
205
+ # earlier one already passed, if a subsequent one is hostile.
206
+ signatures_to_sign.each do |sig_node|
207
+ index = all_sigs_in_order.index(sig_node)
208
+ enforce_before_native_sign!(doc, xml_bytes, index, sig_node: sig_node, adopted: adopted)
209
+ end
210
+
211
+ xml_bytes = prepare_key_infos!(doc, xml_bytes, signatures_to_sign, adopted: adopted)
212
+
213
+ signatures_to_sign.each do |sig_node|
214
+ index = all_sigs_in_order.index(sig_node)
215
+
216
+ xml_bytes = Native.sign(xml_bytes, @key.to_pem, certificate_pems, native_id_attributes, index)
217
+
218
+ write_back_signature(doc, xml_bytes, index, sig_node)
219
+ end
220
+
221
+ doc
222
+ end
223
+
224
+ def inspect
225
+ "#<#{self.class} key=[FILTERED] signature_method=#{signature_method.inspect} " \
226
+ "canonicalization_method=#{canonicalization_method.inspect} digest_method=#{digest_method.inspect} " \
227
+ "references=#{references.size}>"
228
+ end
229
+
230
+ private
231
+
232
+ # A certificate or public key as key: can only fail later inside xmlsec,
233
+ # and a key as certificate: would send private key PEM down the
234
+ # certificate path (S-15, F5/F7). Only checks what coercion produced;
235
+ # raw values assigned in the block are left to the native layer.
236
+ def check_key_material!
237
+ # A disposed Key raises KeyLoadError here rather than being misreported
238
+ # as the wrong kind of key below.
239
+ [key, certificate].grep(HankoXml::Key).each(&:to_openssl)
240
+
241
+ if key.is_a?(HankoXml::Key) && !key.private_key?
242
+ raise HankoXml::ConfigurationError, "key: must be a private key (got a #{key_kind(key)})"
243
+ end
244
+
245
+ check_key_type!
246
+ return unless certificate.is_a?(HankoXml::Key) && !certificate.certificate?
247
+
248
+ raise HankoXml::ConfigurationError, "certificate: must be an X.509 certificate (got a #{key_kind(certificate)})"
249
+ end
250
+
251
+ # No XML-DSig signature method here takes an Ed25519/Ed448 key, which
252
+ # otherwise failed as a NoMethodError from inside the library (#69).
253
+ def check_key_type!
254
+ pkey = key.is_a?(HankoXml::Key) ? key.to_openssl : key
255
+ return unless pkey.is_a?(::OpenSSL::PKey::PKey)
256
+ return if SIGNATURE_METHOD_FOR_KEY.key?(pkey.class)
257
+
258
+ raise HankoXml::ConfigurationError,
259
+ "key: is an unsupported #{pkey.oid} key; signing supports RSA, EC and DSA keys"
260
+ end
261
+
262
+ # xmlsec reports a key that does not fit the signature method only as
263
+ # "xmlSecDSigCtxSign failed" (#70). Checked against the method actually
264
+ # being signed, which for an adopted template is the template's.
265
+ def check_key_fits_signature_method!(sig_info)
266
+ name = HankoXml::Algorithms.name_for(:signature, sig_info.signature_method)
267
+ needed = SIGNATURE_KEY_TYPES[signature_key_kind(name)]
268
+ pkey = @key.to_openssl
269
+ return if needed.nil? || pkey.is_a?(needed)
270
+
271
+ raise HankoXml::ConfigurationError,
272
+ "signature method #{name.inspect} needs #{key_type_name(needed)} key, but key: is " \
273
+ "#{key_type_name(pkey.class)} key; use signature_method: #{SIGNATURE_METHOD_FOR_KEY[pkey.class].inspect}"
274
+ end
275
+
276
+ # With +signature+, the one <Signature> at that document-order index, to
277
+ # agree with Verifier's Integer index (S-11). Otherwise every signature,
278
+ # innermost first, so nested signatures are signed before the ones that
279
+ # envelope them.
280
+ def target_signatures(signatures, all_sigs_in_order, signature)
281
+ return signatures.sort_by { |node| -node.ancestors.size } unless signature
282
+
283
+ target = all_sigs_in_order[signature]
284
+ raise HankoXml::ConfigurationError, "signature: #{signature.inspect} not found" unless target
285
+
286
+ [target]
287
+ end
288
+
289
+ # A Node other than the root is the default reference target (API_CONTRACT.md
290
+ # §6.2, #96): with no references: of its own, the Signer signs "#<its Id>",
291
+ # exactly as if the caller had written references: ["#<its Id>"]. Returns
292
+ # that Signer, or nil when the input is a whole document, the root, or
293
+ # this Signer already names its references.
294
+ def signer_for_node(input)
295
+ return nil unless HankoXml::Dom.node?(input) && !HankoXml::Dom.document?(input)
296
+ return nil if input == input.document.root || !@references.empty?
297
+
298
+ id = @id_attributes.lazy.map { |attr| input[attr.is_a?(Array) ? attr[1].to_s : attr.to_s] }.find { |v| v }
299
+ unless id
300
+ raise HankoXml::ConfigurationError,
301
+ "a #{input.name} node was passed to sign, so it is the reference target, but it has no " \
302
+ "#{@id_attributes.inspect} attribute; give it one or pass references:"
303
+ end
304
+
305
+ self.class.new(**to_h.merge(references: ["##{id}"]))
306
+ end
307
+
308
+ # Built-in method names encode the key class in the prefix: rsa_sha256
309
+ # and rsa_pss_sha256 are RSA, ecdsa_sha256 and ecdsa_sha3_256 are EC.
310
+ # HMAC and custom registrations are left to xmlsec (needed is nil).
311
+ def signature_key_kind(name)
312
+ method = name.to_s
313
+ return "ecdsa" if method.start_with?("ecdsa")
314
+ return "rsa" if method.start_with?("rsa")
315
+ return "dsa" if method.start_with?("dsa")
316
+
317
+ nil
318
+ end
319
+
320
+ def key_type_name(klass)
321
+ type = klass.name.split("::").last
322
+ "#{type == "DSA" ? "a" : "an"} #{type}"
323
+ end
324
+
325
+ def key_kind(key)
326
+ return "certificate" if key.certificate?
327
+
328
+ key.private_key? ? "private key" : "public key"
329
+ end
330
+
331
+ # A key that does not belong to certificate: produces a signature that
332
+ # verifies against neither that certificate nor any other (S-14). Only
333
+ # checks a private key against an X.509 certificate; other shapes are
334
+ # left to Key coercion and the native layer.
335
+ def check_key_matches_certificate!
336
+ pkey = key.is_a?(HankoXml::Key) ? key.to_openssl : key
337
+ cert = certificate.is_a?(HankoXml::Key) ? certificate.to_openssl : certificate
338
+ return unless pkey.is_a?(::OpenSSL::PKey::PKey) && cert.is_a?(::OpenSSL::X509::Certificate)
339
+ return if cert.check_private_key(pkey)
340
+
341
+ raise HankoXml::ConfigurationError,
342
+ "key: does not match certificate: (subject #{cert.subject}); " \
343
+ "the signature would not verify against that certificate"
344
+ end
345
+
346
+ def find_or_build_signatures(doc)
347
+ existing = doc.xpath(".//ds:Signature", ds: NS["ds"]).to_a
348
+
349
+ if existing.empty?
350
+ raise HankoXml::ConfigurationError, "template: :require but no <Signature> found" if @template_mode == :require
351
+
352
+ # API_CONTRACT.md §5.5: detached builds a <Signature> but inserts
353
+ # nothing, so there is nothing in the document to sign. Without this
354
+ # the unplaced node fell through to a TypeError (#52 G6).
355
+ if @placement.detached?
356
+ raise HankoXml::ConfigurationError,
357
+ "placement: :detached inserts no <Signature>, so there is nothing to sign; use " \
358
+ "Signer#template(doc) and insert it yourself, or Placement.custom"
359
+ end
360
+
361
+ template_node = template(doc)
362
+ @placement.insert(doc, template_node, namespaces: @namespaces)
363
+ [[template_node], false]
364
+ else
365
+ if @template_mode == :build
366
+ raise HankoXml::ConfigurationError,
367
+ "template: :build but <Signature> already exists"
368
+ end
369
+
370
+ [existing, true]
371
+ end
372
+ end
373
+
374
+ # Before inspect_sig, which is itself a native parse (S-12).
375
+ def enforce_document_and_key_size!(xml_bytes)
376
+ @policy.enforce_document_size!(xml_bytes.bytesize)
377
+ @policy.enforce_key_size!([@key.to_openssl])
378
+ end
379
+
380
+ # Policy is a structural allowlist over inspect_sig output. Enforce it
381
+ # before Native.sign so a hostile adopted template cannot choose
382
+ # algorithms, external URIs, or XSLT (S-05). On :adopt/:require, the
383
+ # template's Reference URIs must also match this Signer's references
384
+ # (empty references means whole-document URI=""); that is the
385
+ # chosen-message opt-in. Returns inspect_sig's output.
386
+ def enforce_before_native_sign!(doc, xml_bytes, index, sig_node:, adopted:)
387
+ inspected = Native.inspect_sig(xml_bytes, index)
388
+ sig_info = HankoXml::SignatureInfo.from_native(inspected)
389
+ # require_signed_root is a verification-time XSW defence, not a
390
+ # constraint on what is legitimate to sign (see Policy#violations) --
391
+ # Signer deliberately does not check it, so a caller-chosen fragment
392
+ # signature (references:, R5) still signs under Policy.strict.
393
+ @policy.enforce!(sig_info, document: doc, sig_node: sig_node, id_attributes: @id_attributes,
394
+ xml_bytes: xml_bytes, check_root_signed: false)
395
+ ensure_adopted_references_match!(sig_info) if adopted
396
+ check_key_fits_signature_method!(sig_info)
397
+ inspected
398
+ end
399
+
400
+ # #sign without Nokogiri (#102): the same checks as #sign! on the same
401
+ # bytes, then Native.sign. Only an existing template can be signed, one
402
+ # per call, and only when its KeyInfo needs no DOM edits first.
403
+ def sign_bytes(source, force:, signature:)
404
+ input = HankoXml::Input.read(source, policy: policy)
405
+ bytes = input.bytes
406
+ index = bytes_target_index(input.scan(native_id_attributes), signature, force)
407
+ return HankoXml::SignedDocument.from_bytes(bytes) unless index
408
+
409
+ enforce_document_and_key_size!(bytes)
410
+ inspected = enforce_before_native_sign!(nil, bytes, index, sig_node: nil, adopted: true)
411
+ check_key_info_without_dom!(inspected)
412
+
413
+ HankoXml::SignedDocument.from_bytes(
414
+ Native.sign(bytes, @key.to_pem, certificate_pems, native_id_attributes, index)
415
+ )
416
+ end
417
+
418
+ # The document-order index of the template to sign, or nil when it is
419
+ # already signed and force: is false. Mirrors find_or_build_signatures
420
+ # and target_signatures, from Native.scan instead of a DOM.
421
+ def bytes_target_index(scan, signature, force)
422
+ signed = scan[:signature_values]
423
+ if signed.empty?
424
+ raise HankoXml::ConfigurationError, "template: :require but no <Signature> found" if @template_mode == :require
425
+
426
+ Dom.require!("Building a <Signature> template (template: #{@template_mode.inspect})")
427
+ end
428
+ raise HankoXml::ConfigurationError, "template: :build but <Signature> already exists" if @template_mode == :build
429
+
430
+ candidates = signature.nil? ? signed.each_index.to_a : [bytes_signature_index(signed.size, signature)]
431
+ candidates.reject! { |i| signed[i] } unless force
432
+ if candidates.size > 1
433
+ Dom.require!("Signing several <Signature> templates in one call (pass signature: to sign one at a time)")
434
+ end
435
+
436
+ candidates.first
437
+ end
438
+
439
+ def bytes_signature_index(count, signature)
440
+ unless signature.is_a?(Integer) && signature.between?(-count, count - 1)
441
+ raise HankoXml::ConfigurationError, "signature: #{signature.inspect} not found"
442
+ end
443
+
444
+ signature % count
445
+ end
446
+
447
+ # prepare_key_infos! edits KeyInfo in the DOM: it names an empty
448
+ # <KeyName> (#67) and empties stale key-derived leaves (#72). Without a
449
+ # DOM neither can happen, so such a template is refused rather than
450
+ # signed with an empty name or a stale certificate.
451
+ def check_key_info_without_dom!(inspected)
452
+ Dom.require!("Signing a template with an empty <KeyName>") if inspected[:empty_key_names]
453
+ return unless inspected[:key_derived_values]
454
+
455
+ Dom.require!("Signing a template whose <KeyInfo> already holds certificate or key values (#72)")
456
+ end
457
+
458
+ def certificate_pems
459
+ pems = @certificates.map(&:to_pem)
460
+ pems.unshift(@certificate.to_pem) if @certificate
461
+ pems
462
+ end
463
+
464
+ # Same mapping as Verifier#native_id_attributes: xmlSecAddIDs matches by
465
+ # local name only, so a [href, name] pair crosses the FFI as its name (#68).
466
+ def native_id_attributes
467
+ @id_attributes.map { |attr| attr.is_a?(Array) ? attr[1].to_s : attr.to_s }
468
+ end
469
+
470
+ # Readies each template's KeyInfo for xmlsec, and returns the bytes to
471
+ # sign. Runs only once every check passed, so a refused template leaves
472
+ # the caller's document untouched.
473
+ def prepare_key_infos!(doc, xml_bytes, signatures_to_sign, adopted:)
474
+ changed = signatures_to_sign.map do |sig_node|
475
+ key_info = sig_node.at_xpath("./ds:KeyInfo", ds: NS["ds"])
476
+ next false unless key_info
477
+
478
+ # Both run: | does not short-circuit.
479
+ name_empty_key_names!(key_info) | (adopted && empty_key_derived_leaves!(key_info))
480
+ end
481
+ changed.any? ? HankoXml.serialize(doc) : xml_bytes
482
+ end
483
+
484
+ # xmlsec writes the key's own name into <KeyName>, and a key loaded from
485
+ # PEM has none (#67). Returns whether anything was named.
486
+ def name_empty_key_names!(key_info)
487
+ empty = key_info.xpath("./ds:KeyName", ds: NS["ds"]).select { |node| node.content.strip.empty? }
488
+ return false if empty.empty?
489
+
490
+ key_name = @key_info.find { |c| c.name == :key_name && c.value }&.value
491
+ unless key_name
492
+ raise HankoXml::ConfigurationError,
493
+ "<KeyName> would be signed empty: a key has no name of its own; pass " \
494
+ "key_info: [{ key_name: \"...\" }] or, in the block form, s.key_info :key_name, name: \"...\""
495
+ end
496
+
497
+ empty.each { |node| node.content = key_name }
498
+ true
499
+ end
500
+
501
+ # xmlsec fills only empty key-derived leaves, so an adopted template's
502
+ # stale certificate stayed in the signed document (#72). Returns whether
503
+ # anything was emptied.
504
+ def empty_key_derived_leaves!(key_info)
505
+ stale = key_info.xpath(KEY_DERIVED_LEAVES, ds: NS["ds"]).reject { |leaf| leaf.children.empty? }
506
+ stale.each { |leaf| leaf.children.each(&:unlink) }
507
+ stale.any?
508
+ end
509
+
510
+ def ensure_adopted_references_match!(sig_info)
511
+ expected = effective_reference_uris
512
+ actual = sig_info.references.map(&:uri)
513
+ return if expected == actual
514
+
515
+ raise HankoXml::ConfigurationError,
516
+ "template: #{@template_mode} would sign reference URIs #{actual.inspect}, " \
517
+ "which do not match this Signer's references #{expected.inspect}"
518
+ end
519
+
520
+ def effective_reference_uris
521
+ refs = @references
522
+ refs = [HankoXml::Reference.new(uri: "")] if refs.empty?
523
+ refs.map(&:uri)
524
+ end
525
+
526
+ def node_signed?(sig_node)
527
+ val = sig_node.at_xpath("./ds:SignatureValue", ds: NS["ds"])
528
+ val && !val.content.strip.empty?
529
+ end
530
+
531
+ def write_back_signature(doc, signed_bytes, index, caller_sig_node)
532
+ # Parse the signed bytes
533
+ signed_doc = HankoXml.parse(signed_bytes)
534
+ signed_sigs = signed_doc.xpath(".//ds:Signature", ds: NS["ds"])
535
+ signed_sig_node = signed_sigs[index]
536
+
537
+ # Copy ds:Reference/ds:DigestValue
538
+ caller_refs = caller_sig_node.xpath(".//ds:Reference", ds: NS["ds"])
539
+ signed_refs = signed_sig_node.xpath(".//ds:Reference", ds: NS["ds"])
540
+
541
+ caller_refs.each_with_index do |caller_ref, i|
542
+ signed_ref = signed_refs[i]
543
+ caller_dv = caller_ref.at_xpath("./ds:DigestValue", ds: NS["ds"])
544
+ signed_dv = signed_ref.at_xpath("./ds:DigestValue", ds: NS["ds"])
545
+
546
+ caller_dv.content = signed_dv.content if caller_dv && signed_dv
547
+ end
548
+
549
+ # Copy ds:SignatureValue
550
+ caller_sv = caller_sig_node.at_xpath("./ds:SignatureValue", ds: NS["ds"])
551
+ signed_sv = signed_sig_node.at_xpath("./ds:SignatureValue", ds: NS["ds"])
552
+ caller_sv.content = signed_sv.content if caller_sv && signed_sv
553
+
554
+ write_back_key_info(doc, caller_sig_node, signed_sig_node)
555
+ end
556
+
557
+ # xmlsec fills KeyInfo from the key: certificates (possibly several, for
558
+ # a certificates: chain), <RSAKeyValue>/<ECKeyValue>, X509SubjectName and
559
+ # the rest. Copying individual leaves missed everything but
560
+ # X509Certificate, so the caller's document kept an empty <KeyValue/>
561
+ # that no longer matched what was signed (#52 E5/E6/E8). Mirror the
562
+ # signed KeyInfo's children wholesale instead.
563
+ def write_back_key_info(doc, caller_sig_node, signed_sig_node)
564
+ caller_key_info = caller_sig_node.at_xpath("./ds:KeyInfo", ds: NS["ds"])
565
+ signed_key_info = signed_sig_node.at_xpath("./ds:KeyInfo", ds: NS["ds"])
566
+ return unless caller_key_info && signed_key_info
567
+
568
+ caller_key_info.children.each(&:unlink)
569
+ signed_key_info.children.each { |child| caller_key_info.add_child(child.dup(1, doc)) }
570
+ end
571
+ end
572
+ end