moxml 0.1.25 → 0.2.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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +9 -0
  3. data/.github/workflows/windows.yml +62 -0
  4. data/Gemfile +3 -1
  5. data/README.adoc +133 -6
  6. data/docs/signature/algorithms.md +108 -0
  7. data/docs/signature/architecture.md +148 -0
  8. data/docs/signature/c14n.md +110 -0
  9. data/docs/signature/examples.md +109 -0
  10. data/docs/signature/flows.md +132 -0
  11. data/docs/signature/quick-reference.md +115 -0
  12. data/docs/signature/security.md +143 -0
  13. data/examples/signature/auto_key_extraction.rb +24 -0
  14. data/examples/signature/enveloped_rsa.rb +45 -0
  15. data/examples/signature/hmac.rb +33 -0
  16. data/lib/compat/opal/moxml_boot.rb +5 -0
  17. data/lib/moxml/adapter/base.rb +53 -49
  18. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  19. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  20. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  21. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  22. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  23. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  24. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  25. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  26. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  27. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  28. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  29. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  30. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  31. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  32. data/lib/moxml/adapter/customized_oga.rb +2 -0
  33. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  34. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  35. data/lib/moxml/adapter/headed_ox.rb +9 -128
  36. data/lib/moxml/adapter/leptris.rb +965 -0
  37. data/lib/moxml/adapter/libxml.rb +131 -99
  38. data/lib/moxml/adapter/nokogiri.rb +29 -8
  39. data/lib/moxml/adapter/oga.rb +91 -54
  40. data/lib/moxml/adapter/ox.rb +93 -137
  41. data/lib/moxml/adapter/rexml.rb +61 -50
  42. data/lib/moxml/adapter.rb +2 -1
  43. data/lib/moxml/attribute.rb +8 -4
  44. data/lib/moxml/attribute_resolver.rb +189 -0
  45. data/lib/moxml/c14n/attribute_handler.rb +71 -0
  46. data/lib/moxml/c14n/character_encoder.rb +29 -0
  47. data/lib/moxml/c14n/data_model.rb +145 -0
  48. data/lib/moxml/c14n/exclusive.rb +188 -0
  49. data/lib/moxml/c14n/inclusive_10.rb +42 -0
  50. data/lib/moxml/c14n/inclusive_11.rb +21 -0
  51. data/lib/moxml/c14n/namespace_context.rb +68 -0
  52. data/lib/moxml/c14n/namespace_handler.rb +71 -0
  53. data/lib/moxml/c14n/node.rb +42 -0
  54. data/lib/moxml/c14n/nodes/attribute_node.rb +50 -0
  55. data/lib/moxml/c14n/nodes/comment_node.rb +29 -0
  56. data/lib/moxml/c14n/nodes/element_node.rb +62 -0
  57. data/lib/moxml/c14n/nodes/namespace_node.rb +41 -0
  58. data/lib/moxml/c14n/nodes/processing_instruction_node.rb +30 -0
  59. data/lib/moxml/c14n/nodes/root_node.rb +22 -0
  60. data/lib/moxml/c14n/nodes/text_node.rb +29 -0
  61. data/lib/moxml/c14n/nodes.rb +17 -0
  62. data/lib/moxml/c14n/processor.rb +118 -0
  63. data/lib/moxml/c14n/writer.rb +121 -0
  64. data/lib/moxml/c14n/xml_base_handler.rb +140 -0
  65. data/lib/moxml/c14n.rb +96 -0
  66. data/lib/moxml/config.rb +29 -3
  67. data/lib/moxml/context.rb +12 -0
  68. data/lib/moxml/element.rb +29 -12
  69. data/lib/moxml/entity/reference.rb +28 -0
  70. data/lib/moxml/entity.rb +125 -0
  71. data/lib/moxml/node.rb +90 -1
  72. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  73. data/lib/moxml/signature/algorithms/base64_transform.rb +40 -0
  74. data/lib/moxml/signature/algorithms/canonicalization_base.rb +87 -0
  75. data/lib/moxml/signature/algorithms/digest_base.rb +51 -0
  76. data/lib/moxml/signature/algorithms/dsa_sha.rb +107 -0
  77. data/lib/moxml/signature/algorithms/ecdsa_sha.rb +125 -0
  78. data/lib/moxml/signature/algorithms/enveloped_signature_transform.rb +79 -0
  79. data/lib/moxml/signature/algorithms/exc_c14n_10.rb +23 -0
  80. data/lib/moxml/signature/algorithms/hmac_sha.rb +117 -0
  81. data/lib/moxml/signature/algorithms/inclusive_c14n_10.rb +21 -0
  82. data/lib/moxml/signature/algorithms/inclusive_c14n_11.rb +23 -0
  83. data/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb +56 -0
  84. data/lib/moxml/signature/algorithms/sha1.rb +13 -0
  85. data/lib/moxml/signature/algorithms/sha224.rb +13 -0
  86. data/lib/moxml/signature/algorithms/sha256.rb +13 -0
  87. data/lib/moxml/signature/algorithms/sha384.rb +13 -0
  88. data/lib/moxml/signature/algorithms/sha512.rb +13 -0
  89. data/lib/moxml/signature/algorithms/signature_method_base.rb +58 -0
  90. data/lib/moxml/signature/algorithms/transform_base.rb +40 -0
  91. data/lib/moxml/signature/algorithms.rb +113 -0
  92. data/lib/moxml/signature/errors.rb +99 -0
  93. data/lib/moxml/signature/key_extractor.rb +172 -0
  94. data/lib/moxml/signature/model/algorithm_method.rb +18 -0
  95. data/lib/moxml/signature/model/digest_method.rb +15 -0
  96. data/lib/moxml/signature/model/key/dsa_key_value.rb +28 -0
  97. data/lib/moxml/signature/model/key/ec_key_value.rb +22 -0
  98. data/lib/moxml/signature/model/key/rsa_key_value.rb +19 -0
  99. data/lib/moxml/signature/model/key/x509_data.rb +32 -0
  100. data/lib/moxml/signature/model/key/x509_digest.rb +19 -0
  101. data/lib/moxml/signature/model/key/x509_issuer_serial.rb +19 -0
  102. data/lib/moxml/signature/model/key_info.rb +21 -0
  103. data/lib/moxml/signature/model/key_value.rb +20 -0
  104. data/lib/moxml/signature/model/object_element.rb +18 -0
  105. data/lib/moxml/signature/model/reference.rb +22 -0
  106. data/lib/moxml/signature/model/signature.rb +21 -0
  107. data/lib/moxml/signature/model/signature_value.rb +16 -0
  108. data/lib/moxml/signature/model/signed_info.rb +20 -0
  109. data/lib/moxml/signature/model/transform.rb +16 -0
  110. data/lib/moxml/signature/model/transforms.rb +36 -0
  111. data/lib/moxml/signature/model.rb +35 -0
  112. data/lib/moxml/signature/parser.rb +270 -0
  113. data/lib/moxml/signature/reference_resolver.rb +79 -0
  114. data/lib/moxml/signature/reference_result.rb +22 -0
  115. data/lib/moxml/signature/serializer.rb +163 -0
  116. data/lib/moxml/signature/signer.rb +73 -0
  117. data/lib/moxml/signature/single_verification_result.rb +31 -0
  118. data/lib/moxml/signature/transform_pipeline.rb +106 -0
  119. data/lib/moxml/signature/verification_result.rb +27 -0
  120. data/lib/moxml/signature/verifier.rb +129 -0
  121. data/lib/moxml/signature.rb +94 -0
  122. data/lib/moxml/version.rb +1 -1
  123. data/lib/moxml/xml_emitter.rb +71 -0
  124. data/lib/moxml/xpath/compiler.rb +60 -7
  125. data/lib/moxml/xpath/parser.rb +22 -15
  126. data/lib/moxml.rb +5 -0
  127. data/reference-docs/w3c-xmldsig-bestpractices.md +216 -0
  128. data/reference-docs/w3c-xmldsig-core.md +400 -0
  129. data/spec/examples/readme_examples_spec.rb +0 -4
  130. data/spec/examples/xpath_examples_spec.rb +0 -11
  131. data/spec/fixtures/xmldsig/keys/rsa_private.pem +28 -0
  132. data/spec/fixtures/xmldsig/keys/rsa_public.pem +9 -0
  133. data/spec/fixtures/xmldsig/keys/rsa_ref.pem +15 -0
  134. data/spec/fixtures/xmldsig/keys/rsa_ref.pub +6 -0
  135. data/spec/fixtures/xmldsig/sign2-doc.xml +6 -0
  136. data/spec/fixtures/xmldsig/sign2-result.xml +25 -0
  137. data/spec/fixtures/xmldsig/sign3-result.xml +39 -0
  138. data/spec/integration/all_adapters_spec.rb +17 -0
  139. data/spec/integration/sax_parity_spec.rb +58 -0
  140. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  141. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  142. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  143. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  144. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
  145. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  146. data/spec/moxml/adapter/ox_spec.rb +20 -6
  147. data/spec/moxml/adapter/platform_spec.rb +15 -2
  148. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  149. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  150. data/spec/moxml/c14n/api_spec.rb +117 -0
  151. data/spec/moxml/c14n/c14n_spec.rb +88 -0
  152. data/spec/moxml/c14n/comments_pis_spec.rb +65 -0
  153. data/spec/moxml/c14n/inclusive10_spec.rb +60 -0
  154. data/spec/moxml/c14n/namespace_edge_cases_spec.rb +88 -0
  155. data/spec/moxml/c14n/xml_attributes_spec.rb +54 -0
  156. data/spec/moxml/doctype_spec.rb +1 -1
  157. data/spec/moxml/entity_spec.rb +76 -0
  158. data/spec/moxml/node_spec.rb +104 -0
  159. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  160. data/spec/moxml/signature/algorithms/base64_transform_spec.rb +26 -0
  161. data/spec/moxml/signature/algorithms/digest_base_spec.rb +72 -0
  162. data/spec/moxml/signature/algorithms/dsa_sha_spec.rb +46 -0
  163. data/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb +72 -0
  164. data/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb +40 -0
  165. data/spec/moxml/signature/algorithms/hmac_sha_spec.rb +71 -0
  166. data/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb +55 -0
  167. data/spec/moxml/signature/algorithms_spec.rb +76 -0
  168. data/spec/moxml/signature/cross_verify_spec.rb +64 -0
  169. data/spec/moxml/signature/edge_cases_spec.rb +284 -0
  170. data/spec/moxml/signature/fixtures_spec.rb +80 -0
  171. data/spec/moxml/signature/key_extractor_spec.rb +96 -0
  172. data/spec/moxml/signature/model_spec.rb +54 -0
  173. data/spec/moxml/signature/round_trip_spec.rb +113 -0
  174. data/spec/moxml/xml_emitter_spec.rb +64 -0
  175. data/spec/moxml/xpath/axes_spec.rb +72 -0
  176. data/spec/moxml/xpath/parser_spec.rb +6 -0
  177. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  178. data/spec/performance/benchmark_spec.rb +13 -6
  179. data/spec/performance/thread_safety_spec.rb +0 -4
  180. data/spec/spec_helper.rb +5 -1
  181. metadata +130 -2
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Entity-reference round-trip pipeline.
5
+ #
6
+ # XML libraries split into two camps: those that preserve named
7
+ # entity references natively (nokogiri) and those that expand or
8
+ # reject them at parse (ox, rexml, oga, libxml, leptris). Moxml
9
+ # bridges the camps with one pipeline, owned here and delegated to
10
+ # by Adapter::Base:
11
+ #
12
+ # 1. Parse time — .preprocess_entities rewrites non-standard
13
+ # entity references to a two-character marker (see MARKER) so
14
+ # the reference survives native parsing verbatim. The five
15
+ # standard entities (& < > " ') are NOT
16
+ # converted.
17
+ # 2. Read time — marker-bearing text is split into text and
18
+ # Entity::Reference nodes (adapters whose natives cannot hold
19
+ # references), or decoded in place (.decode_entities covers the
20
+ # five standard entities plus numeric character references).
21
+ # 3. Serialize time — .restore_entities maps markers back to named
22
+ # references, including markers a native serializer already
23
+ # rendered as character references.
24
+ #
25
+ # How references are STORED between parse and serialize is
26
+ # adapter-shaped (in-tree value objects for ox, attachment
27
+ # sequences for rexml/libxml, text markers for oga/leptris); the
28
+ # marker lifecycle and the Entity::Reference value type are the
29
+ # shared, single-sourced parts.
30
+ module Entity
31
+ autoload :Reference, "moxml/entity/reference"
32
+
33
+ # Marker for adapters that resolve entities during parsing.
34
+ # U+FFFC (Object Replacement Character) + U+FEFF (BOM) is a
35
+ # two-character sentinel chosen because this exact sequence
36
+ # followed by a valid entity name pattern is vanishingly unlikely
37
+ # in real XML content.
38
+ MARKER = "\u{FFFC}\u{FEFF}"
39
+ NAME_PATTERN = "[a-zA-Z_][\\w.:-]*"
40
+ NAME_RE = /&(#{NAME_PATTERN});/
41
+ MARKER_RE = /\u{FFFC}\u{FEFF}(#{NAME_PATTERN});/
42
+ SERIALIZED_MARKER_RE = /(#{NAME_PATTERN});/
43
+ STANDARD_ENTITIES = %w[amp lt gt quot apos].freeze
44
+
45
+ NAMED_DECODE_MAP = {
46
+ "amp" => "&", "lt" => "<", "gt" => ">",
47
+ "quot" => '"', "apos" => "'"
48
+ }.freeze
49
+ private_constant :NAMED_DECODE_MAP
50
+
51
+ DECODE_RE = /&(?:(amp|lt|gt|quot|apos)|#(?:(\d+)|[xX]([0-9a-fA-F]+)));/
52
+ private_constant :DECODE_RE
53
+
54
+ module_function
55
+
56
+ # Replace non-standard entity references with markers before
57
+ # parsing. Always returns a UTF-8 encoded string.
58
+ def preprocess_entities(xml)
59
+ return "" if xml.nil?
60
+
61
+ str = if xml.encoding == Encoding::BINARY
62
+ # Binary strings are assumed to be UTF-8. If the bytes are
63
+ # not valid UTF-8, fall back to encoding as UTF-8 with
64
+ # replacement to avoid raising on gsub.
65
+ dup = xml.dup.force_encoding("UTF-8")
66
+ if dup.valid_encoding?
67
+ dup
68
+ else
69
+ xml.dup.encode("UTF-8",
70
+ "ASCII-8BIT", invalid: :replace, undef: :replace)
71
+ end
72
+ elsif xml.encoding == Encoding::UTF_8
73
+ xml
74
+ else
75
+ xml.encode("UTF-8")
76
+ end
77
+ # Fast path: no `&` means no entity references to mark — skip
78
+ # the regex scan and string allocation entirely. The vast
79
+ # majority of XML payloads contain no entity references.
80
+ return str unless str.include?("&")
81
+
82
+ str.gsub(NAME_RE) do |match|
83
+ STANDARD_ENTITIES.include?(::Regexp.last_match(1)) ? match : "#{MARKER}#{::Regexp.last_match(1)};"
84
+ end
85
+ end
86
+
87
+ # Resolve numeric (&#NN; / &#xNN;) and the five standard named
88
+ # (&amp; &lt; &gt; &quot; &apos;) XML entity references in one
89
+ # single pass. The resulting characters are data; no further
90
+ # decoding is applied. Numeric refs producing invalid UTF-8
91
+ # (NUL, surrogate halves, > U+10FFFF) are preserved verbatim
92
+ # to avoid silently emitting malformed bytes.
93
+ def decode_entities(text)
94
+ return text unless text.is_a?(String) && text.include?("&")
95
+
96
+ text.gsub(DECODE_RE) do
97
+ if (named = ::Regexp.last_match(1))
98
+ NAMED_DECODE_MAP[named]
99
+ else
100
+ code = ::Regexp.last_match(2) ? ::Regexp.last_match(2).to_i : ::Regexp.last_match(3).to_i(16)
101
+ if code.zero? || code.between?(0xD800, 0xDFFF) || code > 0x10FFFF
102
+ ::Regexp.last_match(0)
103
+ else
104
+ [code].pack("U")
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Restore entity markers back to named entity references.
111
+ def restore_entities(text)
112
+ return text unless text.is_a?(String)
113
+
114
+ # Force UTF-8 encoding since markers are UTF-8 characters
115
+ str = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
116
+ # Fast path: the vast majority of documents carry no entity
117
+ # markers — two C-level scans beat two regex passes.
118
+ return str unless str.include?(MARKER) ||
119
+ str.include?("&#xFFFC;")
120
+
121
+ result = str.gsub(MARKER_RE, '&\1;')
122
+ result.gsub(SERIALIZED_MARKER_RE, '&\1;')
123
+ end
124
+ end
125
+ end
data/lib/moxml/node.rb CHANGED
@@ -55,6 +55,8 @@ module Moxml
55
55
  refreshed = adapter.actual_native(node.native, @native)
56
56
  node.refresh_native!(refreshed) if refreshed && refreshed != node.native
57
57
  node.parent_node = self
58
+ # The adopted subtree's in-scope namespaces changed
59
+ node.invalidate_namespace_cache!
58
60
  invalidate_children_cache!
59
61
  self
60
62
  end
@@ -77,9 +79,15 @@ module Moxml
77
79
  invalidate_parent_children_cache!
78
80
  adapter.remove(@native)
79
81
  invalidate_children_cache!
82
+ # The detached subtree left its declaring ancestors behind
83
+ invalidate_namespace_cache!
80
84
  self
81
85
  end
82
86
 
87
+ # Namespace-scope caches live on Element; the base no-op lets tree
88
+ # mutations invalidate uniformly without type checks.
89
+ def invalidate_namespace_cache!; end
90
+
83
91
  def replace(node)
84
92
  node = prepare_node(node)
85
93
  invalidate_parent_children_cache!
@@ -103,7 +111,10 @@ module Moxml
103
111
  end
104
112
 
105
113
  def xpath(expression, namespaces = {})
106
- NodeSet.new(adapter.xpath(@native, expression, namespaces), context)
114
+ result = adapter.xpath(@native, expression, namespaces)
115
+ # Adapter contract: Array<native> | scalar. Scalars (count(),
116
+ # string-length(), booleans) pass through unwrapped.
117
+ result.is_a?(Array) ? NodeSet.new(result, context) : result
107
118
  end
108
119
 
109
120
  def at_xpath(expression, namespaces = {})
@@ -186,6 +197,69 @@ module Moxml
186
197
  children.each(&block)
187
198
  end
188
199
 
200
+ # Returns all ancestor nodes from the parent up to and including
201
+ # the document node.
202
+ #
203
+ # @return [NodeSet] ancestors ordered nearest-first
204
+ def ancestors
205
+ return NodeSet.new([], context) if document?
206
+
207
+ natives = []
208
+ current = parent
209
+ while current
210
+ natives << current.native
211
+ break if current.document?
212
+
213
+ current = current.parent
214
+ end
215
+ NodeSet.new(natives, context)
216
+ end
217
+
218
+ # Returns all descendant nodes (children, grandchildren, and so on),
219
+ # excluding the node itself.
220
+ #
221
+ # @return [NodeSet] descendants in document order
222
+ def descendants
223
+ natives = []
224
+ each_node { |node| natives << node.native }
225
+ NodeSet.new(natives, context)
226
+ end
227
+
228
+ # Returns an XPath expression that uniquely locates this node within
229
+ # its document. Positional predicates are emitted only when sibling
230
+ # elements share the same qualified name, keeping paths minimal.
231
+ #
232
+ # @return [String] XPath expression
233
+ # @raise [Moxml::NotImplementedError] for node types other than
234
+ # element and document
235
+ def path
236
+ return "/" if document?
237
+
238
+ unless element?
239
+ raise Moxml::NotImplementedError.new(
240
+ "path is only supported for element and document nodes",
241
+ feature: "path",
242
+ )
243
+ end
244
+
245
+ segments = []
246
+ current = self
247
+ while current && !current.document?
248
+ segments.unshift(path_segment_for(current))
249
+ current = current.parent
250
+ end
251
+ "/#{segments.join('/')}"
252
+ end
253
+
254
+ # Returns the 1-based line number where this node appears in the
255
+ # source XML, or nil when the underlying adapter does not track
256
+ # source positions.
257
+ #
258
+ # @return [Integer, nil]
259
+ def line_number
260
+ adapter.line_number(@native)
261
+ end
262
+
189
263
  def outer_xml
190
264
  to_xml
191
265
  end
@@ -286,6 +360,21 @@ module Moxml
286
360
 
287
361
  private
288
362
 
363
+ # XPath segment for an element: the qualified name, plus a positional
364
+ # predicate only when same-named element siblings make it ambiguous.
365
+ def path_segment_for(element)
366
+ name = element.name
367
+ parent = element.parent
368
+ return name unless parent
369
+
370
+ same_name = parent.children.select do |child|
371
+ child.element? && child.name == name
372
+ end
373
+ return name if same_name.size == 1
374
+
375
+ "#{name}[#{same_name.find_index(element) + 1}]"
376
+ end
377
+
289
378
  def prepare_node(node)
290
379
  case node
291
380
  when String then Text.new(adapter.create_text(node), context)
@@ -9,6 +9,8 @@ module Moxml
9
9
  # attribute. This module provides a single implementation.
10
10
  module NamespaceSplitter
11
11
  # @param attributes [Hash, Array<Array>] attributes as a hash or array of pairs
12
+ # @yieldparam value [Object] raw attribute/namespace value
13
+ # @yieldreturn [Object] transformed value to store
12
14
  # @return [Array(Hash, Hash)] [regular_attrs, namespaces]
13
15
  def split_attributes_and_namespaces(attributes)
14
16
  attrs = {}
@@ -16,11 +18,12 @@ module Moxml
16
18
 
17
19
  each_attribute(attributes) do |name, value|
18
20
  name_s = name.to_s
21
+ v = block_given? ? yield(value) : value
19
22
  if name_s == "xmlns" || name_s.start_with?("xmlns:")
20
23
  prefix = name_s == "xmlns" ? nil : name_s.sub("xmlns:", "")
21
- ns[prefix] = value
24
+ ns[prefix] = v
22
25
  else
23
- attrs[name_s] = value
26
+ attrs[name_s] = v
24
27
  end
25
28
  end
26
29
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Moxml
6
+ module Signature
7
+ module Algorithms
8
+ # Base64 decode transform (W3C §6.6.2).
9
+ #
10
+ # Input: octets or nodeset. For nodeset, logically applies self::text(),
11
+ # sorts nodes by document order, concatenates string values, then decodes.
12
+ # Output: octets.
13
+ class Base64Transform < TransformBase
14
+ identifier "http://www.w3.org/2000/09/xmldsig#base64"
15
+
16
+ def self.input_type; :octets; end
17
+ def self.output_type; :octets; end
18
+
19
+ def transform(input)
20
+ text = input.is_a?(Array) ? input.map { |n| text_of(n) }.join : input.to_s
21
+ stripped = text.gsub(/\s+/, "")
22
+ Base64.strict_decode64(stripped)
23
+ rescue ArgumentError => e
24
+ raise TransformError.new(
25
+ "base64 decode failed: #{e.message}",
26
+ algorithm: self.class.identifier_uri,
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def text_of(node)
33
+ return node.text if node.is_a?(::Moxml::Node)
34
+
35
+ node.to_s
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Moxml
6
+ module Signature
7
+ module Algorithms
8
+ # Base class for canonicalization algorithms.
9
+ #
10
+ # Subclasses declare `identifier "http://..."` and implement #engine
11
+ # (returning a Moxml::C14n::* walker). Canonicalizers operate on a
12
+ # Moxml::Node subtree and return UTF-8 octets.
13
+ #
14
+ # Canonicalization algorithms can also be used as transforms per
15
+ # spec §6.6.1. The base class provides the #transform method that
16
+ # adapts the canonicalize interface to the transform pipeline.
17
+ class CanonicalizationBase
18
+ class << self
19
+ # Canonicalization presents the transform interface (spec §6.6.1).
20
+ # Input: octet stream or node-set. Output: octet stream.
21
+ def input_type; :nodeset; end
22
+
23
+ def output_type; :octets; end
24
+
25
+ def identifier(uri)
26
+ Algorithms.register(:canonicalization, uri, self)
27
+ end
28
+
29
+ def for_uri(uri, **opts)
30
+ new(identifier_uri: uri, **opts)
31
+ end
32
+ end
33
+
34
+ attr_reader :identifier_uri, :with_comments, :inclusive_namespaces,
35
+ :context
36
+
37
+ # `context:` is required when this algorithm is used as a transform
38
+ # so that octet-stream input can be parsed with the same adapter
39
+ # the caller used for the rest of the document.
40
+ def initialize(identifier_uri: nil, with_comments: nil,
41
+ inclusive_namespaces: [], context: nil, **_unused)
42
+ @identifier_uri = identifier_uri
43
+ @with_comments = with_comments.nil? ? uri_has_comments?(identifier_uri) : with_comments
44
+ @inclusive_namespaces = inclusive_namespaces || []
45
+ @context = context
46
+ end
47
+
48
+ def canonicalize(node)
49
+ engine.canonicalize(
50
+ node,
51
+ with_comments: @with_comments,
52
+ inclusive_namespaces: @inclusive_namespaces,
53
+ )
54
+ end
55
+
56
+ # Adapt to the transform interface (spec §6.6.1). Octet-stream
57
+ # input is parsed using the same Moxml::Context the caller used;
58
+ # this preserves the byte-exact invariant across adapters.
59
+ def transform(input)
60
+ return canonicalize(input) if input.is_a?(::Moxml::Node)
61
+
62
+ if context.nil?
63
+ raise TransformError,
64
+ "canonicalization transform requires a context to parse " \
65
+ "octet-stream input; none was provided"
66
+ end
67
+
68
+ parsed = context.parse(input.to_s)
69
+ canonicalize(parsed.root)
70
+ end
71
+
72
+ private
73
+
74
+ def uri_has_comments?(uri)
75
+ return false if uri.nil?
76
+
77
+ uri.end_with?("#WithComments")
78
+ end
79
+
80
+ def engine
81
+ raise NotImplementedError,
82
+ "#{self.class} must implement #engine"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module Moxml
7
+ module Signature
8
+ module Algorithms
9
+ # Base class for message digest algorithms (SHA family).
10
+ #
11
+ # Subclasses MUST declare:
12
+ # - `identifier "http://..."` to register
13
+ # - either `digest_bits N` and `openssl_digest_name "SHA256"` etc.,
14
+ # OR override `#compute_digest`.
15
+ class DigestBase
16
+ class << self
17
+ attr_reader :identifier_uri, :digest_size_bits
18
+
19
+ def identifier(uri)
20
+ @identifier_uri = uri
21
+ Algorithms.register(:digest, uri, self)
22
+ end
23
+
24
+ def digest_bits(bits)
25
+ @digest_size_bits = bits
26
+ end
27
+
28
+ def openssl_digest_name(name)
29
+ define_method(:compute_digest) do |data|
30
+ OpenSSL::Digest.digest(name, data)
31
+ end
32
+ end
33
+ end
34
+
35
+ def digest(data)
36
+ compute_digest(data.to_s)
37
+ end
38
+
39
+ def digest_base64(data)
40
+ Base64.strict_encode64(digest(data))
41
+ end
42
+
43
+ def compute_digest(_data)
44
+ raise NotImplementedError,
45
+ "#{self.class} must implement #compute_digest " \
46
+ "or declare openssl_digest_name"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Moxml
6
+ module Signature
7
+ module Algorithms
8
+ # DSA signature methods per FIPS 186-3.
9
+ #
10
+ # Two URIs:
11
+ # - SHA1 (1024-bit, q=160): wire format r‖s with 20-byte halves
12
+ # - SHA256 (2048-bit, q=256): wire format r‖s with N-byte halves
13
+ # where N = byte length of q (32 for SHA-256 case)
14
+ #
15
+ # OpenSSL returns DER; we convert to raw r‖s.
16
+ class DsaSha < SignatureMethodBase
17
+ PAIRINGS = {
18
+ "http://www.w3.org/2000/09/xmldsig#dsa-sha1" => "SHA1",
19
+ "http://www.w3.org/2009/xmldsig11#dsa-sha256" => "SHA256",
20
+ }.freeze
21
+
22
+ PAIRINGS.each_key { |uri| identifier uri }
23
+
24
+ def initialize(identifier_uri:, parameters: nil)
25
+ super(nil)
26
+ @identifier_uri = identifier_uri
27
+ @parameters = parameters
28
+ end
29
+
30
+ def compute_signature(data, key)
31
+ dsa = coerce_signing_key(key)
32
+ digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
33
+ der_signature = dsa.sign(digest_name, data)
34
+ der_to_raw(der_signature, coordinate_bytes_for(dsa))
35
+ end
36
+
37
+ def verify_signature(data, key, signature)
38
+ dsa = coerce_verify_key(key)
39
+ digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
40
+ n_bytes = coordinate_bytes_for(dsa)
41
+ der_signature = raw_to_der(signature, n_bytes)
42
+ dsa.verify(digest_name, der_signature, data)
43
+ rescue ArgumentError
44
+ false
45
+ end
46
+
47
+ private
48
+
49
+ def coerce_signing_key(key)
50
+ case key
51
+ when OpenSSL::PKey::DSA
52
+ key
53
+ else
54
+ raise SignatureKeyError,
55
+ "DSA signature method requires OpenSSL::PKey::DSA, " \
56
+ "got #{key.class}"
57
+ end
58
+ end
59
+
60
+ def coerce_verify_key(key)
61
+ coerce_signing_key(key)
62
+ end
63
+
64
+ def coordinate_bytes_for(dsa)
65
+ # q is the subgroup order. byte length of q.
66
+ q_bits = dsa.q.num_bits
67
+ (q_bits + 7) / 8
68
+ end
69
+
70
+ def der_to_raw(der, n_bytes)
71
+ seq = OpenSSL::ASN1.decode(der)
72
+ r = seq.value[0].value
73
+ s = seq.value[1].value
74
+ i2osp(r, n_bytes) + i2osp(s, n_bytes)
75
+ end
76
+
77
+ def raw_to_der(raw, n_bytes)
78
+ raise ArgumentError, "raw signature has wrong size" if raw.bytesize != (2 * n_bytes)
79
+
80
+ r = raw.byteslice(0, n_bytes)
81
+ s = raw.byteslice(n_bytes, n_bytes)
82
+ seq = OpenSSL::ASN1::Sequence.new([
83
+ OpenSSL::ASN1::Integer.new(asn1_integer_value(r)),
84
+ OpenSSL::ASN1::Integer.new(asn1_integer_value(s)),
85
+ ])
86
+ seq.to_der
87
+ end
88
+
89
+ def i2osp(value, length)
90
+ n = value.to_i
91
+ raise ArgumentError, "I2OSP: integer too large" if n >= (1 << (8 * length))
92
+
93
+ bytes = Array.new(length, 0)
94
+ (length - 1).downto(0) do |i|
95
+ bytes[i] = n & 0xff
96
+ n >>= 8
97
+ end
98
+ bytes.pack("C*")
99
+ end
100
+
101
+ def asn1_integer_value(octets)
102
+ octets.bytes.reduce(0) { |acc, byte| (acc << 8) | byte }
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Moxml
6
+ module Signature
7
+ module Algorithms
8
+ # ECDSA signature methods per FIPS 186-3, bound to a digest.
9
+ #
10
+ # Spec §6.4.3 requires support for ECDSAwithSHA256 over the P-256
11
+ # curve. Recommended: P-384, P-521.
12
+ #
13
+ # The wire format is base64(I2OSP(r, n_bytes) || I2OSP(s, n_bytes))
14
+ # where n_bytes is the byte length of the base point order
15
+ # (32 for P-256, 48 for P-384, 66 for P-521).
16
+ #
17
+ # OpenSSL returns a DER-encoded ASN.1 sequence of (r, s). We convert
18
+ # to the raw r||s form on sign, and back from raw to DER on verify.
19
+ class EcdsaSha < SignatureMethodBase
20
+ PAIRINGS = {
21
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1" => "SHA1",
22
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224" => "SHA224",
23
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" => "SHA256",
24
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" => "SHA384",
25
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512" => "SHA512",
26
+ }.freeze
27
+
28
+ # Map OpenSSL curve name → byte length of base point order.
29
+ CURVE_ORDER_BYTES = {
30
+ "prime256v1" => 32, # P-256
31
+ "secp384r1" => 48, # P-384
32
+ "secp521r1" => 66, # P-521 (ceil(521/8) = 66)
33
+ }.freeze
34
+
35
+ PAIRINGS.each_key { |uri| identifier uri }
36
+
37
+ def initialize(identifier_uri:, parameters: nil)
38
+ super(nil)
39
+ @identifier_uri = identifier_uri
40
+ @parameters = parameters
41
+ end
42
+
43
+ def compute_signature(data, key)
44
+ ec_key = coerce_key(key)
45
+ digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
46
+ der_signature = ec_key.sign(digest_name, data)
47
+ der_to_raw(der_signature, coordinate_bytes_for(ec_key))
48
+ end
49
+
50
+ def verify_signature(data, key, signature)
51
+ ec_key = coerce_key(key)
52
+ digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
53
+ n_bytes = coordinate_bytes_for(ec_key)
54
+ der_signature = raw_to_der(signature, n_bytes)
55
+ ec_key.verify(digest_name, der_signature, data)
56
+ rescue ArgumentError
57
+ false
58
+ end
59
+
60
+ private
61
+
62
+ def coerce_key(key)
63
+ case key
64
+ when OpenSSL::PKey::EC then key
65
+ when OpenSSL::PKey::EC::Point
66
+ raise SignatureKeyError,
67
+ "ECDSA requires an EC key, not a bare Point"
68
+ else
69
+ raise SignatureKeyError,
70
+ "ECDSA signature method requires OpenSSL::PKey::EC, " \
71
+ "got #{key.class}"
72
+ end
73
+ end
74
+
75
+ def coordinate_bytes_for(ec_key)
76
+ curve_name = ec_key.group.curve_name
77
+ self.class::CURVE_ORDER_BYTES.fetch(curve_name) do
78
+ raise SignatureError,
79
+ "ECDSA curve #{curve_name.inspect} not supported; " \
80
+ "add it to EcdsaSha::CURVE_ORDER_BYTES"
81
+ end
82
+ end
83
+
84
+ # Convert ASN.1 DER sequence of two INTEGERs to raw r‖s octets.
85
+ def der_to_raw(der, n_bytes)
86
+ seq = OpenSSL::ASN1.decode(der)
87
+ r = seq.value[0].value
88
+ s = seq.value[1].value
89
+ i2osp(r, n_bytes) + i2osp(s, n_bytes)
90
+ end
91
+
92
+ # Convert raw r‖s octets back to ASN.1 DER.
93
+ def raw_to_der(raw, n_bytes)
94
+ raise ArgumentError, "raw signature has wrong size" if raw.bytesize != (2 * n_bytes)
95
+
96
+ r = raw.byteslice(0, n_bytes)
97
+ s = raw.byteslice(n_bytes, n_bytes)
98
+ seq = OpenSSL::ASN1::Sequence.new([
99
+ OpenSSL::ASN1::Integer.new(asn1_integer_value(r)),
100
+ OpenSSL::ASN1::Integer.new(asn1_integer_value(s)),
101
+ ])
102
+ seq.to_der
103
+ end
104
+
105
+ # Integer to octet stream (I2OSP), minimal length parameter.
106
+ def i2osp(value, length)
107
+ n = value.to_i
108
+ raise ArgumentError, "I2OSP: integer too large" if n >= (1 << (8 * length))
109
+
110
+ bytes = Array.new(length, 0)
111
+ (length - 1).downto(0) do |i|
112
+ bytes[i] = n & 0xff
113
+ n >>= 8
114
+ end
115
+ bytes.pack("C*")
116
+ end
117
+
118
+ # Interpret an octet string as a positive integer (OS2IP).
119
+ def asn1_integer_value(octets)
120
+ octets.bytes.reduce(0) { |acc, byte| (acc << 8) | byte }
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end