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,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
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ module Algorithms
6
+ # Enveloped Signature Transform (W3C §6.6.4).
7
+ #
8
+ # Removes the containing ds:Signature element from the node-set so the
9
+ # signature does not include itself in its own digest calculation.
10
+ #
11
+ # Semantics during signing: `signature_element` is nil (the Signature
12
+ # has not been attached to the document yet), so the transform is a
13
+ # no-op — the document contains no Signature to exclude.
14
+ #
15
+ # Semantics during verification: `signature_element` is the Signature
16
+ # element being verified. The transform detaches it (and its
17
+ # descendants) from a deep copy of the input so the canonicalizer
18
+ # walks a tree without the Signature.
19
+ class EnvelopedSignatureTransform < TransformBase
20
+ identifier "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
21
+
22
+ def self.input_type; :nodeset; end
23
+ def self.output_type; :nodeset; end
24
+
25
+ def transform(input)
26
+ return input if @signature_element.nil?
27
+
28
+ # If the signature is not within the input subtree, no-op.
29
+ return input unless signature_within?(input)
30
+
31
+ working_copy = deep_copy(input)
32
+ remove_signature_within(working_copy)
33
+ working_copy
34
+ end
35
+
36
+ private
37
+
38
+ def signature_within?(input)
39
+ !find_signature_element(input).nil?
40
+ end
41
+
42
+ def remove_signature_within(node)
43
+ sig = find_signature_element(node)
44
+ sig&.remove
45
+ end
46
+
47
+ def find_signature_element(node)
48
+ return nil unless node
49
+ return node if signature_element?(node)
50
+
51
+ children = node.children if node.is_a?(::Moxml::Node)
52
+ children&.each do |child|
53
+ found = find_signature_element(child)
54
+ return found if found
55
+ end
56
+ nil
57
+ end
58
+
59
+ def signature_element?(node)
60
+ node.is_a?(::Moxml::Element) &&
61
+ node.name == "Signature" &&
62
+ node.namespace_uri == DSIG_NS
63
+ end
64
+
65
+ def deep_copy(input)
66
+ case input
67
+ when ::Moxml::Document
68
+ @context.parse(input.root.to_xml(indent: 0))
69
+ when ::Moxml::Element
70
+ wrapper = @context.parse("<__wrap__>#{input.to_xml(indent: 0)}</__wrap__>")
71
+ wrapper.root.children.first
72
+ else
73
+ input
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ module Algorithms
6
+ # Exclusive XML Canonicalization 1.0 (https://www.w3.org/TR/xml-exc-c14n/)
7
+ #
8
+ # Two registered URIs: one omits comments, one includes comments.
9
+ # The with_comments variant is detected from the URI suffix at
10
+ # instantiation time (see CanonicalizationBase#uri_has_comments?).
11
+ class ExcC14n10 < CanonicalizationBase
12
+ identifier "http://www.w3.org/2001/10/xml-exc-c14n#"
13
+ identifier "http://www.w3.org/2001/10/xml-exc-c14n#WithComments"
14
+
15
+ private
16
+
17
+ def engine
18
+ ::Moxml::C14n::Exclusive.new
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Moxml
6
+ module Signature
7
+ module Algorithms
8
+ # HMAC per RFC 2104, bound to a digest.
9
+ #
10
+ # Per W3C XML Signature §4.4.2 and §6.3.1, if `HMACOutputLength` is
11
+ # specified, the output is truncated to that many bits and the
12
+ # truncation length MUST be at least max(hash_bits / 2, 80) bits.
13
+ class HmacSha < SignatureMethodBase
14
+ PAIRINGS = {
15
+ "http://www.w3.org/2000/09/xmldsig#hmac-sha1" => "SHA1",
16
+ "http://www.w3.org/2001/04/xmldsig-more#hmac-sha224" => "SHA224",
17
+ "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" => "SHA256",
18
+ "http://www.w3.org/2001/04/xmldsig-more#hmac-sha384" => "SHA384",
19
+ "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512" => "SHA512",
20
+ }.freeze
21
+
22
+ HASH_BITS = {
23
+ "SHA1" => 160,
24
+ "SHA224" => 224,
25
+ "SHA256" => 256,
26
+ "SHA384" => 384,
27
+ "SHA512" => 512,
28
+ }.freeze
29
+
30
+ MIN_TRUNCATION_BITS = 80
31
+
32
+ PAIRINGS.each_key { |uri| identifier uri }
33
+
34
+ # parameters: optional { hmac_output_length: Integer } (bits, multiple of 8)
35
+ def initialize(identifier_uri:, parameters: nil)
36
+ super(parameters)
37
+ @identifier_uri = identifier_uri
38
+ @truncation_bits = parameters&.dig(:hmac_output_length)
39
+ validate_truncation! if @truncation_bits
40
+ end
41
+
42
+ def compute_signature(data, key)
43
+ secret = coerce_key(key)
44
+ full = OpenSSL::HMAC.digest(digest_name, secret, data)
45
+ truncate(full)
46
+ end
47
+
48
+ def verify_signature(data, key, signature)
49
+ expected = compute_signature(data, key)
50
+ fixed_comparison(expected, signature)
51
+ end
52
+
53
+ private
54
+
55
+ def digest_name
56
+ PAIRINGS.fetch(@identifier_uri)
57
+ end
58
+
59
+ def hash_bits
60
+ HASH_BITS.fetch(digest_name)
61
+ end
62
+
63
+ def truncate(full_mac)
64
+ return full_mac unless @truncation_bits
65
+
66
+ unless (@truncation_bits % 8).zero?
67
+ raise SignatureError,
68
+ "HMACOutputLength (#{@truncation_bits}) must be a " \
69
+ "multiple of 8"
70
+ end
71
+
72
+ min = [hash_bits / 2, MIN_TRUNCATION_BITS].max
73
+ if @truncation_bits < min
74
+ raise SignatureError,
75
+ "HMACOutputLength (#{@truncation_bits}) below minimum " \
76
+ "of #{min} for #{digest_name}"
77
+ end
78
+
79
+ full_mac.byteslice(0, @truncation_bits / 8)
80
+ end
81
+
82
+ def validate_truncation!
83
+ unless (@truncation_bits % 8).zero?
84
+ raise SignatureError,
85
+ "HMACOutputLength (#{@truncation_bits}) must be a " \
86
+ "multiple of 8"
87
+ end
88
+
89
+ min = [hash_bits / 2, MIN_TRUNCATION_BITS].max
90
+ return if @truncation_bits >= min
91
+
92
+ raise SignatureError,
93
+ "HMACOutputLength (#{@truncation_bits}) below minimum " \
94
+ "of #{min} for #{digest_name}"
95
+ end
96
+
97
+ def coerce_key(key)
98
+ case key
99
+ when String then key
100
+ else
101
+ raise SignatureKeyError,
102
+ "HMAC signature method requires a String secret, " \
103
+ "got #{key.class}"
104
+ end
105
+ end
106
+
107
+ def fixed_comparison(expected, actual)
108
+ return false unless expected.bytesize == actual.bytesize
109
+
110
+ OpenSSL.fixed_length_secure_compare(expected, actual)
111
+ rescue ArgumentError
112
+ false
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ module Algorithms
6
+ # Canonical XML 1.0 (https://www.w3.org/TR/xml-c14n/)
7
+ #
8
+ # Implemented via the canon-derived C14n::Inclusive10 engine.
9
+ class InclusiveC14n10 < CanonicalizationBase
10
+ identifier "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"
11
+ identifier "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"
12
+
13
+ private
14
+
15
+ def engine
16
+ ::Moxml::C14n::Inclusive10.new
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Signature
5
+ module Algorithms
6
+ # Canonical XML 1.1 (https://www.w3.org/TR/xml-c14n11/)
7
+ #
8
+ # Currently delegates to Inclusive 1.0. Full 1.1 differences
9
+ # (XML 1.1 line-ending handling, notations, DTD entity refs)
10
+ # are documented in TODO.complete/05-c14n-engine.md.
11
+ class InclusiveC14n11 < CanonicalizationBase
12
+ identifier "http://www.w3.org/2006/12/xml-c14n11"
13
+ identifier "http://www.w3.org/2006/12/xml-c14n11#WithComments"
14
+
15
+ private
16
+
17
+ def engine
18
+ ::Moxml::C14n::Inclusive10.new
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end