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,285 @@
1
+ require_relative 'tc_key_cache_identity'
2
+
3
+ class DnssecDenialProofsTest < Minitest::Test
4
+ def setup
5
+ @private_key = OpenSSL::PKey::EC.new(KeyCacheIdentityTest::PRIVATE_KEYS.first)
6
+ @zone = 'collision.invalid.'
7
+ public_bytes = @private_key.public_key.to_octet_string(:uncompressed).byteslice(1, 64)
8
+ @key = Dnsruby::RR.create("#{@zone} 300 IN DNSKEY 257 3 13 #{Base64.strict_encode64(public_bytes)}")
9
+ @verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
10
+ @verifier.add_trust_anchor(@key)
11
+ end
12
+
13
+ def test_cname_nodata_checks_final_target_with_nsec
14
+ message = response('alias.collision.invalid.', 'AAAA')
15
+ add_signed(message.answer, 'alias.collision.invalid. 300 IN CNAME target.collision.invalid.')
16
+ add_signed(message.authority, 'target.collision.invalid. 300 IN NSEC z.collision.invalid. TXT NSEC RRSIG')
17
+ assert(@verifier.verify(message))
18
+ remove_set(message.authority, 'target.collision.invalid.', Dnsruby::Types.NSEC)
19
+ add_signed(message.authority, 'target.collision.invalid. 300 IN NSEC z.collision.invalid. TXT AAAA NSEC RRSIG')
20
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
21
+ end
22
+
23
+ def test_partial_cname_response_and_positive_terminal_answer
24
+ message = Dnsruby::Message.new('alias.collision.invalid.', 'AAAA')
25
+ add_signed(message.answer, 'alias.collision.invalid. 300 IN CNAME target.collision.invalid.')
26
+ assert(@verifier.verify(message))
27
+ add_signed(message.answer, 'target.collision.invalid. 300 IN AAAA 2001:db8::1')
28
+ assert(@verifier.verify(message))
29
+ end
30
+
31
+ def test_partial_answer_requires_a_cname_from_the_question
32
+ message = Dnsruby::Message.new('alias.collision.invalid.', 'AAAA')
33
+ add_signed(message.answer, 'target.collision.invalid. 300 IN AAAA 2001:db8::1')
34
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
35
+ end
36
+
37
+ def test_any_nodata_requires_an_empty_name_but_positive_answers_can_be_subsets
38
+ message = response('target.collision.invalid.', 'ANY')
39
+ add_chain(message)
40
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
41
+
42
+ message = response('empty.collision.invalid.', 'ANY')
43
+ add_chain(message)
44
+ assert(@verifier.verify(message))
45
+
46
+ message = response('missing.wild.collision.invalid.', 'ANY')
47
+ add_chain(message)
48
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
49
+
50
+ message = Dnsruby::Message.new('target.collision.invalid.', 'ANY')
51
+ add_signed(message.answer, 'target.collision.invalid. 300 IN TXT "one RRset"')
52
+ assert(@verifier.verify(message))
53
+ end
54
+
55
+ def test_signed_ds_referral_does_not_require_a_signature_on_parent_side_ns
56
+ child = @key.clone
57
+ child.name = 'child.collision.invalid.'
58
+ message = Dnsruby::Message.new('www.child.collision.invalid.', 'A')
59
+ message.add_authority(Dnsruby::RR.create('child.collision.invalid. 300 IN NS ns.child.collision.invalid.'))
60
+ signed(Dnsruby::RR::DS.from_key(child, 2)).each { |rr| message.add_authority(rr) }
61
+ assert(@verifier.verify(message))
62
+ end
63
+
64
+ def test_nsec_consistency_accepts_normal_circular_chain_and_partial_proofs
65
+ message = response('a.collision.invalid.', 'AAAA')
66
+ add_signed(message.authority, 'collision.invalid. 300 IN NSEC a.collision.invalid. SOA NS NSEC RRSIG')
67
+ add_signed(message.authority, 'a.collision.invalid. 300 IN NSEC z.collision.invalid. A NSEC RRSIG')
68
+ add_signed(message.authority, 'z.collision.invalid. 300 IN NSEC collision.invalid. TXT NSEC RRSIG')
69
+ assert(@verifier.verify(message))
70
+ remove_set(message.authority, @zone, Dnsruby::Types.NSEC)
71
+ assert(@verifier.verify(message))
72
+ end
73
+
74
+ def test_signed_negative_soa_requires_denial
75
+ message = response('target.collision.invalid.', 'AAAA')
76
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
77
+ add_chain(message)
78
+ assert(@verifier.verify(message))
79
+ end
80
+
81
+ def test_nsec3_exact_nodata_and_empty_nonterminal
82
+ ['target.collision.invalid.', 'empty.collision.invalid.'].each do |name|
83
+ message = response(name, 'AAAA')
84
+ add_chain(message)
85
+ assert(@verifier.verify(message))
86
+ end
87
+ ['AAAA', 'CNAME'].each do |present|
88
+ message = response('target.collision.invalid.', 'AAAA')
89
+ add_chain(message, entries: zone_entries.merge('target.collision.invalid.' => present))
90
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
91
+ end
92
+ end
93
+
94
+ def test_nsec3_nxdomain_after_cname_uses_target_and_complete_closest_encloser
95
+ message = response('alias.collision.invalid.', 'AAAA', nxdomain: true)
96
+ add_signed(message.answer, 'alias.collision.invalid. 300 IN CNAME missing.collision.invalid.')
97
+ add_chain(message)
98
+ assert(@verifier.verify(message))
99
+ remove_set(message.authority, "#{hash_name(@zone)}.#{@zone}", Dnsruby::Types.NSEC3)
100
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
101
+ end
102
+
103
+ def test_nsec3_wildcard_nodata_and_nxdomain_are_distinct
104
+ message = response('missing.wild.collision.invalid.', 'AAAA')
105
+ add_chain(message)
106
+ assert(@verifier.verify(message))
107
+ message.header.rcode = Dnsruby::RCode.NXDOMAIN
108
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
109
+
110
+ message = response('missing.wild.collision.invalid.', 'MX')
111
+ add_chain(message)
112
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
113
+ end
114
+
115
+ def test_nsec3_nxdomain_and_ds_opt_out_are_insecure
116
+ message = response('missing.collision.invalid.', 'AAAA', nxdomain: true)
117
+ add_chain(message, flags: 1)
118
+ assert_equal(:insecure, @verifier.verify_result(message))
119
+ message = response('child.collision.invalid.', 'DS')
120
+ add_chain(message, flags: 1)
121
+ assert_equal(:insecure, @verifier.verify_result(message))
122
+
123
+ message = response('child.collision.invalid.', 'DS')
124
+ add_chain(message)
125
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
126
+ end
127
+
128
+ def test_nsec3_exact_ds_denial_and_referral
129
+ message = response('child.collision.invalid.', 'DS')
130
+ add_chain(message, entries: zone_entries.merge('child.collision.invalid.' => 'NS'))
131
+ assert(@verifier.verify(message))
132
+
133
+ message = Dnsruby::Message.new('www.child.collision.invalid.', 'A')
134
+ message.add_authority(Dnsruby::RR.create('child.collision.invalid. 300 IN NS ns.child.collision.invalid.'))
135
+ add_chain(message, entries: zone_entries.merge('child.collision.invalid.' => 'NS'))
136
+ assert_equal(:insecure, @verifier.verify_result(message))
137
+
138
+ message = response(@zone, 'DS')
139
+ add_chain(message)
140
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
141
+ end
142
+
143
+ def test_cname_to_unsigned_referral_validates_ds_denial_at_terminal_delegation
144
+ message = Dnsruby::Message.new('alias.collision.invalid.', 'A')
145
+ add_signed(message.answer, 'alias.collision.invalid. 300 IN CNAME www.child.collision.invalid.')
146
+ message.add_authority(Dnsruby::RR.create('child.collision.invalid. 300 IN NS ns.child.collision.invalid.'))
147
+ add_chain(message, entries: zone_entries.merge('child.collision.invalid.' => 'NS'))
148
+ assert_equal(:insecure, @verifier.verify_result(message))
149
+ end
150
+
151
+ def test_nsec3_wrong_zone_and_mixed_parameters_cannot_form_denial
152
+ foreign_key = @key.clone
153
+ foreign_key.name = 'other.invalid.'
154
+ @verifier.add_trust_anchor(foreign_key)
155
+ message = response('target.collision.invalid.', 'AAAA')
156
+ add_chain(message, key: foreign_key)
157
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
158
+
159
+ message = response('target.collision.invalid.', 'AAAA')
160
+ add_chain(message)
161
+ add_chain(message, salt: 'abcd')
162
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
163
+ end
164
+
165
+ def test_nsec3_closest_encloser_cannot_be_dname_or_unsigned_delegation
166
+ ['DNAME', 'NS'].each do |types|
167
+ message = response('missing.wild.collision.invalid.', 'AAAA')
168
+ add_chain(message, entries: zone_entries.merge('wild.collision.invalid.' => types))
169
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
170
+ end
171
+ end
172
+
173
+ def test_nsec3_hash_interval_excludes_endpoints_and_supports_wire_empty_salt
174
+ message = response('target.collision.invalid.', 'AAAA')
175
+ records = add_chain(message)
176
+ first = records.first
177
+ refute(first.check_name_in_range(zone_entries.keys.find { |name| hash_name(name) == first.owner_hash }))
178
+ decoded = Dnsruby::Message.decode(message.encode)
179
+ assert(@verifier.verify(decoded))
180
+ assert(records.any? { |record| record.check_name_in_range('missing.collision.invalid.') })
181
+ end
182
+
183
+ def test_nsec_and_its_signature_exist_independently_of_their_bitmap_bits
184
+ [[], %w[NSEC], %w[RRSIG], %w[NSEC RRSIG]].each do |bits|
185
+ record = Dnsruby::RR.create("target.collision.invalid. 300 IN NSEC z.collision.invalid. TXT #{bits.join(' ')}")
186
+ [Dnsruby::Types.NSEC, Dnsruby::Types.RRSIG].each do |type|
187
+ refute(@verifier.send(:denial_bitmap?, record, type))
188
+ end
189
+ assert(@verifier.send(:denial_bitmap?, record, Dnsruby::Types.AAAA))
190
+ end
191
+ end
192
+
193
+ def test_nsec3_future_flags_do_not_hide_the_supported_denial_chain
194
+ message = response('target.collision.invalid.', 'AAAA')
195
+ supported = add_chain(message)
196
+ add_chain(message, flags: 2, salt: 'abcd')
197
+ decoded = Dnsruby::Message.decode(message.encode)
198
+ assert(@verifier.verify(decoded))
199
+ remaining = decoded.authority.select { |record| record.type == Dnsruby::Types.NSEC3 }
200
+ assert_equal(supported.length, remaining.length)
201
+ assert(remaining.all? { |record| record.flags == 0 })
202
+ assert_equal(decoded.authority.length, decoded.header.nscount)
203
+ end
204
+
205
+ def test_nsec3_mixed_future_flags_keep_the_complete_signed_rrset
206
+ message = response('target.collision.invalid.', 'AAAA')
207
+ records = add_chain(message)
208
+ record = records.find { |rr| rr.owner_hash == hash_name('target.collision.invalid.') }
209
+ remove_set(message.authority, record.name.to_s, Dnsruby::Types.NSEC3)
210
+ future = record.clone
211
+ future.flags = 2
212
+ mixed = Dnsruby::RRSet.new([record, future])
213
+ signature = signed(record).sigs.first
214
+ bytes = @verifier.send(:canonical_rrset_data, mixed, signature, {})
215
+ der = @private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + bytes))
216
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
217
+ mixed.add(signature)
218
+ mixed.each { |rr| message.add_authority(rr) }
219
+ assert(@verifier.verify(message))
220
+ assert_equal([0, 2], message.authority.select { |rr| rr.type == Dnsruby::Types.NSEC3 && rr.name == record.name }.map(&:flags))
221
+ end
222
+
223
+ def test_dnskey_message_authenticates_each_rrset_once
224
+ message = Dnsruby::Message.new(@zone, 'DNSKEY')
225
+ signed(@key).each { |record| message.add_answer(record) }
226
+ calls = 0
227
+ original = @verifier.method(:verify_rrset_with_key)
228
+ @verifier.stub(:verify_rrset_with_key, lambda { |*args| calls += 1; original.call(*args) }) do
229
+ assert(@verifier.verify(message))
230
+ end
231
+ assert_equal(1, calls)
232
+ end
233
+
234
+ private
235
+
236
+ def remove_set(section, name, type)
237
+ name = Dnsruby::Name.create(name)
238
+ section.delete_if { |rr| rr.name == name && (rr.type == type || (rr.type == Dnsruby::Types.RRSIG && rr.type_covered == type)) }
239
+ end
240
+
241
+ def response(name, type, nxdomain: false)
242
+ Dnsruby::Message.new(name, type).tap do |message|
243
+ message.header.qr = true
244
+ message.header.rcode = Dnsruby::RCode.NXDOMAIN if nxdomain
245
+ add_signed(message.authority, "#{@zone} 300 IN SOA ns.#{@zone} hostmaster.#{@zone} 1 3600 600 86400 300")
246
+ end
247
+ end
248
+
249
+ def zone_entries
250
+ { @zone => 'SOA NS DNSKEY', 'alias.collision.invalid.' => 'CNAME',
251
+ 'target.collision.invalid.' => 'TXT', 'empty.collision.invalid.' => '',
252
+ 'wild.collision.invalid.' => 'TXT', '*.wild.collision.invalid.' => 'MX' }
253
+ end
254
+
255
+ def hash_name(name, salt = '-')
256
+ Dnsruby::RR::NSEC3.calculate_hash(name, 0, Dnsruby::RR::NSEC3.decode_salt(salt), 1)
257
+ end
258
+
259
+ # Build the ordinary sorted NSEC3 chain for a small complete test zone.
260
+ def add_chain(message, entries: zone_entries, flags: 0, salt: '-', key: @key)
261
+ owners = entries.map { |name, types| [hash_name(name, salt), types] }.sort
262
+ owners.each_with_index.map do |(owner, types), index|
263
+ following = owners[(index + 1) % owners.length].first
264
+ record = Dnsruby::RR.create("#{owner}.#{@zone} 300 IN NSEC3 1 #{flags} 0 #{salt} #{following} #{types}")
265
+ signed(record, key: key).each { |rr| message.add_authority(rr) }
266
+ record
267
+ end
268
+ end
269
+
270
+ def add_signed(section, text)
271
+ signed(Dnsruby::RR.create(text)).each { |record| section << record }
272
+ end
273
+
274
+ def signed(record, key: @key)
275
+ signature = Dnsruby::RR.create(name: record.name, type: 'RRSIG', ttl: record.ttl,
276
+ type_covered: record.type, algorithm: 13,
277
+ labels: record.name.labels.length - (record.name.wild? ? 1 : 0),
278
+ original_ttl: record.ttl, expiration: Time.now.to_i + 3600, inception: Time.now.to_i - 60,
279
+ key_tag: key.key_tag, signers_name: key.name)
280
+ bytes = Dnsruby::MessageEncoder.new { |encoder| encoder.put_rr(record, true) }.to_s
281
+ der = @private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + bytes))
282
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
283
+ Dnsruby::RRSet.new([record, signature])
284
+ end
285
+ end
@@ -0,0 +1,186 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+
4
+ # Discovery and completion boundaries use ordinary query metadata and resolver
5
+ # doubles. No sockets or DNS traffic are needed to check terminal limit errors.
6
+ class DnssecRecursionLimitsTest < Minitest::Test
7
+ def setup
8
+ @verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
9
+ @zone = Dnsruby::Name.create('example.')
10
+ @child = Dnsruby::Name.create('child.example.')
11
+ @anchor = metadata_double(name: @zone, klass: Dnsruby::Classes.IN, type: Dnsruby::Types.DNSKEY)
12
+ @limit = Dnsruby::RecursionLimitError.new('Recursive lookup work limit exceeded')
13
+ @queries = []
14
+ @saved_recursor = Dnsruby::Dnssec.do_validation_with_recursor?
15
+ Dnsruby::Dnssec.do_validation_with_recursor(false)
16
+ end
17
+
18
+ def teardown
19
+ Dnsruby::Dnssec.do_validation_with_recursor(@saved_recursor)
20
+ end
21
+
22
+ def test_ds_discovery_preserves_the_limit_without_caching_a_question_failure
23
+ resolver = exhausted_resolver
24
+ @verifier.stub(:get_nameservers_for, ->(*) { flunk('DS exhaustion must stop child discovery') }) do
25
+ error = assert_raises(Dnsruby::RecursionLimitError) do
26
+ @verifier.get_anchor_for(@child, @zone, @anchor, resolver)
27
+ end
28
+ assert_same(@limit, error)
29
+ assert_uncached_limit(error)
30
+ end
31
+ assert_equal([[@child, Dnsruby::Types.DS]], @queries)
32
+ end
33
+
34
+ def test_dnskey_discovery_preserves_the_limit_without_trying_another_key
35
+ resolver = exhausted_resolver
36
+ @verifier.stub(:get_nameservers_for, resolver) do
37
+ @verifier.stub(:verify, ->(*) { flunk('DNSKEY exhaustion must stop key fallback') }) do
38
+ error = assert_raises(Dnsruby::RecursionLimitError) do
39
+ @verifier.get_anchor_for(@zone, @zone, @anchor, resolver)
40
+ end
41
+ assert_same(@limit, error)
42
+ assert_uncached_limit(error)
43
+ end
44
+ end
45
+ assert_equal([[@zone, Dnsruby::Types.DNSKEY]], @queries)
46
+ end
47
+
48
+ def test_dlv_registry_key_discovery_preserves_the_limit
49
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::DLV)
50
+ verifier.stub(:get_dlv_resolver, exhausted_resolver) do
51
+ verifier.stub(:verify, ->(*) { flunk('An exhausted DLV lookup must not verify keys') }) do
52
+ error = assert_raises(Dnsruby::RecursionLimitError) { verifier.get_dlv_key(@anchor) }
53
+ assert_same(@limit, error)
54
+ assert_empty(verifier.trusted_keys)
55
+ end
56
+ end
57
+ assert_equal([[Dnsruby::Name.create('dlv.isc.org.'), Dnsruby::Types.DNSKEY]], @queries)
58
+ end
59
+
60
+ def test_dlv_zone_key_discovery_preserves_the_limit
61
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::DLV)
62
+ verifier.stub(:get_nameservers_for, exhausted_resolver) do
63
+ verifier.stub(:verify, ->(*) { flunk('An exhausted zone lookup must not verify keys') }) do
64
+ error = assert_raises(Dnsruby::RecursionLimitError) do
65
+ verifier.get_zone_key_from_dlv_rrset(metadata_double(type: Dnsruby::Types.DLV), @child)
66
+ end
67
+ assert_same(@limit, error)
68
+ assert_empty(verifier.trusted_keys)
69
+ end
70
+ end
71
+ assert_equal([[@child, Dnsruby::Types.DNSKEY]], @queries)
72
+ end
73
+
74
+ def test_dlv_record_discovery_stops_before_trying_the_next_ancestor
75
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::DLV)
76
+ verifier.stub(:get_dlv_resolver, exhausted_resolver) do
77
+ verifier.stub(:get_zone_key_from_dlv_rrset, ->(*) { flunk('Exhaustion must stop DLV discovery') }) do
78
+ error = assert_raises(Dnsruby::RecursionLimitError) { verifier.find_closest_dlv_anchor_for(@child) }
79
+ assert_same(@limit, error)
80
+ end
81
+ end
82
+ assert_equal([[Dnsruby::Name.create('child.example.dlv.isc.org.'), Dnsruby::Types.DLV]], @queries)
83
+ end
84
+
85
+ def test_exhausted_discovery_stops_validation_policy_fallbacks
86
+ previous_policy = Dnsruby::Dnssec.validation_policy
87
+ query = Dnsruby::Message.new(@child, 'A')
88
+ query.header.cd = true
89
+ resolver = exhausted_resolver
90
+ discover = ->(*) { @verifier.get_anchor_for(@child, @zone, @anchor, resolver) }
91
+ fallback = ->(*) { flunk('Aggregate exhaustion must not try another validation policy') }
92
+ policies = Dnsruby::Dnssec::ValidationPolicy
93
+ [[policies::LOCAL_ANCHORS_THEN_ROOT, :validate_with_anchors, :validate_with_root],
94
+ [policies::ROOT_THEN_LOCAL_ANCHORS, :validate_with_root, :validate_with_anchors]].each do |policy, first, second|
95
+ @queries.clear
96
+ Dnsruby::Dnssec.validation_policy = policy
97
+ Dnsruby::Dnssec.stub(first, discover) do
98
+ Dnsruby::Dnssec.stub(second, fallback) do
99
+ Dnsruby::Dnssec.stub(:validate_with_dlv, fallback) do
100
+ error = assert_raises(Dnsruby::RecursionLimitError) do
101
+ Dnsruby::Dnssec.validate_with_query(query, positive_response)
102
+ end
103
+ assert_same(@limit, error)
104
+ end
105
+ end
106
+ end
107
+ assert_equal([[@child, Dnsruby::Types.DS]], @queries)
108
+ end
109
+ ensure
110
+ Dnsruby::Dnssec.validation_policy = previous_policy
111
+ end
112
+
113
+ def test_async_validator_completion_preserves_the_limit_and_avoids_both_caches
114
+ worker = nil
115
+ query = Dnsruby::Message.new(@child, 'A')
116
+ query.do_validation = true
117
+ response = positive_response
118
+ completions, registrations, removals, positive_cache_calls = [], [], [], []
119
+ cache = Dnsruby::Cache::ResolutionCache.new
120
+ selector = Object.new
121
+ selector.define_singleton_method(:register_validation) { |*args| registrations << args; true }
122
+ selector.define_singleton_method(:unregister_validation) { |*args| removals << args }
123
+ selector.define_singleton_method(:push_validation_response_to_select) do |*args|
124
+ completions << args
125
+ cache.add(args[4], :test_scope, args[2], args[3])
126
+ end
127
+ resolver = metadata_double(dnssec: true)
128
+ validator = Dnsruby::ValidatorThread.new(:client, Queue.new, response, nil, query, selector, resolver)
129
+ Dnsruby::Dnssec.stub(:validate_with_query, ->(*) { raise @limit }) do
130
+ Dnsruby::PacketSender.stub(:cache, ->(*args) { positive_cache_calls << args }) do
131
+ worker = Thread.new { validator.do_validate }
132
+ assert(worker.join(2), 'Validator completion did not finish')
133
+ end
134
+ end
135
+ assert_equal(1, completions.length)
136
+ assert_same(@limit, completions.first[3])
137
+ assert_same(@limit, response.security_error)
138
+ assert_equal(Dnsruby::Message::SecurityLevel.BOGUS, response.security_level)
139
+ assert_empty(positive_cache_calls)
140
+ assert_nil(cache.find(query, :test_scope))
141
+ assert_equal(1, registrations.length)
142
+ assert_equal(registrations, removals)
143
+ assert_nil(worker[:dnsruby_validation_budget])
144
+ assert_nil(worker[:dnsruby_query_control])
145
+ ensure
146
+ worker&.kill if worker&.alive?
147
+ worker&.join
148
+ end
149
+
150
+ private
151
+
152
+ def exhausted_resolver
153
+ queries, error = @queries, @limit
154
+ Object.new.tap do |resolver|
155
+ resolver.define_singleton_method(:dnssec=) { |_| }
156
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
157
+ queries << [Dnsruby::Name.create(name).tap { |owner| owner.absolute = true }, type]
158
+ raise error
159
+ end
160
+ end
161
+ end
162
+
163
+ def positive_response
164
+ Dnsruby::Message.new(@child, 'A').tap do |message|
165
+ message.header.qr = true
166
+ message.add_answer(Dnsruby::RR.create('child.example. 60 IN A 192.0.2.1'))
167
+ end
168
+ end
169
+
170
+ def assert_uncached_limit(error)
171
+ query = Dnsruby::Message.new(@child, 'A')
172
+ [nil, positive_response].each do |response|
173
+ cache = Dnsruby::Cache::ResolutionCache.new
174
+ cache.add(query, :test_scope, response, error)
175
+ assert_nil(cache.find(query, :test_scope))
176
+ end
177
+ assert_nil(Dnsruby::ValidationBudget.current)
178
+ assert_nil(Dnsruby::QueryControl.current)
179
+ end
180
+
181
+ def metadata_double(**attributes)
182
+ Object.new.tap do |object|
183
+ attributes.each { |name, value| object.define_singleton_method(name) { value } }
184
+ end
185
+ end
186
+ end