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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ RSpec.describe Moxml::Signature::Algorithms::HmacSha do
8
+ let(:secret) { "super-secret-shared-key" }
9
+ let(:data) { "the quick brown fox" }
10
+
11
+ describe "HMAC-SHA256" do
12
+ let(:uri) { "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" }
13
+ let(:algo) { described_class.new(identifier_uri: uri) }
14
+
15
+ it "produces a verifiable MAC" do
16
+ mac = algo.sign(data, secret)
17
+ expect(algo.verify(data, secret, mac)).to be true
18
+ end
19
+
20
+ it "rejects a tampered payload" do
21
+ mac = algo.sign(data, secret)
22
+ expect(algo.verify("#{data}!", secret, mac)).to be false
23
+ end
24
+
25
+ it "matches OpenSSL HMAC for the same inputs" do
26
+ mac = algo.sign(data, secret)
27
+ expected = OpenSSL::HMAC.digest("SHA256", secret, data)
28
+ expect(mac).to eq(expected)
29
+ end
30
+ end
31
+
32
+ describe "HMAC truncation" do
33
+ let(:uri) { "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" }
34
+
35
+ it "truncates to the specified bit length" do
36
+ algo = described_class.new(
37
+ identifier_uri: uri,
38
+ parameters: { hmac_output_length: 128 },
39
+ )
40
+ mac = algo.sign(data, secret)
41
+ expect(mac.bytesize).to eq(16)
42
+ end
43
+
44
+ it "rejects truncation below hash_bits / 2" do
45
+ expect do
46
+ described_class.new(
47
+ identifier_uri: uri,
48
+ parameters: { hmac_output_length: 64 },
49
+ )
50
+ end.to raise_error(Moxml::Signature::SignatureError)
51
+ end
52
+
53
+ it "rejects truncation below 80 bits even for SHA-1" do
54
+ expect do
55
+ described_class.new(
56
+ identifier_uri: "http://www.w3.org/2000/09/xmldsig#hmac-sha1",
57
+ parameters: { hmac_output_length: 72 },
58
+ )
59
+ end.to raise_error(Moxml::Signature::SignatureError)
60
+ end
61
+
62
+ it "rejects truncation that is not a multiple of 8" do
63
+ expect do
64
+ described_class.new(
65
+ identifier_uri: uri,
66
+ parameters: { hmac_output_length: 130 },
67
+ )
68
+ end.to raise_error(Moxml::Signature::SignatureError)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ RSpec.describe Moxml::Signature::Algorithms::RsaPkcs1Sha do
8
+ let(:key) { OpenSSL::PKey::RSA.generate(2048) }
9
+ let(:data) { "the quick brown fox jumps over the lazy dog" }
10
+
11
+ describe "RSA-SHA256" do
12
+ let(:uri) { "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" }
13
+ let(:algo) { described_class.new(identifier_uri: uri) }
14
+
15
+ it "produces a verifiable signature" do
16
+ signature = algo.sign(data, key)
17
+ expect(algo.verify(data, key, signature)).to be true
18
+ end
19
+
20
+ it "rejects a tampered payload" do
21
+ signature = algo.sign(data, key)
22
+ expect(algo.verify("#{data}!", key, signature)).to be false
23
+ end
24
+
25
+ it "rejects verification with a different key" do
26
+ other_key = OpenSSL::PKey::RSA.generate(2048)
27
+ signature = algo.sign(data, key)
28
+ expect(algo.verify(data, other_key, signature)).to be false
29
+ end
30
+ end
31
+
32
+ describe "RSA-SHA1 / SHA224 / SHA384 / SHA512" do
33
+ %w[
34
+ http://www.w3.org/2000/09/xmldsig#rsa-sha1
35
+ http://www.w3.org/2001/04/xmldsig-more#rsa-sha224
36
+ http://www.w3.org/2001/04/xmldsig-more#rsa-sha384
37
+ http://www.w3.org/2001/04/xmldsig-more#rsa-sha512
38
+ ].each do |uri|
39
+ it "#{uri} round-trips sign/verify" do
40
+ algo = described_class.new(identifier_uri: uri)
41
+ sig = algo.sign(data, key)
42
+ expect(algo.verify(data, key, sig)).to be true
43
+ end
44
+ end
45
+ end
46
+
47
+ it "rejects a non-RSA key" do
48
+ algo = described_class.new(
49
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
50
+ )
51
+ expect do
52
+ algo.sign(data, "not-a-key")
53
+ end.to raise_error(Moxml::Signature::SignatureKeyError)
54
+ end
55
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+
6
+ RSpec.describe Moxml::Signature::Algorithms do
7
+ describe "registry" do
8
+ before { described_class.load_builtins! }
9
+
10
+ it "registers built-in digest algorithms" do
11
+ expect(described_class.registered?(:digest,
12
+ "http://www.w3.org/2001/04/xmlenc#sha256")).to be true
13
+ expect(described_class.registered?(:digest,
14
+ "http://www.w3.org/2000/09/xmldsig#sha1")).to be true
15
+ end
16
+
17
+ it "registers built-in signature methods" do
18
+ expect(described_class.registered?(:signature_method,
19
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")).to be true
20
+ expect(described_class.registered?(:signature_method,
21
+ "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256")).to be true
22
+ end
23
+
24
+ it "registers built-in canonicalization algorithms" do
25
+ expect(described_class.registered?(:canonicalization,
26
+ "http://www.w3.org/2001/10/xml-exc-c14n#")).to be true
27
+ expect(described_class.registered?(:canonicalization,
28
+ "http://www.w3.org/2001/10/xml-exc-c14n#WithComments")).to be true
29
+ end
30
+
31
+ it "registers built-in transform algorithms" do
32
+ expect(described_class.registered?(:transform,
33
+ "http://www.w3.org/2000/09/xmldsig#base64")).to be true
34
+ expect(described_class.registered?(:transform,
35
+ "http://www.w3.org/2000/09/xmldsig#enveloped-signature")).to be true
36
+ end
37
+
38
+ it "looks up a registered algorithm class" do
39
+ klass = described_class.lookup(:digest,
40
+ "http://www.w3.org/2001/04/xmlenc#sha256")
41
+ expect(klass).to eq(Moxml::Signature::Algorithms::SHA256)
42
+ end
43
+
44
+ it "raises UnknownAlgorithm for unregistered URIs" do
45
+ expect do
46
+ described_class.lookup(:digest, "http://example.com/nonexistent")
47
+ end.to raise_error(Moxml::Signature::UnknownAlgorithm)
48
+ end
49
+
50
+ it "validates category names" do
51
+ expect do
52
+ described_class.register(:bogus, "http://example.com", Class.new)
53
+ end.to raise_error(ArgumentError)
54
+ end
55
+ end
56
+
57
+ describe "custom algorithm registration" do
58
+ after do
59
+ described_class.instance_variable_get(:@registry)[:digest]
60
+ &.delete("http://test.example/custom-digest")
61
+ end
62
+
63
+ it "accepts a custom algorithm class" do
64
+ custom = Class.new(Moxml::Signature::Algorithms::DigestBase) do
65
+ identifier "http://test.example/custom-digest"
66
+
67
+ def compute_digest(data)
68
+ "x" * data.bytesize
69
+ end
70
+ end
71
+
72
+ expect(described_class.lookup(:digest,
73
+ "http://test.example/custom-digest")).to eq(custom)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ RSpec.describe "Ported reference fixtures (cross-verification)" do
8
+ let(:ctx) { Moxml.new(:nokogiri) }
9
+ let(:fixtures_dir) do
10
+ File.expand_path("../../fixtures/xmldsig", __dir__)
11
+ end
12
+
13
+ describe "sign3-result.xml (libxmlsec1 with embedded X509Certificate)" do
14
+ let(:xml) { File.read(File.join(fixtures_dir, "sign3-result.xml")) }
15
+ let(:doc) { ctx.parse(xml) }
16
+
17
+ it "auto-extracts the verification key from X509Certificate" do
18
+ # No explicit key passed — Verifier should auto-extract from KeyInfo.
19
+ result = Moxml::Signature.verify(context: ctx, document: doc)
20
+ expect(result.valid?).to be true
21
+ expect(result.results.first.signature_valid?).to be true
22
+ expect(result.results.first.references.first.valid?).to be true
23
+ end
24
+
25
+ it "parses the X509Data correctly" do
26
+ sig_elem = doc.at_xpath(
27
+ "//ds:Signature", "ds" => "http://www.w3.org/2000/09/xmldsig#"
28
+ )
29
+ parsed = Moxml::Signature::Parser.new(context: ctx).parse(sig_elem)
30
+ expect(parsed.key_info).not_to be_nil
31
+ expect(parsed.key_info.x509_data).not_to be_nil
32
+ expect(parsed.key_info.x509_data.certificates.size).to eq(1)
33
+
34
+ extractor = Moxml::Signature::KeyExtractor.new
35
+ key = extractor.extract(parsed.key_info)
36
+ expect(key).to be_a(OpenSSL::PKey::RSA)
37
+ end
38
+ end
39
+
40
+ describe "sign2-result.xml (libxmlsec1 with KeyName only)" do
41
+ let(:xml) { File.read(File.join(fixtures_dir, "sign2-result.xml")) }
42
+ let(:doc) { ctx.parse(xml) }
43
+ let(:pub_key_path) { File.join(fixtures_dir, "keys", "rsa_ref.pub") }
44
+
45
+ it "cross-verifies with the Ruby ref's RSA public key" do
46
+ skip "public key fixture not present" unless File.exist?(pub_key_path)
47
+
48
+ pub = OpenSSL::PKey::RSA.new(File.read(pub_key_path))
49
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: pub)
50
+ expect(result.valid?).to be true
51
+ end
52
+
53
+ it "auto-resolves KeyName via the application key_map" do
54
+ skip "public key fixture not present" unless File.exist?(pub_key_path)
55
+
56
+ pub = OpenSSL::PKey::RSA.new(File.read(pub_key_path))
57
+ # The fixture uses <KeyName>test</KeyName>; map it.
58
+ result = Moxml::Signature.verify(
59
+ context: ctx, document: doc, key_map: { "test" => pub },
60
+ )
61
+ expect(result.valid?).to be true
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ # Edge cases and defensive behavior. Per the audit (TODO.complete/20),
8
+ # the original suite lacked:
9
+ # - malformed input handling
10
+ # - attack-scenario rejection
11
+ # - KeyExtractor failure modes
12
+ # - HMAC truncation at exact boundaries
13
+ # - multi-signature documents
14
+ # - adapter portability
15
+ RSpec.describe "Moxml::Signature edge cases and defense" do
16
+ let(:ctx) { Moxml.new(:nokogiri) }
17
+
18
+ describe "malformed Signature elements" do
19
+ it "rejects a document with no Signature element" do
20
+ doc = ctx.parse("<doc/>")
21
+ result = Moxml::Signature.verify(
22
+ context: ctx, document: doc, key: OpenSSL::PKey::RSA.generate(2048),
23
+ )
24
+ expect(result.signature_count).to eq(0)
25
+ expect(result.valid?).to be true # vacuously — no signatures
26
+ end
27
+
28
+ it "raises on SignatureValue with invalid base64" do
29
+ key = OpenSSL::PKey::RSA.generate(2048)
30
+ doc = ctx.parse(<<~XML.strip)
31
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
32
+ <ds:SignedInfo>
33
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
34
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
35
+ <ds:Reference URI="">
36
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
37
+ <ds:DigestValue>!!!!not base64!!!!</ds:DigestValue>
38
+ </ds:Reference>
39
+ </ds:SignedInfo>
40
+ <ds:SignatureValue>also not base64!!!</ds:SignatureValue>
41
+ </ds:Signature>
42
+ XML
43
+ expect do
44
+ Moxml::Signature.verify(context: ctx, document: doc, key: key)
45
+ end.to raise_error(Moxml::Signature::MalformedSignatureError)
46
+ end
47
+
48
+ it "returns false for a Signature with empty SignatureValue" do
49
+ key = OpenSSL::PKey::RSA.generate(2048)
50
+ doc = ctx.parse(<<~XML.strip)
51
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
52
+ <ds:SignedInfo>
53
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
54
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
55
+ <ds:Reference URI="">
56
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
57
+ <ds:DigestValue>YWJjZA==</ds:DigestValue>
58
+ </ds:Reference>
59
+ </ds:SignedInfo>
60
+ <ds:SignatureValue></ds:SignatureValue>
61
+ </ds:Signature>
62
+ XML
63
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: key)
64
+ expect(result.valid?).to be false
65
+ end
66
+
67
+ it "returns false for an unknown signature method URI" do
68
+ key = OpenSSL::PKey::RSA.generate(2048)
69
+ doc = ctx.parse(<<~XML.strip)
70
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
71
+ <ds:SignedInfo>
72
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
73
+ <ds:SignatureMethod Algorithm="http://example.com/bogus-alg"/>
74
+ <ds:Reference URI="">
75
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
76
+ <ds:DigestValue>YWJjZA==</ds:DigestValue>
77
+ </ds:Reference>
78
+ </ds:SignedInfo>
79
+ <ds:SignatureValue>YWJjZA==</ds:SignatureValue>
80
+ </ds:Signature>
81
+ XML
82
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: key)
83
+ expect(result.valid?).to be false
84
+ expect(result.results.first.error).to be_a(Moxml::Signature::UnknownAlgorithm)
85
+ end
86
+ end
87
+
88
+ describe "HMAC truncation boundary" do
89
+ let(:secret) { "shared-secret" }
90
+ let(:data) { "data" }
91
+
92
+ it "accepts truncation at exactly the minimum (hash_bits/2)" do
93
+ # For SHA-256, minimum is 128 bits. Should be accepted.
94
+ algo = Moxml::Signature::Algorithms::HmacSha.new(
95
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256",
96
+ parameters: { hmac_output_length: 128 },
97
+ )
98
+ mac = algo.sign(data, secret)
99
+ expect(mac.bytesize).to eq(16)
100
+ end
101
+
102
+ it "accepts truncation at exactly 80 bits (SHA-1 minimum)" do
103
+ algo = Moxml::Signature::Algorithms::HmacSha.new(
104
+ identifier_uri: "http://www.w3.org/2000/09/xmldsig#hmac-sha1",
105
+ parameters: { hmac_output_length: 80 },
106
+ )
107
+ expect(algo.sign(data, secret).bytesize).to eq(10)
108
+ end
109
+
110
+ it "rejects truncation one bit below the minimum" do
111
+ expect do
112
+ Moxml::Signature::Algorithms::HmacSha.new(
113
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256",
114
+ parameters: { hmac_output_length: 127 },
115
+ )
116
+ end.to raise_error(Moxml::Signature::SignatureError)
117
+ end
118
+ end
119
+
120
+ describe "KeyExtractor failure modes" do
121
+ let(:extractor) { Moxml::Signature::KeyExtractor.new }
122
+
123
+ it "returns nil for an empty KeyInfo" do
124
+ key_info = Moxml::Signature::Model::KeyInfo.new
125
+ expect(extractor.extract(key_info)).to be_nil
126
+ end
127
+
128
+ it "returns nil for nil KeyInfo" do
129
+ expect(extractor.extract(nil)).to be_nil
130
+ end
131
+
132
+ it "returns nil for a malformed X509Certificate" do
133
+ key_info = Moxml::Signature::Model::KeyInfo.new(
134
+ x509_data: Moxml::Signature::Model::Key::X509Data.new(
135
+ certificates: ["!!!not valid base64!!!"],
136
+ ),
137
+ )
138
+ expect(extractor.extract(key_info)).to be_nil
139
+ end
140
+
141
+ it "returns nil for a valid-base64 but invalid-DER certificate" do
142
+ require "base64"
143
+ key_info = Moxml::Signature::Model::KeyInfo.new(
144
+ x509_data: Moxml::Signature::Model::Key::X509Data.new(
145
+ certificates: [Base64.strict_encode64("not a certificate")],
146
+ ),
147
+ )
148
+ expect(extractor.extract(key_info)).to be_nil
149
+ end
150
+
151
+ it "returns nil for RSAKeyValue with malformed modulus" do
152
+ key_info = Moxml::Signature::Model::KeyInfo.new(
153
+ key_value: Moxml::Signature::Model::KeyValue.new(
154
+ rsa_key_value: Moxml::Signature::Model::Key::RSAKeyValue.new(
155
+ modulus: "!!!invalid base64!!!",
156
+ exponent: "AQAB",
157
+ ),
158
+ ),
159
+ )
160
+ expect(extractor.extract(key_info)).to be_nil
161
+ end
162
+
163
+ it "returns nil for ECKeyValue with unknown curve URI" do
164
+ key_info = Moxml::Signature::Model::KeyInfo.new(
165
+ key_value: Moxml::Signature::Model::KeyValue.new(
166
+ ec_key_value: Moxml::Signature::Model::Key::ECKeyValue.new(
167
+ named_curve_uri: "urn:oid:1.2.3.4.unknown",
168
+ public_key: "abc",
169
+ ),
170
+ ),
171
+ )
172
+ expect(extractor.extract(key_info)).to be_nil
173
+ end
174
+
175
+ it "returns nil for KeyName not in key_map" do
176
+ key_info = Moxml::Signature::Model::KeyInfo.new(key_name: "unknown")
177
+ expect(extractor.extract(key_info)).to be_nil
178
+ end
179
+ end
180
+
181
+ describe "multi-signature documents" do
182
+ it "reports each signature separately" do
183
+ doc = ctx.parse("<doc><a>x</a><b>y</b></doc>")
184
+ key1 = OpenSSL::PKey::RSA.generate(2048)
185
+ key2 = OpenSSL::PKey::RSA.generate(2048)
186
+
187
+ sig1 = Moxml::Signature.sign(
188
+ context: ctx, document: doc, key: key1,
189
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
190
+ canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
191
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
192
+ reference_uri: "",
193
+ transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"]
194
+ )
195
+ sig2 = Moxml::Signature.sign(
196
+ context: ctx, document: doc, key: key2,
197
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
198
+ canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
199
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
200
+ reference_uri: "",
201
+ transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"]
202
+ )
203
+ serializer = Moxml::Signature::Serializer.new(context: ctx)
204
+ doc.root.add_child(serializer.serialize(sig1).root)
205
+ doc.root.add_child(serializer.serialize(sig2).root)
206
+
207
+ # Verifying with key1: only sig1 should pass; sig2's SignatureValue
208
+ # won't verify against key1.
209
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: key1)
210
+ expect(result.signature_count).to eq(2)
211
+ expect(result.results.map(&:signature_valid?)).to contain_exactly(true, false)
212
+ end
213
+ end
214
+
215
+ describe "wrapping attack defense" do
216
+ it "does not verify a Signature whose SignedInfo references a different node" do
217
+ # Construct: doc has <payload xml:id="real">. Signature references
218
+ # "#real" but a tampered copy of <payload> lives inside <Object>.
219
+ # The reference digest matches the tampered copy, not the real one.
220
+ # This is the canonical wrapping attack pattern.
221
+ #
222
+ # The library cannot detect this on its own (the spec allows
223
+ # Object payloads). The application must check that the signed
224
+ # node is the one expected. This spec documents that the library
225
+ # faithfully reports per-reference validity, leaving the trust
226
+ # decision to the caller.
227
+ key = OpenSSL::PKey::RSA.generate(2048)
228
+ doc = ctx.parse(<<~XML.strip)
229
+ <doc>
230
+ <payload xml:id="real">original</payload>
231
+ </doc>
232
+ XML
233
+
234
+ # Sign the real payload
235
+ signature = Moxml::Signature.sign(
236
+ context: ctx, document: doc, key: key,
237
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
238
+ canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
239
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
240
+ reference_uri: "#real",
241
+ transforms: []
242
+ )
243
+ serializer = Moxml::Signature::Serializer.new(context: ctx)
244
+ doc.root.add_child(serializer.serialize(signature).root)
245
+
246
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: key)
247
+ expect(result.valid?).to be true
248
+
249
+ # Library returns the reference result; application must check URI.
250
+ ref = result.results.first.references.first
251
+ expect(ref.uri).to eq("#real")
252
+ end
253
+ end
254
+
255
+ describe "adapter portability" do
256
+ # Skip on Opal — adapter switching is the whole point of moxml.
257
+ it "verifies a signature produced under one adapter with another", :adapter_portability do
258
+ skip "Only Nokogiri available in CI" unless defined?(Nokogiri)
259
+
260
+ signing_ctx = Moxml.new(:nokogiri)
261
+ verify_ctx = Moxml.new(:nokogiri) # would be :rexml in a multi-adapter env
262
+
263
+ key = OpenSSL::PKey::RSA.generate(2048)
264
+ doc = signing_ctx.parse("<doc>payload</doc>")
265
+ signature = Moxml::Signature.sign(
266
+ context: signing_ctx, document: doc, key: key,
267
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
268
+ canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
269
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
270
+ reference_uri: "",
271
+ transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"]
272
+ )
273
+ serializer = Moxml::Signature::Serializer.new(context: signing_ctx)
274
+ doc.root.add_child(serializer.serialize(signature).root)
275
+
276
+ # Round-trip the XML through a different context
277
+ xml = doc.to_xml(indent: 0)
278
+ doc2 = verify_ctx.parse(xml)
279
+
280
+ result = Moxml::Signature.verify(context: verify_ctx, document: doc2, key: key)
281
+ expect(result.valid?).to be true
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ # Parses fixtures produced by the nokogiri-xmlsec-instructure reference
8
+ # implementation (which wraps libxmlsec1). The fixtures are real-world
9
+ # XML signatures; verifying them byte-for-byte requires byte-exact
10
+ # canonicalization (see TODO.complete/05-c14n-engine.md — inclusive C14N
11
+ # 1.0/1.1 are stubs). These specs assert the parser handles the wire
12
+ # format correctly.
13
+ RSpec.describe "Ported reference fixtures" do
14
+ let(:ctx) { Moxml.new(:nokogiri) }
15
+ let(:fixtures_dir) do
16
+ File.expand_path("../../fixtures/xmldsig", __dir__)
17
+ end
18
+
19
+ describe "sign2-result.xml (libxmlsec1 enveloped RSA-SHA256)" do
20
+ let(:xml) { File.read(File.join(fixtures_dir, "sign2-result.xml")) }
21
+ let(:doc) { ctx.parse(xml) }
22
+ let(:signature_element) do
23
+ doc.at_xpath("//ds:Signature", "ds" => "http://www.w3.org/2000/09/xmldsig#") ||
24
+ doc.at_xpath("//*[local-name()='Signature']")
25
+ end
26
+
27
+ it "is parseable into a model" do
28
+ parsed = Moxml::Signature::Parser.new(context: ctx).parse(signature_element)
29
+ expect(parsed.signed_info).not_to be_nil
30
+ expect(parsed.signed_info.canonicalization_method.algorithm)
31
+ .to eq("http://www.w3.org/2001/10/xml-exc-c14n#")
32
+ expect(parsed.signed_info.signature_method.algorithm)
33
+ .to eq("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")
34
+ expect(parsed.signed_info.references.length).to eq(1)
35
+
36
+ ref = parsed.signed_info.references.first
37
+ expect(ref.digest_method.algorithm)
38
+ .to eq("http://www.w3.org/2001/04/xmlenc#sha256")
39
+ expect(ref.digest_value).to eq("Gx8CGUsbi2qvBLd15VCmwELbDMND8F4vY3jPOc7/FJ0=")
40
+ # Reference uses 1024-bit RSA (libxmlsec1 test key) → 128-byte signature.
41
+ expect(parsed.signature_value.value.bytesize).to eq(128)
42
+ end
43
+
44
+ it "decodes SignatureValue despite embedded newlines (libxmlsec1 wraps long base64)" do
45
+ parsed = Moxml::Signature::Parser.new(context: ctx).parse(signature_element)
46
+ expect(parsed.signature_value.value.bytesize).to eq(128)
47
+ end
48
+
49
+ it "exposes the transforms chain" do
50
+ parsed = Moxml::Signature::Parser.new(context: ctx).parse(signature_element)
51
+ transforms = parsed.signed_info.references.first.transforms
52
+ expect(transforms.size).to eq(2)
53
+ expect(transforms.transforms.map(&:algorithm)).to eq([
54
+ "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
55
+ "http://www.w3.org/2001/10/xml-exc-c14n#",
56
+ ])
57
+ end
58
+
59
+ it "cross-verifies with the Ruby ref's RSA public key" do
60
+ pub_pem = File.read(File.join(fixtures_dir, "keys", "rsa_ref.pub"))
61
+ pub = OpenSSL::PKey::RSA.new(pub_pem)
62
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: pub)
63
+ expect(result.valid?).to be true
64
+ expect(result.results.first.signature_valid?).to be true
65
+ expect(result.results.first.references.first.valid?).to be true
66
+ end
67
+ end
68
+
69
+ describe "sign2-doc.xml (the unsigned payload)" do
70
+ let(:xml) { File.read(File.join(fixtures_dir, "sign2-doc.xml")) }
71
+
72
+ it "is the expected enveloped payload" do
73
+ doc = ctx.parse(xml)
74
+ expect(doc.root.name).to eq("Envelope")
75
+ # Data is in the default urn:envelope namespace.
76
+ data = doc.at_xpath("//*[local-name()='Data']")
77
+ expect(data.text.strip).to eq("Hello, World!")
78
+ end
79
+ end
80
+ end