dnsruby 1.74.0 → 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 +302 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/demo/rubydig.rb +0 -2
  10. data/dnsruby.gemspec +5 -4
  11. data/lib/dnsruby/cache.rb +279 -47
  12. data/lib/dnsruby/code_mapper.rb +35 -8
  13. data/lib/dnsruby/code_mappers.rb +63 -49
  14. data/lib/dnsruby/config.rb +75 -4
  15. data/lib/dnsruby/deadline_queue.rb +75 -0
  16. data/lib/dnsruby/dnssec.rb +148 -45
  17. data/lib/dnsruby/ipv6.rb +45 -5
  18. data/lib/dnsruby/key_cache.rb +202 -35
  19. data/lib/dnsruby/message/decoder.rb +85 -19
  20. data/lib/dnsruby/message/encoder.rb +27 -16
  21. data/lib/dnsruby/message/header.rb +8 -12
  22. data/lib/dnsruby/message/message.rb +53 -22
  23. data/lib/dnsruby/message/section.rb +12 -34
  24. data/lib/dnsruby/name.rb +5 -2
  25. data/lib/dnsruby/packet_sender.rb +645 -203
  26. data/lib/dnsruby/presentation.rb +147 -0
  27. data/lib/dnsruby/query_control.rb +132 -0
  28. data/lib/dnsruby/recursor.rb +795 -339
  29. data/lib/dnsruby/resolver.rb +468 -186
  30. data/lib/dnsruby/resource/CAA.rb +67 -16
  31. data/lib/dnsruby/resource/DLV.rb +6 -0
  32. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  33. data/lib/dnsruby/resource/DS.rb +27 -28
  34. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  35. data/lib/dnsruby/resource/IN.rb +19 -0
  36. data/lib/dnsruby/resource/MX.rb +2 -2
  37. data/lib/dnsruby/resource/NSEC.rb +6 -10
  38. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  39. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  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 +979 -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 +42 -20
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +21 -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 +105 -10
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,251 @@
1
+ require_relative 'spec_helper'
2
+ require 'stringio'
3
+ require 'minitest/mock'
4
+
5
+ class TestTsigApi < Minitest::Test
6
+ KEY_NAME = 'api-key.example.'
7
+ SECRET = ['a test-only shared secret'].pack('m0')
8
+
9
+ def test_default_sha256_and_explicit_algorithms_across_public_apis
10
+ configurations = [Dnsruby::Resolver.new(nameserver: '127.0.0.1'),
11
+ Dnsruby::SingleResolver.new(server: '127.0.0.1'),
12
+ Dnsruby::PacketSender.new(server: '127.0.0.1'), Dnsruby::ZoneTransfer.new]
13
+ configurations.each do |configuration|
14
+ [nil, 'hmac-sha256', 'hmac-md5'].each do |algorithm|
15
+ configuration.tsig = algorithm ? [KEY_NAME, SECRET, algorithm] : [KEY_NAME, SECRET]
16
+ key = Dnsruby::Resolver.get_tsig([configuration.tsig])
17
+ if configuration.is_a?(Dnsruby::ZoneTransfer)
18
+ wire = transfer_query(configuration, Time.now.to_i).encode
19
+ else
20
+ sender = configuration.is_a?(Dnsruby::Resolver) ? configuration.single_resolvers.first : configuration
21
+ wire = sender.make_query_packet(Dnsruby::Message.new('example.'))
22
+ end
23
+ assert_signed_exchange(wire, key, algorithm)
24
+ end
25
+ end
26
+ [nil, 'hmac-sha256', 'hmac-md5'].each do |algorithm|
27
+ message = Dnsruby::Message.new('example.')
28
+ if algorithm
29
+ message.set_tsig(name: KEY_NAME, key: SECRET, algorithm: algorithm)
30
+ else
31
+ message.set_tsig(KEY_NAME, SECRET)
32
+ end
33
+ wire = message.encode
34
+ assert_signed_exchange(wire, message.tsig, algorithm)
35
+ end
36
+ ensure
37
+ configurations&.each { |configuration| configuration.close if configuration.respond_to?(:close) }
38
+ end
39
+
40
+ def test_explicit_algorithms_keep_the_existing_configuration_forms
41
+ %w[hmac-md5 hmac-sha1 hmac-sha256 hmac-sha512].each do |algorithm|
42
+ args = [KEY_NAME, SECRET, algorithm]
43
+ direct = Dnsruby::Resolver.get_tsig(args)
44
+ nested = Dnsruby::Resolver.get_tsig([args])
45
+ assert_equal(direct.algorithm, nested.algorithm)
46
+ assert_same(direct, Dnsruby::Resolver.get_tsig([direct]))
47
+ message = Dnsruby::Message.new('example.')
48
+ message.set_tsig(name: KEY_NAME, key: SECRET, algorithm: algorithm)
49
+ assert_equal(direct.algorithm, Dnsruby::Message.decode(message.encode).tsig.algorithm)
50
+ end
51
+ assert_nil(Dnsruby::Resolver.get_tsig([nil]))
52
+ end
53
+
54
+ def test_explicit_md5_constructor_options_sign_and_verify
55
+ configurations = []
56
+ ['hmac-md5', 'HMAC-MD5.SIG-ALG.REG.INT.', Dnsruby::RR::TSIG::HMAC_MD5].each do |algorithm|
57
+ args = [KEY_NAME, SECRET, algorithm]
58
+ configurations = [Dnsruby::Resolver.new(nameserver: '127.0.0.1', tsig: args),
59
+ Dnsruby::SingleResolver.new(server: '127.0.0.1', tsig: args),
60
+ Dnsruby::PacketSender.new(server: '127.0.0.1', tsig: args)]
61
+ configurations.each do |configuration|
62
+ key = Dnsruby::Resolver.get_tsig([configuration.tsig])
63
+ sender = configuration.is_a?(Dnsruby::Resolver) ? configuration.single_resolvers.first : configuration
64
+ wire = sender.make_query_packet(Dnsruby::Message.new('example.'))
65
+ assert_signed_exchange(wire, key, 'hmac-md5')
66
+ end
67
+ configurations.each(&:close)
68
+ end
69
+ ensure
70
+ configurations&.each(&:close)
71
+ end
72
+
73
+ def test_invalid_tsig_configuration_fails_before_sending
74
+ algorithms = ['unsupported-tsig-algorithm', Dnsruby::Name.create('unsupported-tsig-algorithm.')]
75
+ configurations = [Dnsruby::Resolver.new(nameserver: '127.0.0.1'),
76
+ Dnsruby::SingleResolver.new(server: '127.0.0.1'),
77
+ Dnsruby::PacketSender.new(server: '127.0.0.1'), Dnsruby::ZoneTransfer.new]
78
+ algorithms.each do |algorithm|
79
+ args = [KEY_NAME, SECRET, algorithm]
80
+ [args, [args]].each do |form|
81
+ assert_configuration_error { Dnsruby::Resolver.get_tsig(form) }
82
+ end
83
+ configurations.each do |configuration|
84
+ assert_configuration_error { configuration.tsig = args }
85
+ assert_nil(configuration.tsig)
86
+ end
87
+ assert_configuration_error do
88
+ Dnsruby::Message.new('example.').set_tsig(name: KEY_NAME, key: SECRET, algorithm: algorithm)
89
+ end
90
+ assert_configuration_error do
91
+ Dnsruby::Resolver.new(nameserver: '127.0.0.1', tsig: args)
92
+ end
93
+ [Dnsruby::SingleResolver, Dnsruby::PacketSender].each do |klass|
94
+ assert_configuration_error { klass.new(server: '127.0.0.1', tsig: args) }
95
+ end
96
+ end
97
+ ensure
98
+ configurations&.each { |configuration| configuration.close if configuration.respond_to?(:close) }
99
+ end
100
+
101
+ def test_key_configuration_does_not_log_the_secret
102
+ output = StringIO.new
103
+ Dnsruby.stub(:log, Logger.new(output)) do
104
+ Dnsruby::Resolver.get_tsig([[KEY_NAME, SECRET]])
105
+ end
106
+ assert_includes(output.string, KEY_NAME.delete_suffix('.'))
107
+ refute_includes(output.string, SECRET)
108
+ end
109
+
110
+ def test_md5_warning_is_once_per_process_and_never_logs_the_key
111
+ klass = Dnsruby::RR::TSIG
112
+ mutex = klass.const_get(:LEGACY_WARNING_MUTEX)
113
+ previous = mutex.synchronize do
114
+ old = klass.instance_variable_get(:@legacy_warning_issued)
115
+ klass.instance_variable_set(:@legacy_warning_issued, false)
116
+ old
117
+ end
118
+ output = StringIO.new
119
+ logger = Logger.new(output)
120
+ logger.level = Logger::WARN
121
+ Dnsruby.stub(:log, logger) do
122
+ Dnsruby::Resolver.get_tsig([[KEY_NAME, SECRET]])
123
+ assert_empty(output.string)
124
+ threads = Array.new(8) do
125
+ Thread.new { Dnsruby::Resolver.get_tsig([[KEY_NAME, SECRET, 'hmac-md5']]) }
126
+ end
127
+ keys = threads.map(&:value)
128
+ 3.times do
129
+ query = Dnsruby::Message.new('example.')
130
+ keys.first.apply(query)
131
+ end
132
+ end
133
+ assert_equal(1, output.string.lines.size)
134
+ assert_match(/WARN.*HMAC-MD5/, output.string)
135
+ assert_match(/RFC 8945.*MUST NOT/, output.string)
136
+ assert_match(/hmac-sha256.*DNS server and client/, output.string)
137
+ refute_includes(output.string, SECRET)
138
+ refute_includes(output.string, KEY_NAME)
139
+ ensure
140
+ mutex&.synchronize { klass.instance_variable_set(:@legacy_warning_issued, previous) }
141
+ end
142
+
143
+ def test_tsig_is_rejected_outside_the_final_additional_position
144
+ [:answer, :authority, :additional].each do |section|
145
+ message = Dnsruby::Message.new('example.')
146
+ signature = unsigned_tsig
147
+ message.public_send("add_#{section}", signature)
148
+ message.add_additional(Dnsruby::RR.create('example. 0 IN A 192.0.2.1'))
149
+ error = assert_raises(Dnsruby::DecodeError) { Dnsruby::Message.decode(message.encode) }
150
+ assert_instance_of(Dnsruby::Message, error.partial_message)
151
+ end
152
+ message = Dnsruby::Message.new('example.')
153
+ message.add_additional(unsigned_tsig)
154
+ other = unsigned_tsig
155
+ other.name = 'another-key.example.'
156
+ message.add_additional(other)
157
+ assert_raises(Dnsruby::DecodeError) { Dnsruby::Message.decode(message.encode) }
158
+ end
159
+
160
+ def test_tsig_class_and_ttl_are_checked_before_record_dispatch
161
+ [[Dnsruby::Classes::IN, 0], [Dnsruby::Classes::ANY, 60]].each do |klass, ttl|
162
+ signature = unsigned_tsig
163
+ message = Dnsruby::Message.new('example.')
164
+ message.add_additional(signature)
165
+ wire = message.encode
166
+ decoded = Dnsruby::Message.decode(wire)
167
+ fields = wire.index([Dnsruby::Types::TSIG, Dnsruby::Classes::ANY, 0].pack('nnN'), decoded.tsigstart)
168
+ refute_nil(fields)
169
+ wire[fields + 2, 6] = [klass, ttl].pack('nN')
170
+ assert_raises(Dnsruby::DecodeError) { Dnsruby::Message.decode(wire) }
171
+ end
172
+ end
173
+
174
+ def test_reused_zone_transfer_gets_a_fresh_timestamp_and_keeps_key_changes
175
+ transfer = Dnsruby::ZoneTransfer.new
176
+ transfer.tsig = KEY_NAME, SECRET
177
+ first = transfer_query(transfer, 10_000)
178
+ transfer.tsig.fudge = 600
179
+ transfer.tsig.mac_length = 16
180
+ second = transfer_query(transfer, 20_000)
181
+ assert_equal(10_000, first.tsig.time_signed)
182
+ assert_equal(20_000, second.tsig.time_signed)
183
+ assert_equal(600, second.tsig.fudge)
184
+ assert_equal(16, second.tsig.mac_size)
185
+ assert_equal(second.tsig.mac, transfer.tsig.mac)
186
+ end
187
+
188
+ def test_zone_transfer_respects_explicit_times_and_can_return_to_current_time
189
+ transfer = Dnsruby::ZoneTransfer.new
190
+ transfer.tsig = KEY_NAME, SECRET
191
+ transfer.tsig.time_signed = 1234
192
+ assert_equal(1234, transfer_query(transfer, 10_000).tsig.time_signed)
193
+ assert_equal(1234, transfer_query(transfer, 20_000).tsig.time_signed)
194
+ transfer.tsig.time_signed = 2345
195
+ assert_equal(2345, transfer_query(transfer, 30_000).tsig.time_signed)
196
+ transfer.tsig.time_signed = nil
197
+ assert_equal(40_000, transfer_query(transfer, 40_000).tsig.time_signed)
198
+ assert_equal(50_000, transfer_query(transfer, 50_000).tsig.time_signed)
199
+ end
200
+
201
+ private
202
+
203
+ def assert_configuration_error
204
+ error = assert_raises(ArgumentError) { yield }
205
+ assert_match(/Invalid TSIG algorithm/, error.message)
206
+ refute_includes(error.message, SECRET)
207
+ end
208
+
209
+ def assert_signed_exchange(wire, client_key, algorithm)
210
+ md5 = algorithm == 'hmac-md5'
211
+ expected = md5 ? Dnsruby::RR::TSIG::HMAC_MD5 : Dnsruby::RR::TSIG::HMAC_SHA256
212
+ query = Dnsruby::Message.decode(wire)
213
+ signature = query.tsig
214
+ assert_equal(expected, client_key.algorithm)
215
+ assert_equal(expected, signature.algorithm)
216
+ assert_equal(md5 ? 16 : 32, signature.mac_size)
217
+ unsigned = wire.byteslice(0, query.tsigstart).dup
218
+ unsigned[10, 2] = [query.header.arcount - 1].pack('n')
219
+ name_wire = ->(name) { name.to_s.downcase.sub(/\.$/, '').split('.').map { |label| [label.bytesize].pack('C') + label }.join.b + "\0".b }
220
+ variables = name_wire.call(signature.name) + [255, 0].pack('nN') + name_wire.call(signature.algorithm) +
221
+ [signature.time_signed >> 32, signature.time_signed & 0xffff_ffff, signature.fudge,
222
+ signature.error, signature.other_size].pack('nNn3') + signature.other_data
223
+ assert_equal(OpenSSL::HMAC.digest(md5 ? 'MD5' : 'SHA256', SECRET.unpack1('m0'), unsigned + variables), signature.mac)
224
+ server_key = Dnsruby::RR.create(name: KEY_NAME, type: 'TSIG', klass: 'ANY', key: SECRET,
225
+ algorithm: algorithm || 'hmac-sha256')
226
+ reply = Dnsruby::Message.new('example.')
227
+ reply.header.qr = true
228
+ server_key.apply(reply, query)
229
+ response_wire = reply.encode
230
+ decoded = Dnsruby::Message.decode(response_wire)
231
+ assert_equal(true, client_key.verify(query, decoded, response_wire))
232
+ assert(decoded.verified?)
233
+ end
234
+
235
+ def transfer_query(transfer, clock)
236
+ writes = []
237
+ socket = Object.new
238
+ socket.define_singleton_method(:write_nonblock) { |bytes| writes << bytes; bytes.bytesize }
239
+ Time.stub(:now, Time.at(clock)) do
240
+ transfer.send_message(socket, Dnsruby::Message.new('example.', Dnsruby::Types::AXFR))
241
+ end
242
+ frame = writes.join
243
+ assert_equal(frame.bytesize - 2, frame.unpack1('n'))
244
+ Dnsruby::Message.decode(frame.byteslice(2..-1))
245
+ end
246
+
247
+ def unsigned_tsig
248
+ Dnsruby::RR.create(name: KEY_NAME, type: 'TSIG', klass: 'ANY',
249
+ time_signed: Time.now.to_i, error: Dnsruby::RCode::BADKEY)
250
+ end
251
+ end
@@ -0,0 +1,357 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'stringio'
4
+
5
+ class TsigRfc8945Test < Minitest::Test
6
+ SECRET = 'secret'.b
7
+
8
+ def key(algorithm = 'hmac-sha256', **options)
9
+ Dnsruby::RR.create({name: 'key.example.', type: 'TSIG', klass: 'ANY',
10
+ key: [SECRET].pack('m0'), algorithm: algorithm}.merge(options))
11
+ end
12
+
13
+ def request(signer)
14
+ Dnsruby::Message.new('example.', 'A').tap do |message|
15
+ message.header.id = 12_345
16
+ signer.apply(message)
17
+ end
18
+ end
19
+
20
+ def dns_name(text)
21
+ text.sub(/\.$/, '').split('.').map { |label| [label.bytesize].pack('C') + label }.join.b + "\0".b
22
+ end
23
+
24
+ # Build expected signatures with OpenSSL and explicit wire fields, independently
25
+ # of TSIG#generate, #calculate_mac and #sig_data.
26
+ def signed_wire(query, algorithm: 'hmac-sha256', length: nil, time: Time.now.to_i,
27
+ fudge: 300, error: 0, other: ''.b, original_id: query.header.id,
28
+ header_id: query.header.id, prior: nil, klass: 255, ttl: 0)
29
+ message = Dnsruby::Message.new('example.', 'A')
30
+ message.header.id, message.header.qr = original_id, true
31
+ message.header.rcode = 9 unless error.zero?
32
+ unsigned = message.encode
33
+ timers = [time >> 32, time & 0xffff_ffff, fudge].pack('nNn')
34
+ variables = dns_name('key.example.') + [klass, ttl].pack('nN') +
35
+ dns_name(algorithm.downcase) + timers + [error, other.bytesize].pack('n2') + other
36
+ prefix = prior || [query.tsig.mac_size].pack('n') + query.tsig.mac
37
+ digest = {'hmac-md5.sig-alg.reg.int' => 'MD5', 'hmac-sha1' => 'SHA1',
38
+ 'hmac-sha256' => 'SHA256', 'hmac-sha512' => 'SHA512'}.fetch(algorithm.downcase.sub(/\.$/, ''))
39
+ mac = OpenSSL::HMAC.digest(digest, SECRET, prefix + unsigned + (prior ? timers : variables))
40
+ mac = mac.byteslice(0, length) if length && length <= mac.bytesize
41
+ mac += 'x' * (length - mac.bytesize) if length && length > mac.bytesize
42
+ rdata = dns_name(algorithm) + timers + [mac.bytesize].pack('n') + mac +
43
+ [original_id, error, other.bytesize].pack('n3') + other
44
+ wire = unsigned.dup
45
+ wire[0, 2], wire[10, 2] = [header_id].pack('n'), [1].pack('n')
46
+ wire << dns_name('key.example.') << [250, klass, ttl, rdata.bytesize].pack('n2Nn') << rdata
47
+ [wire, mac]
48
+ end
49
+
50
+ def verify(signer, query, wire)
51
+ decoded = Dnsruby::Message.decode(wire)
52
+ [signer.verify(query, decoded, wire), decoded]
53
+ end
54
+
55
+ def test_omitted_algorithm_uses_sha256
56
+ default = Dnsruby::RR.create(name: 'key.example.', type: 'TSIG', klass: 'ANY', key: [SECRET].pack('m0'))
57
+ assert_equal(Dnsruby::RR::TSIG::HMAC_SHA256, default.algorithm)
58
+ query = request(default)
59
+ assert_equal(32, query.tsig.mac_size)
60
+ wire, = signed_wire(query)
61
+ assert_equal(true, verify(default, query, wire).first)
62
+ assert_equal(32, request(key('hmac-sha256')).tsig.mac_size)
63
+ end
64
+
65
+ def test_explicit_md5_aliases_sign_and_verify_independent_responses
66
+ ['hmac-md5', 'HMAC-MD5.SIG-ALG.REG.INT.', Dnsruby::RR::TSIG::HMAC_MD5].each do |algorithm|
67
+ signer = key(algorithm)
68
+ query = request(signer)
69
+ assert_equal(Dnsruby::RR::TSIG::HMAC_MD5, query.tsig.algorithm)
70
+ assert_equal(16, query.tsig.mac_size)
71
+ wire, = signed_wire(query, algorithm: 'hmac-md5.sig-alg.reg.int')
72
+ result, reply = verify(signer, query, wire)
73
+ assert_equal(true, result)
74
+ assert(reply.verified?)
75
+ restored_key = Dnsruby::Message.decode(wire).tsig
76
+ restored_key.key = [SECRET].pack('m0')
77
+ assert_equal(16, request(restored_key).tsig.mac_size)
78
+ end
79
+ end
80
+
81
+ def test_md5_requires_explicit_selection_and_authenticates_errors
82
+ signer = key('hmac-md5')
83
+ query = request(signer)
84
+ [0, 18, 22].each do |error|
85
+ wire, = signed_wire(query, algorithm: 'hmac-md5.sig-alg.reg.int', error: error,
86
+ time: query.tsig.time_signed, other: error == 18 ? [0, Time.now.to_i].pack('nN') : ''.b)
87
+ result, response = verify(signer, query, wire)
88
+ assert_equal(error.zero?, result)
89
+ assert(response.verified?)
90
+ assert_equal(error, response.tsigerror)
91
+ decoded = Dnsruby::Message.decode(wire)
92
+ forged = wire.dup
93
+ mac_offset = decoded.tsigstart + dns_name('key.example.').bytesize + 10 + dns_name('hmac-md5.sig-alg.reg.int').bytesize + 10
94
+ forged.setbyte(mac_offset, forged.getbyte(mac_offset) ^ 1)
95
+ result, response = verify(signer, query, forged)
96
+ assert_equal(false, result)
97
+ assert_equal(Dnsruby::RCode::BADSIG, response.tsigerror)
98
+ refute(response.verified?)
99
+ end
100
+ [['hmac-sha256', 'hmac-md5.sig-alg.reg.int'], ['hmac-md5', 'hmac-sha256']].each do |configured, received|
101
+ signer = key(configured)
102
+ query = request(signer)
103
+ wire, = signed_wire(query, algorithm: received)
104
+ OpenSSL::HMAC.stub(:digest, ->(*) { flunk('Wrong algorithm reached HMAC verification') }) do
105
+ result, response = verify(signer, query, wire)
106
+ assert_equal(false, result)
107
+ assert_equal(Dnsruby::RCode::BADKEY, response.tsigerror)
108
+ refute(response.verified?)
109
+ end
110
+ end
111
+ end
112
+
113
+ def test_inbound_md5_does_not_warn_but_presigned_outbound_md5_does
114
+ sender = nil
115
+ signer = key('hmac-md5')
116
+ query = request(signer)
117
+ wire, = signed_wire(query, algorithm: 'hmac-md5.sig-alg.reg.int')
118
+ klass = Dnsruby::RR::TSIG
119
+ mutex = klass.const_get(:LEGACY_WARNING_MUTEX)
120
+ previous = mutex.synchronize do
121
+ old = klass.instance_variable_get(:@legacy_warning_issued)
122
+ klass.instance_variable_set(:@legacy_warning_issued, false)
123
+ old
124
+ end
125
+ output = StringIO.new
126
+ logger = Logger.new(output)
127
+ logger.level = Logger::WARN
128
+ Dnsruby.stub(:log, logger) do
129
+ assert_equal(Dnsruby::RR::TSIG::HMAC_MD5, Dnsruby::Message.decode(wire).tsig.algorithm)
130
+ assert_equal(true, verify(signer, query, wire).first)
131
+ assert_equal(false, verify(key, query, wire).first)
132
+ assert_empty(output.string)
133
+ sender = Dnsruby::PacketSender.new(server: '127.0.0.1')
134
+ message = Dnsruby::Message.decode(wire)
135
+ message.send_raw = true
136
+ sender.make_query_packet(message)
137
+ assert_match(/WARN.*HMAC-MD5/, output.string)
138
+ sender.make_query_packet(message)
139
+ assert_equal(1, output.string.lines.size)
140
+ end
141
+ ensure
142
+ sender&.close
143
+ mutex&.synchronize { klass.instance_variable_set(:@legacy_warning_issued, previous) }
144
+ end
145
+
146
+ def test_algorithm_names_are_case_insensitive_strings_and_names
147
+ ['HMAC-SHA256.', Dnsruby::Name.new([Dnsruby::Name::Label.new('HmAc-ShA256')])].each do |algorithm|
148
+ signer = key(algorithm)
149
+ query = request(signer)
150
+ wire, = signed_wire(query, algorithm: 'HmAc-ShA256')
151
+ assert_equal(true, verify(signer, query, wire).first)
152
+ end
153
+ end
154
+
155
+ def test_allowed_mac_prefixes_and_protocol_bounds
156
+ {'hmac-sha1' => [10, 12, 20], 'hmac-sha256' => [16, 24, 32], 'hmac-sha512' => [32, 48, 64]}.each do |algorithm, lengths|
157
+ signer = key(algorithm)
158
+ query = request(signer)
159
+ lengths.each do |length|
160
+ wire, = signed_wire(query, algorithm: algorithm, length: length)
161
+ result, reply = verify(signer, query, wire)
162
+ assert_equal(true, result, "#{algorithm}/#{length}")
163
+ assert(reply.verified?)
164
+ end
165
+ [0, lengths.first - 1, lengths.last + 1].each do |length|
166
+ wire, = signed_wire(query, algorithm: algorithm, length: length)
167
+ result, reply = verify(signer, query, wire)
168
+ assert_equal(false, result)
169
+ assert_equal(Dnsruby::RCode::FORMERR, reply.tsigerror)
170
+ refute(reply.verified?)
171
+ end
172
+ end
173
+ end
174
+
175
+ def test_configured_mac_length_is_separate_from_wire_size_and_sets_local_minimum
176
+ signer = key(mac_length: 16)
177
+ query = request(signer)
178
+ assert_equal(16, query.tsig.mac_size)
179
+ assert_equal(16, query.tsig.mac_length)
180
+ [16, 24, 32].each do |length|
181
+ wire, = signed_wire(query, length: length)
182
+ assert_equal(true, verify(query.tsig, query, wire).first)
183
+ end
184
+ strict = key(mac_length: 32)
185
+ query = request(strict)
186
+ wire, = signed_wire(query, length: 16)
187
+ result, reply = verify(strict, query, wire)
188
+ assert_equal(false, result)
189
+ assert_equal(22, reply.tsigerror)
190
+ [1, 15, 33].each do |length|
191
+ assert_raises(Dnsruby::EncodeError) { request(key(mac_length: length)) }
192
+ end
193
+ [0, -1, 16.5, '16'].each do |length|
194
+ assert_raises(ArgumentError) { key(mac_length: length) }
195
+ end
196
+ end
197
+
198
+ def test_mac_is_checked_before_time_and_badtime_remains_authenticatable
199
+ signer = key
200
+ query = request(signer)
201
+ wire, = signed_wire(query, time: Time.now.to_i - 10_000)
202
+ valid, reply = verify(signer, query, wire)
203
+ assert_equal(false, valid)
204
+ assert_equal(Dnsruby::RCode::BADTIME, reply.tsigerror)
205
+ forged = wire.dup
206
+ decoded = Dnsruby::Message.decode(wire)
207
+ mac_offset = decoded.tsigstart + dns_name('key.example.').bytesize + 10 + dns_name('hmac-sha256').bytesize + 10
208
+ forged.setbyte(mac_offset, forged.getbyte(mac_offset) ^ 1)
209
+ valid, reply = verify(signer, query, forged)
210
+ assert_equal(false, valid)
211
+ assert_equal(Dnsruby::RCode::BADSIG, reply.tsigerror)
212
+ wire, = signed_wire(query, error: 18, time: Time.now.to_i - 10_000, other: [0, Time.now.to_i].pack('nN'))
213
+ result, reply = verify(signer, query, wire)
214
+ assert_equal(false, result)
215
+ refute(reply.verified?)
216
+ wire, = signed_wire(query, error: 18, time: query.tsig.time_signed, other: [0, Time.now.to_i + 10_000].pack('nN'))
217
+ result, reply = verify(signer, query, wire)
218
+ assert_equal(false, result)
219
+ assert(reply.verified?)
220
+ assert_equal(18, reply.tsigerror)
221
+ end
222
+
223
+ def test_original_id_is_substituted_in_the_mac_input_without_mutating_the_message
224
+ signer = key
225
+ query = request(signer)
226
+ wire, = signed_wire(query, original_id: query.header.id, header_id: 54321)
227
+ frozen_wire = wire.freeze
228
+ result, reply = verify(signer, query, frozen_wire)
229
+ assert_equal(true, result)
230
+ assert_equal(54321, reply.header.id)
231
+ assert_equal(frozen_wire, wire)
232
+ tampered = wire.dup
233
+ tampered[-6, 2] = [query.header.id + 1].pack('n')
234
+ result, reply = verify(signer, query, tampered)
235
+ assert_equal(false, result)
236
+ assert_equal(Dnsruby::RCode::BADSIG, reply.tsigerror)
237
+ assert_equal(54321, reply.header.id)
238
+ end
239
+
240
+ def test_other_data_is_checked_even_with_a_valid_mac
241
+ signer = key
242
+ query = request(signer)
243
+ [[0, 'unexpected'], [18, 'short'], [22, 'unexpected']].each do |error, other|
244
+ wire, = signed_wire(query, error: error, other: other)
245
+ result, reply = verify(signer, query, wire)
246
+ assert_equal(false, result)
247
+ assert_equal(Dnsruby::RCode::FORMERR, reply.tsigerror)
248
+ end
249
+ end
250
+
251
+ def test_badtime_generation_echoes_request_timers_and_preserves_server_time
252
+ signer = key(fudge: 123)
253
+ query = request(signer)
254
+ server_time = Time.now.to_i + 10_000
255
+ error_key = key(error: 18, time_signed: server_time, fudge: 30,
256
+ other_size: 6, other_data: [0, server_time].pack('nN'))
257
+ reply = Dnsruby::Message.new('example.', 'A')
258
+ reply.header.qr, reply.header.rcode = true, 9
259
+ error_key.apply(reply, query)
260
+ assert_equal(query.tsig.time_signed, reply.tsig.time_signed)
261
+ assert_equal(query.tsig.fudge, reply.tsig.fudge)
262
+ assert_equal([0, server_time].pack('nN'), reply.tsig.other_data)
263
+ _, expected_mac = signed_wire(query, time: query.tsig.time_signed, fudge: 123,
264
+ error: 18, other: [0, server_time].pack('nN'))
265
+ assert_equal(expected_mac, reply.tsig.mac)
266
+ result, decoded = verify(signer, query, reply.encode)
267
+ assert_equal(false, result)
268
+ assert(decoded.verified?)
269
+ [16, 17].each do |error|
270
+ reply = Dnsruby::Message.new('example.')
271
+ reply.header.qr, reply.header.rcode = true, 9
272
+ assert_raises(Dnsruby::EncodeError) { key(error: error).apply(reply, query) }
273
+ end
274
+ end
275
+
276
+ def test_response_to_a_forwarded_request_retains_transport_and_original_ids
277
+ signer = key
278
+ query = request(signer)
279
+ forwarded = query.encode
280
+ forwarded[0, 2] = [54321].pack('n')
281
+ forwarded = Dnsruby::Message.decode(forwarded)
282
+ reply = Dnsruby::Message.new('example.', 'A')
283
+ reply.header.qr = true
284
+ signer.apply(reply, forwarded)
285
+ assert_equal(54321, reply.header.id)
286
+ assert_equal(query.header.id, reply.tsig.original_id)
287
+ _, expected_mac = signed_wire(forwarded, time: reply.tsig.time_signed,
288
+ original_id: query.header.id, header_id: 54321)
289
+ assert_equal(expected_mac, reply.tsig.mac)
290
+ assert_equal(true, verify(signer, query, reply.encode).first)
291
+ end
292
+
293
+ def test_stream_chains_received_truncated_macs_and_accepts_99_unsigned_messages
294
+ signer = key(mac_length: 16)
295
+ query = request(signer)
296
+ now = Time.now.to_i
297
+ wire, mac = signed_wire(query, length: 16, time: now)
298
+ assert_equal(true, signer.verify_envelope(Dnsruby::Message.decode(wire), wire))
299
+ prior = [mac.bytesize].pack('n') + mac
300
+ 99.times do
301
+ unsigned = Dnsruby::Message.new('example.').encode
302
+ reply = Dnsruby::Message.decode(unsigned)
303
+ assert_equal(true, signer.verify_envelope(reply, unsigned))
304
+ assert_equal(:Intermediate, reply.tsigstate)
305
+ prior += unsigned
306
+ end
307
+ wire, mac = signed_wire(query, length: 32, prior: prior, time: now)
308
+ assert_equal(true, signer.verify_envelope(Dnsruby::Message.decode(wire), wire))
309
+ wire, = signed_wire(query, length: 16, prior: [mac.bytesize].pack('n') + mac, time: now)
310
+ assert_equal(true, signer.verify_envelope(Dnsruby::Message.decode(wire), wire))
311
+ 99.times { signer.verify_envelope(Dnsruby::Message.new, Dnsruby::Message.new.encode) }
312
+ assert_equal(false, signer.verify_envelope(Dnsruby::Message.new, Dnsruby::Message.new.encode))
313
+ end
314
+
315
+ def test_signed_stream_times_cannot_decrease
316
+ signer = key
317
+ query = request(signer)
318
+ now = Time.now.to_i
319
+ wire, mac = signed_wire(query, time: now)
320
+ assert_equal(true, signer.verify_envelope(Dnsruby::Message.decode(wire), wire))
321
+ wire, = signed_wire(query, prior: [mac.bytesize].pack('n') + mac, time: now - 1)
322
+ reply = Dnsruby::Message.decode(wire)
323
+ assert_equal(false, signer.verify_envelope(reply, wire))
324
+ assert_equal(Dnsruby::RCode::BADTIME, reply.tsigerror)
325
+ end
326
+
327
+ def test_signing_rejects_invalid_request_fields_and_supports_full_fudge_range
328
+ assert_equal(65_535, key(fudge: 65_535).fudge)
329
+ [{time_signed: 1 << 48}, {time_signed: -1}, {ttl: 60}, {error: 18}, {other_size: 1}].each do |fields|
330
+ assert_raises(Dnsruby::EncodeError) { request(key(**fields)) }
331
+ end
332
+ query = request(key)
333
+ decoded = Dnsruby::Message.decode(query.encode)
334
+ assert_raises(Dnsruby::EncodeError) { key.apply(decoded) }
335
+ end
336
+
337
+ def test_algorithm_name_compression_is_rejected
338
+ signer = key
339
+ query = request(signer)
340
+ wire, = signed_wire(query)
341
+ decoded = Dnsruby::Message.decode(wire)
342
+ rdata_at = decoded.tsigstart + dns_name('key.example.').bytesize + 10
343
+ wire[rdata_at, dns_name('hmac-sha256').bytesize] = [0xc00c].pack('n')
344
+ length_at = rdata_at - 2
345
+ wire[length_at, 2] = [wire.bytesize - rdata_at].pack('n')
346
+ assert_raises(Dnsruby::DecodeError) { Dnsruby::Message.decode(wire) }
347
+ end
348
+
349
+ def test_signing_rejects_existing_signatures_in_any_section
350
+ [:answer, :authority, :additional].each do |section|
351
+ message = Dnsruby::Message.new('example.')
352
+ message.public_send(section) << request(key).tsig
353
+ message.add_additional(Dnsruby::RR.create('extra.example. 0 IN TXT "data"'))
354
+ assert_raises(Dnsruby::EncodeError) { key.apply(message) }
355
+ end
356
+ end
357
+ end