dnsruby 1.73.1 → 2.0.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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -4
  3. data/DNSSEC +3 -2
  4. data/EXAMPLES +1 -1
  5. data/README.md +370 -64
  6. data/RELEASE_NOTES.md +314 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/dnsruby.gemspec +6 -4
  10. data/lib/dnsruby/cache.rb +279 -49
  11. data/lib/dnsruby/code_mapper.rb +35 -8
  12. data/lib/dnsruby/code_mappers.rb +63 -49
  13. data/lib/dnsruby/config.rb +77 -4
  14. data/lib/dnsruby/deadline_queue.rb +75 -0
  15. data/lib/dnsruby/dnssec.rb +148 -45
  16. data/lib/dnsruby/ipv6.rb +45 -5
  17. data/lib/dnsruby/key_cache.rb +202 -35
  18. data/lib/dnsruby/message/decoder.rb +85 -19
  19. data/lib/dnsruby/message/encoder.rb +27 -16
  20. data/lib/dnsruby/message/header.rb +8 -12
  21. data/lib/dnsruby/message/message.rb +53 -22
  22. data/lib/dnsruby/message/section.rb +12 -34
  23. data/lib/dnsruby/name.rb +5 -2
  24. data/lib/dnsruby/packet_sender.rb +664 -207
  25. data/lib/dnsruby/presentation.rb +147 -0
  26. data/lib/dnsruby/query_control.rb +132 -0
  27. data/lib/dnsruby/recursor.rb +798 -341
  28. data/lib/dnsruby/resolver.rb +472 -176
  29. data/lib/dnsruby/resource/CAA.rb +67 -16
  30. data/lib/dnsruby/resource/DLV.rb +6 -0
  31. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  32. data/lib/dnsruby/resource/DS.rb +27 -28
  33. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  34. data/lib/dnsruby/resource/IN.rb +21 -2
  35. data/lib/dnsruby/resource/MX.rb +2 -2
  36. data/lib/dnsruby/resource/NSEC.rb +6 -10
  37. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  38. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  39. data/lib/dnsruby/resource/OPT.rb +2 -3
  40. data/lib/dnsruby/resource/RR.rb +108 -65
  41. data/lib/dnsruby/resource/RRSIG.rb +5 -5
  42. data/lib/dnsruby/resource/RRSet.rb +38 -22
  43. data/lib/dnsruby/resource/SRV.rb +2 -2
  44. data/lib/dnsruby/resource/SVCB.rb +775 -0
  45. data/lib/dnsruby/resource/TSIG.rb +266 -231
  46. data/lib/dnsruby/resource/TXT.rb +34 -42
  47. data/lib/dnsruby/resource/domain_name.rb +1 -1
  48. data/lib/dnsruby/resource/generic.rb +31 -0
  49. data/lib/dnsruby/resource/generic_cache.rb +384 -0
  50. data/lib/dnsruby/resource/generic_constants.rb +79 -0
  51. data/lib/dnsruby/select_thread.rb +986 -291
  52. data/lib/dnsruby/serial_time.rb +25 -0
  53. data/lib/dnsruby/single_resolver.rb +25 -6
  54. data/lib/dnsruby/single_verifier.rb +1620 -530
  55. data/lib/dnsruby/transport_clock.rb +9 -0
  56. data/lib/dnsruby/update.rb +8 -6
  57. data/lib/dnsruby/validation_budget.rb +143 -0
  58. data/lib/dnsruby/validator_thread.rb +139 -58
  59. data/lib/dnsruby/version.rb +1 -1
  60. data/lib/dnsruby/zone_reader.rb +196 -129
  61. data/lib/dnsruby/zone_transfer.rb +157 -25
  62. data/lib/dnsruby.rb +7 -2
  63. data/test/ci_watchdog.rb +33 -0
  64. data/test/fixtures/org-ds-2026-11-05.zone +5 -0
  65. data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
  66. data/test/fork_test_helper.rb +37 -0
  67. data/test/local_dns_server.rb +57 -0
  68. data/test/soak_server.rb +152 -0
  69. data/test/socket_helpers.rb +26 -0
  70. data/test/spec_helper.rb +8 -13
  71. data/test/tc_attempt_configuration.rb +137 -0
  72. data/test/tc_caa.rb +2 -2
  73. data/test/tc_cache_keys.rb +36 -0
  74. data/test/tc_cancelled_send_capacity.rb +158 -0
  75. data/test/tc_dnssec_alias_validation.rb +289 -0
  76. data/test/tc_dnssec_budget.rb +489 -0
  77. data/test/tc_dnssec_cache_bounds.rb +197 -0
  78. data/test/tc_dnssec_cache_lifetime.rb +615 -0
  79. data/test/tc_dnssec_denial_proofs.rb +285 -0
  80. data/test/tc_dnssec_recursion_limits.rb +186 -0
  81. data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
  82. data/test/tc_dnssec_trust_anchors.rb +286 -0
  83. data/test/tc_dnssec_trust_races.rb +147 -0
  84. data/test/tc_dnssec_wildcard_proofs.rb +249 -0
  85. data/test/tc_ds.rb +5 -5
  86. data/test/tc_edns_retry_regressions.rb +255 -0
  87. data/test/tc_escapedchars.rb +8 -0
  88. data/test/tc_final_review_fixes.rb +167 -0
  89. data/test/tc_final_transport_contracts.rb +104 -0
  90. data/test/tc_generic_cache.rb +852 -0
  91. data/test/tc_header.rb +26 -0
  92. data/test/tc_ipv6.rb +120 -0
  93. data/test/tc_key_cache_identity.rb +382 -0
  94. data/test/tc_malformed_tcp.rb +712 -0
  95. data/test/tc_name.rb +99 -5
  96. data/test/tc_nameserver_bootstrap.rb +322 -0
  97. data/test/tc_nsec.rb +4 -45
  98. data/test/tc_nsec3.rb +17 -1
  99. data/test/tc_pipeline_retirement.rb +358 -0
  100. data/test/tc_pipeline_retry.rb +462 -0
  101. data/test/tc_pipeline_send.rb +663 -0
  102. data/test/tc_protocol_review_fixes.rb +95 -0
  103. data/test/tc_query_control.rb +90 -0
  104. data/test/tc_record_codec_contracts.rb +107 -0
  105. data/test/tc_record_regressions.rb +225 -0
  106. data/test/tc_recur.rb +1 -0
  107. data/test/tc_recursor_address_fallback.rb +169 -0
  108. data/test/tc_recursor_limits.rb +139 -0
  109. data/test/tc_release_auxiliary_fixes.rb +288 -0
  110. data/test/tc_release_configuration_fixes.rb +612 -0
  111. data/test/tc_release_dnssec_completion.rb +405 -0
  112. data/test/tc_release_dnssec_fixes.rb +305 -0
  113. data/test/tc_release_negative_cache.rb +327 -0
  114. data/test/tc_release_recursor_cache.rb +212 -0
  115. data/test/tc_release_review_transport.rb +372 -0
  116. data/test/tc_release_review_transport_native.rb +56 -0
  117. data/test/tc_release_transport.rb +906 -0
  118. data/test/tc_release_transport_fixes.rb +196 -0
  119. data/test/tc_release_zone_defaults.rb +157 -0
  120. data/test/tc_release_zone_ttls.rb +39 -0
  121. data/test/tc_res_config.rb +53 -2
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +34 -19
  124. data/test/tc_resolver_concurrency.rb +383 -0
  125. data/test/tc_resolver_configuration_contracts.rb +31 -0
  126. data/test/tc_resolver_validation.rb +211 -0
  127. data/test/tc_review_cache.rb +163 -0
  128. data/test/tc_review_code_mapper.rb +279 -0
  129. data/test/tc_review_control_deadlines.rb +169 -0
  130. data/test/tc_review_followup_transport.rb +583 -0
  131. data/test/tc_review_packet_contracts.rb +369 -0
  132. data/test/tc_review_presentation.rb +213 -0
  133. data/test/tc_review_record_compatibility.rb +101 -0
  134. data/test/tc_review_transport_fixes.rb +195 -0
  135. data/test/tc_review_transport_native.rb +415 -0
  136. data/test/tc_review_validation_priming.rb +178 -0
  137. data/test/tc_rr-opt.rb +5 -2
  138. data/test/tc_rr-unknown.rb +19 -0
  139. data/test/tc_rr.rb +14 -0
  140. data/test/tc_rrset_grouping.rb +50 -0
  141. data/test/tc_rrset_review_fixes.rb +58 -0
  142. data/test/tc_rrsig.rb +21 -1
  143. data/test/tc_scoped_ipv6.rb +153 -0
  144. data/test/tc_scoped_ipv6_select.rb +157 -0
  145. data/test/tc_scoped_ipv6_transport.rb +329 -0
  146. data/test/tc_selector_wakeup.rb +42 -0
  147. data/test/tc_soak.rb +118 -196
  148. data/test/tc_socket_helpers.rb +90 -0
  149. data/test/tc_supporting_lookup_limits.rb +252 -0
  150. data/test/tc_svcb.rb +1043 -0
  151. data/test/tc_svcb_signed_target.rb +145 -0
  152. data/test/tc_svcb_transport.rb +120 -0
  153. data/test/tc_tcp.rb +69 -43
  154. data/test/tc_tcp_pipelining.rb +95 -204
  155. data/test/tc_transport_fork.rb +179 -0
  156. data/test/tc_tsig.rb +2 -2
  157. data/test/tc_tsig_api.rb +251 -0
  158. data/test/tc_tsig_rfc8945.rb +357 -0
  159. data/test/tc_v2_release_dnssec.rb +442 -0
  160. data/test/tc_v2_release_transport.rb +304 -0
  161. data/test/tc_v2_zone_transfer_native.rb +58 -0
  162. data/test/tc_zone_reader.rb +217 -1
  163. data/test/tcp_pipelining_server.rb +150 -0
  164. data/test/ts_offline.rb +77 -6
  165. data/test/ts_online.rb +2 -1
  166. metadata +127 -14
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,351 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ # Ordinary records and local doubles keep these contracts independent of DNS.
7
+ class DnssecRecursorReviewFixesTest < Minitest::Test
8
+ def test_child_budgets_share_work_and_transaction_but_cancel_independently
9
+ root = Dnsruby::ValidationBudget.new(limits: limits(max_work: 2))
10
+ first, second = root.child, root.child
11
+ assert_same(root.transaction, first.transaction)
12
+ assert_same(root.transaction, second.transaction)
13
+ first.check!(1)
14
+ first.cancel
15
+ assert_raises(Dnsruby::ValidationCancelled) { first.check! }
16
+ assert(root.check!)
17
+ second.check!(1)
18
+ assert_raises(Dnsruby::ValidationLimitError) { second.check!(1) }
19
+ assert_raises(Dnsruby::ValidationLimitError) { root.check! }
20
+ end
21
+
22
+ def test_parallel_children_share_signature_allowance
23
+ root = Dnsruby::ValidationBudget.new(limits: limits(max_signature_verifications: 2))
24
+ threads = 2.times.map { Thread.new { root.child.signature! } }
25
+ assert(threads.all? { |thread| thread.join(2) })
26
+ threads.each(&:value)
27
+ assert_raises(Dnsruby::ValidationLimitError) { root.signature! }
28
+ ensure
29
+ threads&.each { |thread| thread.kill if thread.alive?; thread.join }
30
+ end
31
+
32
+ def test_parent_cancellation_reaches_children
33
+ root = Dnsruby::ValidationBudget.new
34
+ child = root.child
35
+ root.cancel
36
+ assert_raises(Dnsruby::ValidationCancelled) { child.check! }
37
+ end
38
+
39
+ def test_validator_hops_use_shared_budget_without_cancelling_the_lookup
40
+ root = Dnsruby::ValidationBudget.new(limits: limits(max_signature_verifications: 2))
41
+ transmissions = Dnsruby::SelectThread::TransmissionBudget.new
42
+ transmissions.context(:validation) { root }
43
+ controls = []
44
+ selector = Object.new
45
+ selector.define_singleton_method(:register_validation) { |_, _, control| controls << control; true }
46
+ selector.define_singleton_method(:unregister_validation) { |_, _, control| control.cancel }
47
+ selector.define_singleton_method(:push_validation_response_to_select) { |*_| }
48
+ query = Dnsruby::Message.new('www.example.')
49
+ query.do_validation = true
50
+ resolver = Struct.new(:dnssec).new(true)
51
+ validation = lambda do |_, response|
52
+ budget = Dnsruby::ValidationBudget.current
53
+ assert_same(root.transaction, budget.transaction)
54
+ refute_same(root.control, budget.control)
55
+ budget.signature!
56
+ response.security_level = Dnsruby::Message::SecurityLevel.SECURE
57
+ true
58
+ end
59
+ Dnsruby::Dnssec.stub(:validate_with_query, validation) do
60
+ Dnsruby::PacketSender.stub(:cache, nil) do
61
+ 2.times do
62
+ response = answer('www.example.', 'www.example. 60 IN A 192.0.2.1')
63
+ Dnsruby::ValidatorThread.new(:client, Queue.new, response, nil, query,
64
+ selector, resolver, transmissions).do_validate
65
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, response.security_level)
66
+ assert(root.check!)
67
+ end
68
+ end
69
+ end
70
+ refute_same(*controls)
71
+ assert_raises(Dnsruby::ValidationLimitError) { root.signature! }
72
+ end
73
+
74
+ def test_alias_query_keeps_original_question_and_one_budget_through_final_validation
75
+ packets = {
76
+ 'www.example.' => answer('www.example.', 'www.example. 60 IN CNAME target.other.'),
77
+ 'target.other.' => answer('target.other.', 'target.other. 60 IN A 192.0.2.1')
78
+ }
79
+ with_recursor(packets) do |recursor, calls, budgets|
80
+ validation = lambda do |response|
81
+ assert_same(budgets.first, Dnsruby::ValidationBudget.current)
82
+ assert_equal('www.example.', response.question.first.qname.to_s(true))
83
+ true
84
+ end
85
+ Dnsruby::Dnssec.stub(:validate, validation) do
86
+ response = recursor.query('www.example')
87
+ assert_equal(%w[www.example. target.other.], calls)
88
+ assert_equal([Dnsruby::Types.CNAME, Dnsruby::Types.A], response.answer.map(&:type))
89
+ assert_equal(1, budgets.map(&:object_id).uniq.length)
90
+ refute(response.header.aa)
91
+ end
92
+ end
93
+ assert_nil(Dnsruby::ValidationBudget.current)
94
+ assert_nil(Dnsruby::QueryControl.current)
95
+ end
96
+
97
+ def test_complete_alias_answer_uses_one_hop
98
+ response = answer('www.example.', 'www.example. 60 IN CNAME target.example.',
99
+ 'target.example. 60 IN A 192.0.2.1')
100
+ with_recursor('www.example.' => response) do |recursor, calls|
101
+ assert_same(response, recursor.query('www.example.', 'A', 'IN', true))
102
+ assert_equal(['www.example.'], calls)
103
+ end
104
+ end
105
+
106
+ def test_combining_alias_answers_preserves_the_earliest_authenticated_lifetime
107
+ first = answer('www.example.', 'www.example. 60 IN CNAME target.other.')
108
+ last = answer('target.other.', 'target.other. 60 IN A 192.0.2.1')
109
+ first.instance_variable_set(:@dnsruby_authenticated_expiration, 100)
110
+ last.instance_variable_set(:@dnsruby_authenticated_expiration, 200)
111
+ with_recursor('www.example.' => first, 'target.other.' => last) do |recursor|
112
+ response = recursor.query('www.example.', 'A', 'IN', true)
113
+ assert_equal(100, response.instance_variable_get(:@dnsruby_authenticated_expiration))
114
+ end
115
+ end
116
+
117
+ def test_independent_queries_get_independent_validation_budgets
118
+ with_recursor('www.example.' => answer('www.example.', 'www.example. 60 IN A 192.0.2.1')) do |recursor, _, budgets|
119
+ Dnsruby::Dnssec.stub(:validate, true) do
120
+ 2.times { assert(recursor.query('www.example.')) }
121
+ end
122
+ refute_same(*budgets)
123
+ refute_same(budgets.first.transaction, budgets.last.transaction)
124
+ end
125
+ end
126
+
127
+ def test_dname_chase_preserves_synthesized_cname_and_original_question
128
+ packets = {
129
+ 'host.branch.example.' => answer('host.branch.example.', 'branch.example. 60 IN DNAME other.'),
130
+ 'host.other.' => answer('host.other.', 'host.other. 60 IN A 192.0.2.2')
131
+ }
132
+ with_recursor(packets) do |recursor, calls|
133
+ response = recursor.query('host.branch.example.', 'A', 'IN', true)
134
+ assert_equal(%w[host.branch.example. host.other.], calls)
135
+ assert_equal('host.branch.example.', response.question.first.qname.to_s(true))
136
+ cname = response.answer.find { |rr| rr.type == Dnsruby::Types.CNAME }
137
+ assert_equal('host.other.', cname.domainname.to_s(true))
138
+ assert_equal(60, cname.ttl)
139
+ end
140
+ end
141
+
142
+ def test_explicit_alias_queries_do_not_chase
143
+ ['CNAME', 'DNAME'].each do |type|
144
+ packet = answer('alias.example.', "alias.example. 60 IN #{type} target.other.")
145
+ with_recursor('alias.example.' => packet) do |recursor, calls|
146
+ assert_same(packet, recursor.query('alias.example.', type, 'IN', true))
147
+ assert_equal(['alias.example.'], calls)
148
+ end
149
+ end
150
+ end
151
+
152
+ def test_nonvalidating_lookup_does_not_create_a_dnssec_budget
153
+ with_recursor('www.example.' => answer('www.example.', 'www.example. 60 IN A 192.0.2.1')) do |recursor, _, budgets|
154
+ Dnsruby::ValidationBudget.stub(:new, ->(*) { flunk('Nonvalidating recursion created a DNSSEC budget') }) do
155
+ assert(recursor.query('www.example.', 'A', 'IN', true))
156
+ end
157
+ assert_equal([nil], budgets)
158
+ end
159
+ end
160
+
161
+ def test_alias_steps_use_the_recursion_budget
162
+ packets = {
163
+ 'www.example.' => answer('www.example.', 'www.example. 60 IN CNAME target.other.'),
164
+ 'target.other.' => answer('target.other.', 'target.other. 60 IN CNAME final.other.')
165
+ }
166
+ with_recursor(packets) do |recursor, calls|
167
+ recursor.recursion_limits = { max_steps: 1 }
168
+ assert_raises(Dnsruby::RecursionLimitError) { recursor.query('www.example.', 'A', 'IN', true) }
169
+ assert_equal(%w[www.example. target.other.], calls)
170
+ end
171
+ end
172
+
173
+ def test_zero_ttl_addresses_are_local_and_snapshots_are_independent
174
+ cache = Dnsruby::Recursor::AddressCache.new
175
+ cache.push(['192.0.2.1', 0])
176
+ cache.push(['192.0.2.2', 60])
177
+ assert_equal(%w[192.0.2.2 192.0.2.1], cache.current_values)
178
+ assert_equal(['192.0.2.2'], cache.values)
179
+ snapshot = cache.snapshot
180
+ assert_equal(['192.0.2.2'], snapshot.current_values)
181
+ cache.push(['192.0.2.3', 60])
182
+ assert_equal(['192.0.2.2'], snapshot.values)
183
+ end
184
+
185
+ def test_zero_ttl_referral_glue_is_used_without_entering_shared_caches
186
+ with_private_caches do
187
+ recursor = bare_recursor
188
+ referral = Dnsruby::Message.new('www.child.example.')
189
+ referral.add_authority(Dnsruby::RR.create('child.example. 60 IN NS ns.child.example.'))
190
+ referral.add_additional(Dnsruby::RR.create('ns.child.example. 0 IN A 192.0.2.1'))
191
+ zone, authorities, ttl = recursor.referral_authorities(referral, 'example.', 'www.child.example.', 'IN')
192
+ Dnsruby::Recursor.send(:cache_zone, zone, authorities, ttl)
193
+ result = answer('www.child.example.', 'www.child.example. 60 IN A 192.0.2.2')
194
+ sends = []
195
+ factory = lambda do |settings|
196
+ sends << settings[:nameserver]
197
+ fake_resolver { |_| result }
198
+ end
199
+ Dnsruby::Resolver.stub(:new, factory) do
200
+ assert_same(result, recursor.send(:_dorecursion, 'www.child.example.', 'A', 'IN', zone, authorities, 0, true))
201
+ end
202
+ assert_equal([['192.0.2.1']], sends)
203
+ assert_empty(Dnsruby::Recursor.class_variable_get(:@@authority_cache).fetch('ns.child.example.').current_values)
204
+ shared = Dnsruby::Recursor.class_variable_get(:@@zones_cache).fetch(zone)
205
+ assert_empty(shared.fetch('ns.child.example.').current_values)
206
+ end
207
+ end
208
+
209
+ def test_zero_ttl_discovered_nameserver_address_is_used_once
210
+ with_private_caches do
211
+ hints = Dnsruby::Recursor::AddressCache.new
212
+ hints.push(['192.0.2.53', 60])
213
+ Dnsruby::Recursor.class_variable_set(:@@hints, { 'root.example.' => hints })
214
+ recursor = bare_recursor
215
+ sent = []
216
+ factory = lambda do |settings|
217
+ fake_resolver do |query|
218
+ name = query.question.first.qname.to_s(true)
219
+ sent << [name, settings[:nameserver]]
220
+ name == 'ns.other.' ? answer(name, 'ns.other. 0 IN A 192.0.2.1') :
221
+ answer(name, 'www.child.example. 60 IN A 192.0.2.2')
222
+ end
223
+ end
224
+ authorities = { 'ns.other.' => Dnsruby::Recursor::AddressCache.new }
225
+ Dnsruby::Resolver.stub(:new, factory) do
226
+ assert(recursor.send(:_dorecursion, 'www.child.example.', 'A', 'IN', 'child.example.', authorities, 0, true))
227
+ end
228
+ assert_equal([['ns.other.', ['192.0.2.53']], ['www.child.example.', ['192.0.2.1']]], sent)
229
+ assert_empty(Dnsruby::Recursor.class_variable_get(:@@authority_cache).fetch('ns.other.').current_values)
230
+ end
231
+ end
232
+
233
+ def test_signature_serial_time_across_the_epoch_boundary_and_cache_expiry
234
+ epoch = 1 << 32
235
+ now = epoch + 20
236
+ private_key = OpenSSL::PKey::EC.generate('prime256v1')
237
+ public_key = private_key.public_key.to_octet_string(:uncompressed).byteslice(1, 64)
238
+ key = Dnsruby::RR.create("example. 300 IN DNSKEY 257 3 13 #{Base64.strict_encode64(public_key)}")
239
+ signature = Dnsruby::RR.create(name: key.name, type: 'RRSIG', ttl: 300,
240
+ type_covered: 'DNSKEY', algorithm: 13, labels: 1, original_ttl: 300,
241
+ inception: epoch - 60, expiration: 80, key_tag: key.key_tag, signers_name: key.name)
242
+ wire = Dnsruby::MessageEncoder.new { |encoder| encoder.put_rr(key, true) }.to_s
243
+ der = private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + wire))
244
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
245
+ records = Dnsruby::RRSet.new([key, signature])
246
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
247
+ cache = Dnsruby::KeyCache.new
248
+ Time.stub(:now, Time.at(now)) do
249
+ assert(verifier.verify_rrset(records, key))
250
+ assert_equal(60, records.ttl)
251
+ cache.add_rrset(records)
252
+ assert_equal(epoch + 80, cache.keys_and_expirations.first.first)
253
+ end
254
+ Time.stub(:now, Time.at(epoch + 79)) do
255
+ assert_equal([Dnsruby::KeyCache.key_identity(key)], cache.keys.map { |item| Dnsruby::KeyCache.key_identity(item) })
256
+ end
257
+ Time.stub(:now, Time.at(epoch + 80)) { assert_empty(cache.keys) }
258
+ refute(Dnsruby::SerialTime.valid?(epoch - 60, 80, epoch + 81))
259
+ refute(Dnsruby::SerialTime.valid?(epoch - 60, 80, epoch - 61))
260
+ assert_nil(Dnsruby::SerialTime.absolute(now + (1 << 31), now))
261
+ end
262
+
263
+ def test_provisional_insecure_and_missing_cached_keys_reach_full_policy
264
+ [:insecure, Dnsruby::VerifyError.new('Signing key not found')].each do |cached|
265
+ with_full_validation(cached) do |query, response, calls|
266
+ assert(Dnsruby::Dnssec.validate_with_query(query, response))
267
+ assert_equal([:full], calls)
268
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, response.security_level)
269
+ end
270
+ end
271
+ end
272
+
273
+ def test_full_policy_bogus_is_preserved_after_provisional_insecure
274
+ with_full_validation(:insecure, bogus: true) do |query, response, calls|
275
+ assert_raises(Dnsruby::VerifyError) { Dnsruby::Dnssec.validate_with_query(query, response) }
276
+ assert_equal([:full], calls)
277
+ assert_equal(Dnsruby::Message::SecurityLevel.BOGUS, response.security_level)
278
+ end
279
+ end
280
+
281
+ private
282
+
283
+ def limits(**changes)
284
+ Dnsruby::ValidationBudget::DEFAULT_LIMITS.merge(changes)
285
+ end
286
+
287
+ def answer(name, *records)
288
+ Dnsruby::Message.new(name).tap do |packet|
289
+ packet.header.qr = packet.header.aa = true
290
+ records.each { |record| packet.add_answer(Dnsruby::RR.create(record)) }
291
+ end
292
+ end
293
+
294
+ def bare_recursor
295
+ Dnsruby::Recursor.allocate.tap do |recursor|
296
+ recursor.instance_variable_set(:@resolver, Struct.new(:query_timeout, :recurse).new(10, false))
297
+ recursor.instance_variable_set(:@ipv6_ok, false)
298
+ recursor.instance_variable_set(:@dnssec, false)
299
+ end
300
+ end
301
+
302
+ def with_recursor(packets)
303
+ recursor, calls, budgets = bare_recursor, [], []
304
+ recursor.define_singleton_method(:recursion_cache_scope) { |_| nil }
305
+ recursor.define_singleton_method(:get_closest_known_zone_authorities_for) { |_| ['.', {}] }
306
+ recursor.define_singleton_method(:_dorecursion) do |name, *_|
307
+ calls << name
308
+ budgets << Dnsruby::ValidationBudget.current
309
+ packets.fetch(name)
310
+ end
311
+ Dnsruby::Recursor.stub(:ensure_hints, 0) { yield recursor, calls, budgets }
312
+ end
313
+
314
+ def fake_resolver(&send_message)
315
+ Struct.new(:dnssec, :recurse).new.tap do |resolver|
316
+ resolver.define_singleton_method(:send_message) { |query| send_message.call(query) }
317
+ resolver.define_singleton_method(:close) {}
318
+ end
319
+ end
320
+
321
+ def with_private_caches
322
+ fields = [:@@authority_cache, :@@zones_cache, :@@zone_expirations, :@@hints]
323
+ saved = fields.to_h { |field| [field, Dnsruby::Recursor.class_variable_get(field)] }
324
+ fields.each { |field| Dnsruby::Recursor.class_variable_set(field, {}) }
325
+ yield
326
+ ensure
327
+ saved.each { |field, value| Dnsruby::Recursor.class_variable_set(field, value) }
328
+ end
329
+
330
+ def with_full_validation(cached, bogus: false)
331
+ previous = Dnsruby::Dnssec.validation_policy
332
+ Dnsruby::Dnssec.validation_policy = Dnsruby::Dnssec::ValidationPolicy::ALWAYS_LOCAL_ANCHORS_ONLY
333
+ query = Dnsruby::Message.new('www.example.')
334
+ query.header.cd = true
335
+ response = answer('www.example.', 'www.example. 60 IN A 192.0.2.1')
336
+ response.add_answer(Dnsruby::RR.create(name: 'www.example.', type: 'RRSIG'))
337
+ calls = []
338
+ fast = ->(*) { cached.is_a?(Exception) ? raise(cached) : cached }
339
+ full = lambda do |message, _|
340
+ calls << :full
341
+ raise Dnsruby::VerifyError, 'Full validation failed' if bogus
342
+ message.security_level = Dnsruby::Message::SecurityLevel.SECURE
343
+ true
344
+ end
345
+ Dnsruby::Dnssec.stub(:verify_result, fast) do
346
+ Dnsruby::Dnssec.stub(:validate_with_anchors, full) { yield query, response, calls }
347
+ end
348
+ ensure
349
+ Dnsruby::Dnssec.validation_policy = previous
350
+ end
351
+ end
@@ -0,0 +1,286 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'open3'
4
+ require 'rbconfig'
5
+
6
+ class DnssecTrustAnchorsTest < Minitest::Test
7
+ FIXTURE_DIRECTORY = File.expand_path('fixtures', __dir__)
8
+ FIXTURE_TIME = Time.utc(2026, 11, 5, 12)
9
+ ROOT_DS = [
10
+ '. IN DS 20326 8 2 E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D',
11
+ '. IN DS 38696 8 2 683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16'
12
+ ].freeze
13
+
14
+ def setup
15
+ Dnsruby::Dnssec.reset
16
+ @root_keys = read_fixture('root-dnskey')
17
+ @org_ds = read_fixture('org-ds')
18
+ end
19
+
20
+ def teardown
21
+ Dnsruby::Dnssec.reset
22
+ end
23
+
24
+ def test_cold_start_authenticates_ksk_2024_without_reset
25
+ # A new process exercises the initialization path without a reset or any
26
+ # previously authenticated keys, regardless of the suite's random order.
27
+ script = <<~'RUBY'
28
+ require 'dnsruby'
29
+ require 'minitest/mock'
30
+ records = File.readlines(ARGV.fetch(0)).reject { |line| line.start_with?(';') || line.strip.empty? }
31
+ keys = Dnsruby::RRSet.new(records.map { |line| Dnsruby::RR.create(line) })
32
+ Time.stub(:now, Time.utc(2026, 11, 5, 12)) do
33
+ verifier = Dnsruby::Dnssec.root_verifier
34
+ abort 'unexpected startup anchors' unless verifier.trust_anchors.map(&:key_tag).sort == [20326, 38696]
35
+ abort 'startup cache is warm' unless verifier.get_keys_to_check.empty?
36
+ abort 'fixture must have only the KSK-2024 signature' unless keys.sigs.map(&:key_tag) == [38696]
37
+ abort 'KSK-2024 validation failed' unless verifier.verify_key_rrset(keys)
38
+ end
39
+ RUBY
40
+ stdout, stderr, status = Open3.capture3(
41
+ RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), '-e', script,
42
+ File.join(FIXTURE_DIRECTORY, 'root-dnskey-2026-11-05.zone')
43
+ )
44
+ assert(status.success?, "Cold-start validation failed:\n#{stdout}\n#{stderr}")
45
+ assert_empty(stderr)
46
+ end
47
+
48
+ def test_reset_restores_only_current_anchors_and_authenticates_ksk_2024
49
+ Time.stub(:now, FIXTURE_TIME) do
50
+ 2.times do
51
+ Dnsruby::Dnssec.reset
52
+ verifier = Dnsruby::Dnssec.root_verifier
53
+ assert_equal(ROOT_DS.map { |text| Dnsruby::RR.create(text) }, verifier.trust_anchors)
54
+ assert_empty(verifier.get_keys_to_check)
55
+ assert_equal([38696], @root_keys.sigs.map(&:key_tag))
56
+ assert(verifier.verify_key_rrset(@root_keys))
57
+ end
58
+ end
59
+ end
60
+
61
+ def test_ksk_2017_alone_cannot_validate_the_fixture
62
+ Time.stub(:now, FIXTURE_TIME) do
63
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ROOT)
64
+ verifier.add_root_ds(Dnsruby::RR.create(ROOT_DS.first))
65
+ error = assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
66
+ assert_match(/Signing key not found/, error.message)
67
+ end
68
+ end
69
+
70
+ def test_verify_rrset_accepts_a_single_ds_or_dlv_without_bypassing_authentication
71
+ Time.stub(:now, FIXTURE_TIME) do
72
+ [Dnsruby::SingleVerifier::VerifierType::ROOT, Dnsruby::SingleVerifier::VerifierType::DLV].each do |mode|
73
+ type = mode == Dnsruby::SingleVerifier::VerifierType::DLV ? 'DLV' : 'DS'
74
+ ds = Dnsruby::RR.create(ROOT_DS.last.sub(' DS ', " #{type} "))
75
+ [ds, Dnsruby::RRSet.new([ds])].each do |keys|
76
+ verifier = Dnsruby::SingleVerifier.new(mode)
77
+ assert(verifier.verify_rrset(@root_keys, keys), "#{mode}: #{keys.class}")
78
+ end
79
+
80
+ wrong_digest = Dnsruby::RR.create(ROOT_DS.last.sub(' DS ', " #{type} ").sub('683D', '0000'))
81
+ verifier = Dnsruby::SingleVerifier.new(mode)
82
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_rrset(@root_keys, wrong_digest) }
83
+ assert_empty(verifier.get_keys_to_check)
84
+
85
+ corrupted = read_fixture('root-dnskey')
86
+ signature = corrupted.sigs.first.signature.dup
87
+ signature.setbyte(0, signature.getbyte(0) ^ 1)
88
+ corrupted.sigs.first.signature = signature
89
+ verifier = Dnsruby::SingleVerifier.new(mode)
90
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_rrset(corrupted, ds) }
91
+ end
92
+ end
93
+ end
94
+
95
+ def test_corrupted_signature_is_rejected_with_cold_and_warm_caches
96
+ Time.stub(:now, FIXTURE_TIME) do
97
+ verifier = Dnsruby::Dnssec.root_verifier
98
+ corrupted = read_fixture('root-dnskey')
99
+ signature = corrupted.sigs.first.signature.dup
100
+ signature.setbyte(0, signature.getbyte(0) ^ 1)
101
+ corrupted.sigs.first.signature = signature
102
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(corrupted) }
103
+ zone_key = @root_keys.rrs.find { |key| !key.sep_key? }
104
+ refute_includes(verifier.get_keys_to_check.map(&:key_tag), zone_key.key_tag)
105
+ assert(verifier.verify_key_rrset(@root_keys))
106
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(corrupted) }
107
+ Dnsruby::Dnssec.clear_trusted_keys
108
+ ROOT_DS.each { |text| verifier.add_root_ds(Dnsruby::RR.create(text)) }
109
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(corrupted) }
110
+ assert(verifier.verify_key_rrset(@root_keys))
111
+ end
112
+ end
113
+
114
+ def test_cache_clear_removes_root_anchors_and_learned_dnskeys_and_ds
115
+ Time.stub(:now, FIXTURE_TIME) do
116
+ verifier = Dnsruby::Dnssec.root_verifier
117
+ assert(verifier.verify_key_rrset(@root_keys))
118
+ assert(verifier.verify_ds_rrset(@org_ds))
119
+ assert_includes(verifier.trusted_keys, @org_ds.rrs.first)
120
+ refute_empty(verifier.get_keys_to_check)
121
+
122
+ Dnsruby::Dnssec.clear_trusted_keys
123
+
124
+ assert_empty(verifier.trust_anchors)
125
+ assert_empty(verifier.trusted_keys)
126
+ assert_empty(verifier.get_keys_to_check)
127
+ assert(Dnsruby::Dnssec.no_keys?)
128
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_ds_rrset(@org_ds) }
129
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
130
+ end
131
+ end
132
+
133
+ def test_cache_clear_retains_custom_dnskey_anchors_but_removes_ds_and_dlv_anchors
134
+ Time.stub(:now, FIXTURE_TIME) do
135
+ key = signing_key
136
+ ds = Dnsruby::RR.create(ROOT_DS.last)
137
+ dlv = Dnsruby::RR.create(ROOT_DS.last.sub(' DS ', ' DLV '))
138
+ Dnsruby::Dnssec.add_trust_anchor(key)
139
+ Dnsruby::Dnssec.add_trust_anchor(ds)
140
+ Dnsruby::Dnssec.dlv_verifier.add_trust_anchor(dlv)
141
+ assert(Dnsruby::Dnssec.anchor_verifier.verify_key_rrset(@root_keys))
142
+ assert(Dnsruby::Dnssec.dlv_verifier.verify_key_rrset(@root_keys))
143
+
144
+ Dnsruby::Dnssec.clear_trusted_keys
145
+
146
+ assert_equal([key], Dnsruby::Dnssec.trust_anchors)
147
+ assert_equal([key], Dnsruby::Dnssec.anchor_verifier.get_keys_to_check)
148
+ assert_empty(Dnsruby::Dnssec.dlv_verifier.trust_anchors)
149
+ assert_empty(Dnsruby::Dnssec.dlv_verifier.get_keys_to_check)
150
+ assert(Dnsruby::Dnssec.anchor_verifier.verify_key_rrset(@root_keys))
151
+ assert_raises(Dnsruby::VerifyError) { Dnsruby::Dnssec.dlv_verifier.verify_key_rrset(@root_keys) }
152
+ end
153
+ end
154
+
155
+ def test_public_clear_sequence_removes_local_and_root_trust
156
+ Time.stub(:now, FIXTURE_TIME) do
157
+ # Existing clients use this sequence before supplying their own isolated
158
+ # trust anchors. Retaining root trust here would change their validation.
159
+ Dnsruby::Dnssec.add_trust_anchor(signing_key)
160
+ Dnsruby::Dnssec.add_trust_anchor(Dnsruby::RR.create(ROOT_DS.last))
161
+ assert(Dnsruby::Dnssec.root_verifier.verify_key_rrset(@root_keys))
162
+ assert(Dnsruby::Dnssec.anchor_verifier.verify_key_rrset(@root_keys))
163
+
164
+ Dnsruby::Dnssec.clear_trust_anchors
165
+ Dnsruby::Dnssec.clear_trusted_keys
166
+
167
+ assert_empty(Dnsruby::Dnssec.trust_anchors)
168
+ assert_empty(Dnsruby::Dnssec.root_verifier.trust_anchors)
169
+ assert(Dnsruby::Dnssec.no_keys?)
170
+ assert_raises(Dnsruby::VerifyError) { Dnsruby::Dnssec.root_verifier.verify_key_rrset(@root_keys) }
171
+ assert_raises(Dnsruby::VerifyError) { Dnsruby::Dnssec.anchor_verifier.verify_key_rrset(@root_keys) }
172
+
173
+ Dnsruby::Dnssec.validation_policy = Dnsruby::Dnssec::ValidationPolicy::ALWAYS_LOCAL_ANCHORS_ONLY
174
+ query = Dnsruby::Message.new('.', 'DNSKEY')
175
+ message = Dnsruby::Message.new('.', 'DNSKEY')
176
+ @root_keys.rrs.each { |rr| message.add_answer(rr) }
177
+ @root_keys.sigs.each { |rr| message.add_answer(rr) }
178
+ Dnsruby::Dnssec.validate_with_query(query, message)
179
+ assert_equal('UNCHECKED', message.security_level.to_s)
180
+ end
181
+ end
182
+
183
+ def test_explicit_clear_removes_custom_anchors_and_derived_trust
184
+ Time.stub(:now, FIXTURE_TIME) do
185
+ Dnsruby::Dnssec.add_trust_anchor(signing_key)
186
+ Dnsruby::Dnssec.add_trust_anchor(Dnsruby::RR.create(ROOT_DS.last))
187
+ verifier = Dnsruby::Dnssec.anchor_verifier
188
+ assert(verifier.verify_key_rrset(@root_keys))
189
+ assert(verifier.verify_ds_rrset(@org_ds))
190
+
191
+ Dnsruby::Dnssec.clear_trust_anchors
192
+
193
+ assert_empty(Dnsruby::Dnssec.trust_anchors)
194
+ assert_empty(verifier.trusted_keys)
195
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
196
+ assert_equal([20326, 38696], Dnsruby::Dnssec.root_verifier.trust_anchors.map(&:key_tag))
197
+ end
198
+ end
199
+
200
+ def test_remove_ds_anchor_invalidates_derived_trust_and_retains_other_anchors
201
+ Time.stub(:now, FIXTURE_TIME) do
202
+ old_ds, new_ds = ROOT_DS.map { |text| Dnsruby::RR.create(text) }
203
+ Dnsruby::Dnssec.add_trust_anchor(old_ds)
204
+ Dnsruby::Dnssec.add_trust_anchor(new_ds)
205
+ verifier = Dnsruby::Dnssec.anchor_verifier
206
+ assert(verifier.verify_key_rrset(@root_keys))
207
+ assert(verifier.verify_ds_rrset(@org_ds))
208
+
209
+ Dnsruby::Dnssec.remove_trust_anchor(new_ds)
210
+
211
+ assert_equal([old_ds], Dnsruby::Dnssec.trust_anchors)
212
+ assert_equal([old_ds], verifier.trusted_keys)
213
+ assert_empty(verifier.get_keys_to_check)
214
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
215
+ end
216
+ end
217
+
218
+ def test_remove_equivalent_dnskey_anchor_invalidates_derived_trust
219
+ Time.stub(:now, FIXTURE_TIME) do
220
+ key = signing_key
221
+ Dnsruby::Dnssec.add_trust_anchor(key)
222
+ verifier = Dnsruby::Dnssec.anchor_verifier
223
+ assert(verifier.verify_key_rrset(@root_keys))
224
+
225
+ equivalent_key = Dnsruby::RR.create(key.to_s)
226
+ equivalent_key.ttl += 1 # TTL is not part of a DNSKEY's identity.
227
+ refute_same(key, equivalent_key)
228
+ Dnsruby::Dnssec.remove_trust_anchor(equivalent_key)
229
+
230
+ assert_empty(Dnsruby::Dnssec.trust_anchors)
231
+ assert_empty(verifier.trusted_keys)
232
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
233
+ end
234
+ end
235
+
236
+ def test_removing_unconfigured_key_with_same_tag_preserves_trust
237
+ Time.stub(:now, FIXTURE_TIME) do
238
+ key = signing_key
239
+ Dnsruby::Dnssec.add_trust_anchor(key)
240
+ verifier = Dnsruby::Dnssec.anchor_verifier
241
+ assert(verifier.verify_key_rrset(@root_keys))
242
+ cached_keys = verifier.trusted_keys
243
+ foreign_key = key.clone
244
+ foreign_key.name = 'example.' # Owner name is not included in the key tag.
245
+ assert_equal(key.key_tag, foreign_key.key_tag)
246
+
247
+ Dnsruby::Dnssec.remove_trust_anchor(foreign_key)
248
+
249
+ assert_equal([key], Dnsruby::Dnssec.trust_anchors)
250
+ assert_equal(cached_keys, verifier.trusted_keys)
251
+ assert(verifier.verify_key_rrset(@root_keys))
252
+ end
253
+ end
254
+
255
+ def test_explicit_remove_and_clear_work_for_dlv_anchors
256
+ Time.stub(:now, FIXTURE_TIME) do
257
+ verifier = Dnsruby::Dnssec.dlv_verifier
258
+ dlv = Dnsruby::RR.create(ROOT_DS.last.sub(' DS ', ' DLV '))
259
+ verifier.add_trust_anchor(dlv)
260
+ assert(verifier.verify_key_rrset(@root_keys))
261
+ verifier.remove_trust_anchor(dlv)
262
+ assert_empty(verifier.trust_anchors)
263
+ assert_empty(verifier.trusted_keys)
264
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
265
+
266
+ verifier.add_trust_anchor(dlv)
267
+ assert(verifier.verify_key_rrset(@root_keys))
268
+ verifier.clear_trust_anchors
269
+ assert_empty(verifier.trust_anchors)
270
+ assert_empty(verifier.trusted_keys)
271
+ assert_raises(Dnsruby::VerifyError) { verifier.verify_key_rrset(@root_keys) }
272
+ end
273
+ end
274
+
275
+ private
276
+
277
+ def read_fixture(name)
278
+ lines = File.readlines(File.join(FIXTURE_DIRECTORY, "#{name}-2026-11-05.zone"))
279
+ records = lines.reject { |line| line.start_with?(';') || line.strip.empty? }
280
+ Dnsruby::RRSet.new(records.map { |line| Dnsruby::RR.create(line) })
281
+ end
282
+
283
+ def signing_key
284
+ @root_keys.rrs.find { |key| key.key_tag == 38696 }
285
+ end
286
+ end