moxml 0.1.25 → 0.1.26

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +9 -0
  3. data/README.adoc +64 -0
  4. data/docs/signature/algorithms.md +108 -0
  5. data/docs/signature/architecture.md +148 -0
  6. data/docs/signature/c14n.md +110 -0
  7. data/docs/signature/examples.md +109 -0
  8. data/docs/signature/flows.md +132 -0
  9. data/docs/signature/quick-reference.md +115 -0
  10. data/docs/signature/security.md +143 -0
  11. data/examples/signature/auto_key_extraction.rb +24 -0
  12. data/examples/signature/enveloped_rsa.rb +45 -0
  13. data/examples/signature/hmac.rb +33 -0
  14. data/lib/moxml/c14n/attribute_handler.rb +71 -0
  15. data/lib/moxml/c14n/character_encoder.rb +29 -0
  16. data/lib/moxml/c14n/data_model.rb +145 -0
  17. data/lib/moxml/c14n/exclusive.rb +188 -0
  18. data/lib/moxml/c14n/inclusive_10.rb +42 -0
  19. data/lib/moxml/c14n/inclusive_11.rb +21 -0
  20. data/lib/moxml/c14n/namespace_context.rb +68 -0
  21. data/lib/moxml/c14n/namespace_handler.rb +71 -0
  22. data/lib/moxml/c14n/node.rb +42 -0
  23. data/lib/moxml/c14n/nodes/attribute_node.rb +50 -0
  24. data/lib/moxml/c14n/nodes/comment_node.rb +29 -0
  25. data/lib/moxml/c14n/nodes/element_node.rb +62 -0
  26. data/lib/moxml/c14n/nodes/namespace_node.rb +41 -0
  27. data/lib/moxml/c14n/nodes/processing_instruction_node.rb +30 -0
  28. data/lib/moxml/c14n/nodes/root_node.rb +22 -0
  29. data/lib/moxml/c14n/nodes/text_node.rb +29 -0
  30. data/lib/moxml/c14n/nodes.rb +17 -0
  31. data/lib/moxml/c14n/processor.rb +118 -0
  32. data/lib/moxml/c14n/writer.rb +121 -0
  33. data/lib/moxml/c14n/xml_base_handler.rb +140 -0
  34. data/lib/moxml/c14n.rb +96 -0
  35. data/lib/moxml/signature/algorithms/base64_transform.rb +40 -0
  36. data/lib/moxml/signature/algorithms/canonicalization_base.rb +87 -0
  37. data/lib/moxml/signature/algorithms/digest_base.rb +51 -0
  38. data/lib/moxml/signature/algorithms/dsa_sha.rb +107 -0
  39. data/lib/moxml/signature/algorithms/ecdsa_sha.rb +125 -0
  40. data/lib/moxml/signature/algorithms/enveloped_signature_transform.rb +79 -0
  41. data/lib/moxml/signature/algorithms/exc_c14n_10.rb +23 -0
  42. data/lib/moxml/signature/algorithms/hmac_sha.rb +117 -0
  43. data/lib/moxml/signature/algorithms/inclusive_c14n_10.rb +21 -0
  44. data/lib/moxml/signature/algorithms/inclusive_c14n_11.rb +23 -0
  45. data/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb +56 -0
  46. data/lib/moxml/signature/algorithms/sha1.rb +13 -0
  47. data/lib/moxml/signature/algorithms/sha224.rb +13 -0
  48. data/lib/moxml/signature/algorithms/sha256.rb +13 -0
  49. data/lib/moxml/signature/algorithms/sha384.rb +13 -0
  50. data/lib/moxml/signature/algorithms/sha512.rb +13 -0
  51. data/lib/moxml/signature/algorithms/signature_method_base.rb +58 -0
  52. data/lib/moxml/signature/algorithms/transform_base.rb +40 -0
  53. data/lib/moxml/signature/algorithms.rb +113 -0
  54. data/lib/moxml/signature/errors.rb +99 -0
  55. data/lib/moxml/signature/key_extractor.rb +172 -0
  56. data/lib/moxml/signature/model/algorithm_method.rb +18 -0
  57. data/lib/moxml/signature/model/digest_method.rb +15 -0
  58. data/lib/moxml/signature/model/key/dsa_key_value.rb +29 -0
  59. data/lib/moxml/signature/model/key/ec_key_value.rb +22 -0
  60. data/lib/moxml/signature/model/key/rsa_key_value.rb +19 -0
  61. data/lib/moxml/signature/model/key/x509_data.rb +32 -0
  62. data/lib/moxml/signature/model/key/x509_digest.rb +19 -0
  63. data/lib/moxml/signature/model/key/x509_issuer_serial.rb +19 -0
  64. data/lib/moxml/signature/model/key_info.rb +21 -0
  65. data/lib/moxml/signature/model/key_value.rb +20 -0
  66. data/lib/moxml/signature/model/object_element.rb +18 -0
  67. data/lib/moxml/signature/model/reference.rb +22 -0
  68. data/lib/moxml/signature/model/signature.rb +21 -0
  69. data/lib/moxml/signature/model/signature_value.rb +16 -0
  70. data/lib/moxml/signature/model/signed_info.rb +20 -0
  71. data/lib/moxml/signature/model/transform.rb +16 -0
  72. data/lib/moxml/signature/model/transforms.rb +36 -0
  73. data/lib/moxml/signature/model.rb +35 -0
  74. data/lib/moxml/signature/parser.rb +270 -0
  75. data/lib/moxml/signature/reference_resolver.rb +79 -0
  76. data/lib/moxml/signature/reference_result.rb +22 -0
  77. data/lib/moxml/signature/serializer.rb +163 -0
  78. data/lib/moxml/signature/signer.rb +73 -0
  79. data/lib/moxml/signature/single_verification_result.rb +31 -0
  80. data/lib/moxml/signature/transform_pipeline.rb +106 -0
  81. data/lib/moxml/signature/verification_result.rb +27 -0
  82. data/lib/moxml/signature/verifier.rb +129 -0
  83. data/lib/moxml/signature.rb +94 -0
  84. data/lib/moxml/version.rb +1 -1
  85. data/lib/moxml.rb +2 -0
  86. data/reference-docs/w3c-xmldsig-bestpractices.md +216 -0
  87. data/reference-docs/w3c-xmldsig-core.md +400 -0
  88. data/spec/fixtures/xmldsig/keys/rsa_private.pem +28 -0
  89. data/spec/fixtures/xmldsig/keys/rsa_public.pem +9 -0
  90. data/spec/fixtures/xmldsig/keys/rsa_ref.pem +15 -0
  91. data/spec/fixtures/xmldsig/keys/rsa_ref.pub +6 -0
  92. data/spec/fixtures/xmldsig/sign2-doc.xml +6 -0
  93. data/spec/fixtures/xmldsig/sign2-result.xml +25 -0
  94. data/spec/fixtures/xmldsig/sign3-result.xml +39 -0
  95. data/spec/moxml/c14n/api_spec.rb +117 -0
  96. data/spec/moxml/c14n/c14n_spec.rb +88 -0
  97. data/spec/moxml/c14n/comments_pis_spec.rb +65 -0
  98. data/spec/moxml/c14n/inclusive10_spec.rb +60 -0
  99. data/spec/moxml/c14n/namespace_edge_cases_spec.rb +88 -0
  100. data/spec/moxml/c14n/xml_attributes_spec.rb +54 -0
  101. data/spec/moxml/signature/algorithms/base64_transform_spec.rb +26 -0
  102. data/spec/moxml/signature/algorithms/digest_base_spec.rb +72 -0
  103. data/spec/moxml/signature/algorithms/dsa_sha_spec.rb +46 -0
  104. data/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb +72 -0
  105. data/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb +40 -0
  106. data/spec/moxml/signature/algorithms/hmac_sha_spec.rb +71 -0
  107. data/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb +55 -0
  108. data/spec/moxml/signature/algorithms_spec.rb +76 -0
  109. data/spec/moxml/signature/cross_verify_spec.rb +64 -0
  110. data/spec/moxml/signature/edge_cases_spec.rb +284 -0
  111. data/spec/moxml/signature/fixtures_spec.rb +80 -0
  112. data/spec/moxml/signature/key_extractor_spec.rb +96 -0
  113. data/spec/moxml/signature/model_spec.rb +54 -0
  114. data/spec/moxml/signature/round_trip_spec.rb +113 -0
  115. metadata +111 -2
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Translates a Reference URI to a node (subtree) or octet string ready
6
+ # for the transform pipeline.
7
+ #
8
+ # Only same-document references and bare-octet inputs are handled in
9
+ # this tier. External URI dereferencing is documented in
10
+ # TODO.complete/11.
11
+ class ReferenceResolver
12
+ attr_reader :context, :document
13
+
14
+ def initialize(context:, document:)
15
+ @context = context
16
+ @document = document
17
+ end
18
+
19
+ # Returns:
20
+ # - Moxml::Node for same-document references (subtree apex)
21
+ # - String (octets) for octet inputs
22
+ def resolve(uri)
23
+ case uri.to_s
24
+ when ""
25
+ document.root
26
+ when /\A#/
27
+ resolve_fragment(uri[1..])
28
+ else
29
+ uri.to_s
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_fragment(fragment)
36
+ case fragment
37
+ when /\Axpointer\(\/\)\z/
38
+ document.root
39
+ when /\Axpointer\(id\(['"]([^'"]+)['"]\)\)\z/
40
+ find_by_id(::Regexp.last_match(1))
41
+ else
42
+ find_by_id(fragment)
43
+ end
44
+ end
45
+
46
+ def find_by_id(id)
47
+ candidate = search_by_xml_id(id) || search_by_attribute_id(id)
48
+ unless candidate
49
+ raise MalformedSignatureError.new(
50
+ "Reference URI ##{id.inspect} does not resolve to any element",
51
+ )
52
+ end
53
+ candidate
54
+ end
55
+
56
+ def search_by_xml_id(id)
57
+ result = document.at_xpath("//*[@xml:id='#{xpath_escape(id)}']")
58
+ return nil unless result
59
+ return nil unless result.is_a?(::Moxml::Element)
60
+
61
+ result
62
+ end
63
+
64
+ def search_by_attribute_id(id)
65
+ matches = document
66
+ .xpath("//*[@Id='#{xpath_escape(id)}'] | " \
67
+ "//*[@ID='#{xpath_escape(id)}']")
68
+ .grep(::Moxml::Element)
69
+ return nil if matches.empty?
70
+
71
+ matches.first
72
+ end
73
+
74
+ def xpath_escape(value)
75
+ value.to_s.gsub("'", "''")
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Per-reference digest comparison result. `valid?` is true iff the
6
+ # freshly-computed digest matches the expected DigestValue.
7
+ class ReferenceResult
8
+ attr_reader :uri, :digest_method, :expected, :computed
9
+
10
+ def initialize(uri:, digest_method:, expected:, computed:)
11
+ @uri = uri
12
+ @digest_method = digest_method
13
+ @expected = expected
14
+ @computed = computed
15
+ end
16
+
17
+ def valid?
18
+ expected == computed
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Moxml
6
+ module Signature
7
+ # Translates a Model::Signature into a Moxml::Document.
8
+ #
9
+ # All XML construction goes through moxml primitives
10
+ # (create_element, []=, add_child, create_text). No string templates,
11
+ # no manual attribute escaping — moxml handles entity encoding
12
+ # correctly per the XML spec.
13
+ class Serializer
14
+ DS_PREFIX = "ds"
15
+
16
+ attr_reader :context
17
+
18
+ def initialize(context:)
19
+ @context = context
20
+ end
21
+
22
+ def serialize(signature)
23
+ doc = context.create_document
24
+ root = create_namespaced_element("Signature", doc)
25
+ root["Id"] = signature.id if signature.id
26
+ doc.root = root
27
+
28
+ if signature.signed_info
29
+ root.add_child(build_signed_info(signature.signed_info, doc))
30
+ end
31
+ if signature.signature_value
32
+ root.add_child(build_signature_value(signature.signature_value, doc))
33
+ end
34
+ if signature.key_info
35
+ root.add_child(build_key_info(signature.key_info, doc))
36
+ end
37
+ signature.objects.each do |obj|
38
+ root.add_child(build_object(obj, doc))
39
+ end
40
+
41
+ doc
42
+ end
43
+
44
+ # Produces a standalone ds:SignedInfo document. Used by the Signer
45
+ # when canonicalizing SignedInfo in isolation.
46
+ def serialize_signed_info(signed_info)
47
+ doc = context.create_document
48
+ root = create_namespaced_element("SignedInfo", doc)
49
+ root["Id"] = signed_info.id if signed_info.id
50
+ doc.root = root
51
+ populate_signed_info(root, signed_info, doc)
52
+ doc
53
+ end
54
+
55
+ private
56
+
57
+ def build_signed_info(signed_info, doc)
58
+ el = create_namespaced_element("SignedInfo", doc)
59
+ el["Id"] = signed_info.id if signed_info.id
60
+ populate_signed_info(el, signed_info, doc)
61
+ el
62
+ end
63
+
64
+ def populate_signed_info(root, signed_info, doc)
65
+ if signed_info.canonicalization_method
66
+ root.add_child(
67
+ build_algorithm_method("CanonicalizationMethod",
68
+ signed_info.canonicalization_method, doc),
69
+ )
70
+ end
71
+ if signed_info.signature_method
72
+ root.add_child(
73
+ build_algorithm_method("SignatureMethod",
74
+ signed_info.signature_method, doc),
75
+ )
76
+ end
77
+ signed_info.references.each do |ref|
78
+ root.add_child(build_reference(ref, doc))
79
+ end
80
+ end
81
+
82
+ def build_algorithm_method(name, method, doc)
83
+ el = create_namespaced_element(name, doc)
84
+ el["Algorithm"] = method.algorithm
85
+ if method.parameters.is_a?(Hash) && method.parameters[:hmac_output_length]
86
+ len = create_namespaced_element("HMACOutputLength", doc)
87
+ len.add_child(doc.create_text(method.parameters[:hmac_output_length].to_s))
88
+ el.add_child(len)
89
+ end
90
+ el
91
+ end
92
+
93
+ def build_reference(reference, doc)
94
+ el = create_namespaced_element("Reference", doc)
95
+ el["Id"] = reference.id if reference.id
96
+ el["URI"] = reference.uri if reference.uri
97
+ el["Type"] = reference.type if reference.type
98
+
99
+ if reference.transforms && !reference.transforms.empty?
100
+ tf = create_namespaced_element("Transforms", doc)
101
+ reference.transforms.each do |transform|
102
+ tr = create_namespaced_element("Transform", doc)
103
+ tr["Algorithm"] = transform.algorithm
104
+ tf.add_child(tr)
105
+ end
106
+ el.add_child(tf)
107
+ end
108
+
109
+ if reference.digest_method
110
+ dm = create_namespaced_element("DigestMethod", doc)
111
+ dm["Algorithm"] = reference.digest_method.algorithm
112
+ el.add_child(dm)
113
+ end
114
+
115
+ dv = create_namespaced_element("DigestValue", doc)
116
+ dv.add_child(doc.create_text(reference.digest_value || ""))
117
+ el.add_child(dv)
118
+ el
119
+ end
120
+
121
+ def build_signature_value(signature_value, doc)
122
+ el = create_namespaced_element("SignatureValue", doc)
123
+ el["Id"] = signature_value.id if signature_value.id
124
+ if signature_value.value
125
+ el.add_child(doc.create_text(Base64.strict_encode64(signature_value.value)))
126
+ end
127
+ el
128
+ end
129
+
130
+ def build_key_info(key_info, doc)
131
+ el = create_namespaced_element("KeyInfo", doc)
132
+ el["Id"] = key_info.id if key_info.id
133
+ if key_info.key_name
134
+ kn = create_namespaced_element("KeyName", doc)
135
+ kn.add_child(doc.create_text(key_info.key_name))
136
+ el.add_child(kn)
137
+ end
138
+ key_info.raw_elements.each { |raw| el.add_child(raw.dup) }
139
+ el
140
+ end
141
+
142
+ def build_object(obj, doc)
143
+ el = create_namespaced_element("Object", doc)
144
+ el["Id"] = obj.id if obj.id
145
+ el["MimeType"] = obj.mime_type if obj.mime_type
146
+ el["Encoding"] = obj.encoding if obj.encoding
147
+ # Payload content (Manifest, SignatureProperties, XAdES
148
+ # QualifyingProperties) comes in later tiers.
149
+ el
150
+ end
151
+
152
+ # Create an element in the xmldsig# namespace with the conventional
153
+ # `ds` prefix. The namespace declaration is added once on the
154
+ # element itself; descendants inherit it.
155
+ def create_namespaced_element(local_name, doc)
156
+ el = doc.create_element(local_name)
157
+ el.add_namespace(DS_PREFIX, DSIG_NS)
158
+ el.namespace = { DS_PREFIX => DSIG_NS }
159
+ el
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Reference generation + SignatureValue computation per spec §3.1.
6
+ #
7
+ # Flow:
8
+ # 1. For each Reference in signed_info.references:
9
+ # a. Resolve URI (same-document or octet).
10
+ # b. Apply each Transform in order.
11
+ # c. Compute digest via DigestMethod.
12
+ # d. Store base64 digest on Reference#digest_value.
13
+ # 2. Serialize SignedInfo.
14
+ # 3. Canonicalize per CanonicalizationMethod.
15
+ # 4. Sign via SignatureMethod#sign(canonical_octets, key).
16
+ # 5. Set signature.signature_value.
17
+ class Signer
18
+ attr_reader :context, :signature, :document, :key
19
+
20
+ def initialize(context:, signature:, document:, key:)
21
+ @context = context
22
+ @signature = signature
23
+ @document = document
24
+ @key = key
25
+ end
26
+
27
+ def sign
28
+ Algorithms.load_builtins!
29
+
30
+ signature.signed_info.references.each do |ref|
31
+ digest = compute_reference_digest(ref)
32
+ ref.digest_value = digest
33
+ end
34
+
35
+ signature.signature_value = Model::SignatureValue.new(
36
+ value: compute_signature_value,
37
+ )
38
+ signature
39
+ end
40
+
41
+ private
42
+
43
+ def compute_reference_digest(reference)
44
+ resolver = ReferenceResolver.new(context: context, document: document)
45
+ input = resolver.resolve(reference.uri)
46
+ pipeline = TransformPipeline.new(context: context, signature_element: nil)
47
+ transformed = pipeline.apply(input, reference.transforms)
48
+ canonical = pipeline.to_octets(transformed, reference)
49
+ digest_algo = Algorithms.lookup(:digest, reference.digest_method.algorithm).new
50
+ digest_algo.digest_base64(canonical)
51
+ end
52
+
53
+ def compute_signature_value
54
+ signed_info = signature.signed_info
55
+ serializer = Serializer.new(context: context)
56
+ signed_info_doc = serializer.serialize_signed_info(signed_info)
57
+
58
+ c14n_uri = signed_info.canonicalization_method.algorithm
59
+ c14n_klass = Algorithms.lookup(:canonicalization, c14n_uri)
60
+ c14n = c14n_klass.new(identifier_uri: c14n_uri)
61
+ canonical_octets = c14n.canonicalize(signed_info_doc.root)
62
+
63
+ sm_uri = signed_info.signature_method.algorithm
64
+ sm_klass = Algorithms.lookup(:signature_method, sm_uri)
65
+ sm = sm_klass.new(
66
+ identifier_uri: sm_uri,
67
+ parameters: signed_info.signature_method.parameters,
68
+ )
69
+ sm.sign(canonical_octets, key)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Verification outcome for a single Signature element. Carries the
6
+ # cryptographic-result boolean, per-reference results, and (when
7
+ # applicable) the error that caused failure.
8
+ class SingleVerificationResult
9
+ attr_reader :signature_id, :references, :error
10
+
11
+ def initialize(signature_id:, signature_valid:, references:, error: nil)
12
+ @signature_id = signature_id
13
+ @signature_valid = signature_valid
14
+ @references = references
15
+ @error = error
16
+ end
17
+
18
+ def signature_valid?
19
+ @signature_valid
20
+ end
21
+
22
+ def valid?
23
+ @signature_valid && references.all?(&:valid?)
24
+ end
25
+
26
+ def failing_references
27
+ references.reject(&:valid?)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Shared transform-pipeline logic used by both Signer (signing flow,
6
+ # signature_element: nil) and Verifier (verification flow, with the
7
+ # containing Signature element so the Enveloped Signature transform
8
+ # can remove it from the digest input).
9
+ #
10
+ # Each Transform in the chain is looked up via Algorithms.lookup(:transform, uri),
11
+ # with a fallback to Algorithms.lookup(:canonicalization, uri) per
12
+ # spec §6.6.1 (any canonicalization algorithm can be used as a transform).
13
+ #
14
+ # Type coercion matches spec §4.4.3.2 reference processing model:
15
+ # - octets → nodeset: parse as XML
16
+ # - nodeset → octets: apply inclusive C14N 1.0
17
+ class TransformPipeline
18
+ attr_reader :context, :signature_element
19
+
20
+ def initialize(context:, signature_element: nil)
21
+ @context = context
22
+ @signature_element = signature_element
23
+ end
24
+
25
+ def apply(input, transforms_model)
26
+ current = input
27
+ current_type = type_of(current)
28
+ transforms = transforms_from(transforms_model)
29
+
30
+ transforms.each do |transform_model|
31
+ algo_class = lookup(transform_model.algorithm)
32
+ transform = algo_class.new(
33
+ parameters: transform_model.parameters,
34
+ context: context,
35
+ signature_element: signature_element,
36
+ )
37
+ current_type, current = coerce(current, current_type,
38
+ algo_class.input_type)
39
+ current = transform.transform(current)
40
+ current_type = algo_class.output_type
41
+ end
42
+
43
+ current
44
+ end
45
+
46
+ # Final step: convert the pipeline output (which may still be a node)
47
+ # to octets suitable for digesting. Uses inclusive C14N 1.0 as the
48
+ # default mapping per spec §4.4.3.2.
49
+ def to_octets(value, reference = nil)
50
+ return value if value.is_a?(String)
51
+
52
+ c14n_uri = canonicalization_from(reference) || DEFAULT_OCTET_C14N
53
+ klass = Algorithms.lookup(:canonicalization, c14n_uri)
54
+ klass.new(identifier_uri: c14n_uri).canonicalize(value)
55
+ end
56
+
57
+ DEFAULT_OCTET_C14N = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"
58
+
59
+ private_constant :DEFAULT_OCTET_C14N
60
+
61
+ private
62
+
63
+ def transforms_from(transforms_model)
64
+ return [] unless transforms_model
65
+
66
+ transforms_model.transforms
67
+ end
68
+
69
+ def lookup(uri)
70
+ Algorithms.lookup(:transform, uri)
71
+ rescue UnknownAlgorithm
72
+ Algorithms.lookup(:canonicalization, uri)
73
+ end
74
+
75
+ def type_of(value)
76
+ case value
77
+ when ::Moxml::Node, Array then :nodeset
78
+ else :octets
79
+ end
80
+ end
81
+
82
+ def coerce(input, from, to)
83
+ return [to, input] if from == to
84
+
85
+ case [from, to]
86
+ when %i[octets nodeset]
87
+ parsed = context.parse(input.to_s)
88
+ [:nodeset, parsed.root]
89
+ when %i[nodeset octets]
90
+ octets = C14n::Inclusive10.new.canonicalize(input)
91
+ [:octets, octets]
92
+ else
93
+ raise TransformError, "cannot coerce #{from} to #{to}"
94
+ end
95
+ end
96
+
97
+ def canonicalization_from(reference)
98
+ return nil unless reference&.transforms
99
+
100
+ reference.transforms.transforms.reverse_each.find do |t|
101
+ Algorithms.registered?(:canonicalization, t.algorithm)
102
+ end&.algorithm
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Aggregated verification result for a document. A document may
6
+ # contain multiple Signature elements; this result wraps them all.
7
+ class VerificationResult
8
+ attr_reader :results
9
+
10
+ def initialize(results:)
11
+ @results = results
12
+ end
13
+
14
+ def valid?
15
+ results.all?(&:valid?)
16
+ end
17
+
18
+ def signature_count
19
+ results.size
20
+ end
21
+
22
+ def failing
23
+ results.reject(&:valid?)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ # Core Validation per spec §3.2, with the safe ordering prescribed by
6
+ # Best Practice 1: verify SignatureValue BEFORE running any reference
7
+ # transforms (which could be hostile).
8
+ class Verifier
9
+ attr_reader :context, :document, :key, :key_map
10
+
11
+ def initialize(context:, document:, key: nil, key_map: {}, **_options)
12
+ @context = context
13
+ @document = document
14
+ @key = key
15
+ @key_map = key_map || {}
16
+ end
17
+
18
+ def verify
19
+ Algorithms.load_builtins!
20
+ signatures = find_signatures
21
+
22
+ results = signatures.map { |sig_elem| verify_one(sig_elem) }
23
+ VerificationResult.new(results: results)
24
+ end
25
+
26
+ private
27
+
28
+ def find_signatures
29
+ document.xpath("//ds:Signature", "ds" => DSIG_NS)
30
+ .grep(::Moxml::Element)
31
+ end
32
+
33
+ def verify_one(signature_element)
34
+ signature = Parser.new(context: context).parse(signature_element)
35
+ signature_value_ok, error = verify_signature_value(signature, signature_element)
36
+ reference_results = if signature_value_ok
37
+ verify_references(signature, signature_element)
38
+ else
39
+ []
40
+ end
41
+
42
+ SingleVerificationResult.new(
43
+ signature_id: signature.id,
44
+ signature_valid: signature_value_ok,
45
+ references: reference_results,
46
+ error: error,
47
+ )
48
+ end
49
+
50
+ # Returns [Boolean, ErrorOrNil] so the caller can attach the error
51
+ # to the result for debugging. The error is never raised — Best
52
+ # Practice 1 says authenticate first; surfacing why auth failed is
53
+ # an application concern, not a panic.
54
+ def verify_signature_value(signature, signature_element)
55
+ return [false, nil] if signature.signature_value.nil?
56
+ return [false, nil] if signature.signature_value.value.nil?
57
+
58
+ signed_info_elem = signature_element.at_xpath(
59
+ "./ds:SignedInfo", "ds" => DSIG_NS
60
+ )
61
+ return [false, nil] if signed_info_elem.nil?
62
+
63
+ c14n_uri = signature.signed_info.canonicalization_method&.algorithm
64
+ return [false, nil] if c14n_uri.nil?
65
+
66
+ canonical = canonicalize_signed_info(c14n_uri, signed_info_elem)
67
+ sm_uri = signature.signed_info.signature_method.algorithm
68
+ sm = Algorithms.lookup(:signature_method, sm_uri).new(
69
+ identifier_uri: sm_uri,
70
+ parameters: signature.signed_info.signature_method.parameters,
71
+ )
72
+ verification_key = resolve_key(signature)
73
+ return [false, nil] if verification_key.nil?
74
+
75
+ ok = sm.verify(canonical, verification_key, signature.signature_value.value)
76
+ [ok, nil]
77
+ rescue VerificationError, UnknownAlgorithm => e
78
+ [false, e]
79
+ end
80
+
81
+ # Canonicalize the original SignedInfo element as it appears in the
82
+ # document. Re-serializing the parsed model would change namespace
83
+ # prefixes (e.g. SignedInfo → ds:SignedInfo) and break byte-exact
84
+ # verification.
85
+ def canonicalize_signed_info(c14n_uri, signed_info_elem)
86
+ Algorithms.lookup(:canonicalization, c14n_uri)
87
+ .new(identifier_uri: c14n_uri)
88
+ .canonicalize(signed_info_elem)
89
+ end
90
+
91
+ def resolve_key(signature)
92
+ return key if key
93
+ return nil unless signature.key_info
94
+
95
+ KeyExtractor.new(key_map: key_map).extract(signature.key_info)
96
+ end
97
+
98
+ def verify_references(signature, signature_element)
99
+ signature.signed_info.references.map do |ref|
100
+ verify_reference(ref, signature_element)
101
+ end
102
+ end
103
+
104
+ def verify_reference(reference, signature_element)
105
+ resolver = ReferenceResolver.new(context: context, document: document)
106
+ input = resolver.resolve(reference.uri)
107
+ pipeline = TransformPipeline.new(
108
+ context: context,
109
+ signature_element: signature_element,
110
+ )
111
+ transformed = pipeline.apply(input, reference.transforms)
112
+ canonical = pipeline.to_octets(transformed, reference)
113
+
114
+ digest_algo = Algorithms.lookup(
115
+ :digest, reference.digest_method.algorithm
116
+ ).new
117
+ computed = digest_algo.digest_base64(canonical)
118
+ expected = (reference.digest_value || "").strip
119
+
120
+ ReferenceResult.new(
121
+ uri: reference.uri,
122
+ digest_method: reference.digest_method.algorithm,
123
+ expected: expected,
124
+ computed: computed,
125
+ )
126
+ end
127
+ end
128
+ end
129
+ end