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
data/test/tc_header.rb CHANGED
@@ -15,11 +15,37 @@
15
15
  # ++
16
16
 
17
17
  require_relative 'spec_helper'
18
+ require 'minitest/mock'
18
19
 
19
20
  class TestHeader < Minitest::Test
20
21
 
21
22
  include Dnsruby
22
23
 
24
+ def test_generated_ids_use_secure_randomness_and_include_both_endpoints
25
+ [0, 65_535].each do |id|
26
+ generator = lambda do |range|
27
+ assert_equal(65_536, range)
28
+ id
29
+ end
30
+ SecureRandom.stub(:random_number, generator) do
31
+ header = Header.new
32
+ assert_equal(id, header.id)
33
+ assert_equal(id, header.data.unpack1('n'))
34
+ end
35
+ end
36
+ end
37
+
38
+ def test_header_decoding_does_not_generate_an_unused_id
39
+ bytes = [65_535, 0x8180, 1, 2, 3, 4].pack('n6')
40
+ SecureRandom.stub(:random_number, ->(*) { flunk('Decoding must retain the wire ID') }) do
41
+ header = Header.new_from_data(bytes)
42
+ assert_equal(65_535, header.id)
43
+ assert_equal(bytes, header.data)
44
+ updated = Header.decrement_arcount_encoded(bytes.dup + 'payload')
45
+ assert_equal([65_535, 0x8180, 1, 2, 3, 3].pack('n6') + 'payload', updated)
46
+ end
47
+ end
48
+
23
49
  def test_header
24
50
  header = Header.new();
25
51
  assert(header, "new() returned something")
data/test/tc_ipv6.rb ADDED
@@ -0,0 +1,120 @@
1
+ require_relative 'spec_helper'
2
+
3
+ # Tests for the RFC 5952 text representation of IPv6 addresses
4
+ class TestIPv6 < Minitest::Test
5
+
6
+ include Dnsruby
7
+
8
+ def test_default_text_preserves_v1_74_formatting
9
+ {
10
+ '2001:db8::ab' => '2001:DB8::AB',
11
+ '1:0:0:1:0:0:0:1' => '1::1:0:0:0:1',
12
+ '2001:db8:0:1:1:1:1:1' => '2001:DB8::1:1:1:1:1',
13
+ '::ffff:192.0.2.1' => '::FFFF:C000:201',
14
+ '::' => '::',
15
+ '::1' => '::1',
16
+ '1:2:3:4:5:6:7:0' => '1:2:3:4:5:6:7::'
17
+ }.each do |input, expected|
18
+ address = IPv6.create(input)
19
+ assert_equal(expected, address.to_s)
20
+ assert_equal("#<Dnsruby::IPv6 #{expected}>", address.inspect)
21
+ assert_equal(address, IPv6.create(address.to_s))
22
+ rr = RR.create("example. 60 IN AAAA #{input}")
23
+ assert_equal(expected, rr.address.to_s)
24
+ assert_equal(expected, rr.rdata_to_string)
25
+ end
26
+ end
27
+
28
+ def test_compression_requires_at_least_one_omitted_field
29
+ %w[
30
+ 1:2:3:4:5:6:7::8 ::1:2:3:4:5:6:7:8 1:2:3:4:5:6:7:8::
31
+ 1:2:3:4:5:6::192.0.2.1 ::1:2:3:4:5:6:192.0.2.1
32
+ ].each do |address|
33
+ assert_raises(ArgumentError, address) { IPv6.create(address) }
34
+ end
35
+ assert_equal('1:2:3:4:5:6:0:8', IPv6.create('1:2:3:4:5:6::8').to_s_canonical)
36
+ assert_equal('1:2:3:4:5:0:c000:201', IPv6.create('1:2:3:4:5::192.0.2.1').to_s_canonical)
37
+ end
38
+
39
+ # RFC 5952 section 4.1: leading zeros in a field are suppressed
40
+ def test_leading_zeros_suppressed
41
+ assert_equal('2001:db8::1', IPv6.create('2001:0db8:0000:0000:0000:0000:0000:0001').to_s_canonical)
42
+ assert_equal('2001:db8:0:1:1:1:1:1', IPv6.create('2001:0db8:0000:0001:0001:0001:0001:0001').to_s_canonical)
43
+ end
44
+
45
+ # RFC 5952 section 4.2.1: a run of all-zero fields is replaced by '::'
46
+ def test_zero_run_shortened
47
+ assert_equal('::', IPv6.create('0:0:0:0:0:0:0:0').to_s_canonical)
48
+ assert_equal('::1', IPv6.create('0:0:0:0:0:0:0:1').to_s_canonical)
49
+ assert_equal('1::', IPv6.create('1:0:0:0:0:0:0:0').to_s_canonical)
50
+ assert_equal('2001:db8::1', IPv6.create('2001:db8:0:0:0:0:0:1').to_s_canonical)
51
+ end
52
+
53
+ # RFC 5952 section 4.2.2: '::' must not shorten a single all-zero field
54
+ def test_single_zero_field_not_shortened
55
+ assert_equal('2001:db8:0:1:1:1:1:1', IPv6.create('2001:db8:0:1:1:1:1:1').to_s_canonical)
56
+ assert_equal('1:2:3:4:5:6:0:8', IPv6.create('1:2:3:4:5:6:0:8').to_s_canonical)
57
+ assert_equal('0:1:2:3:4:5:6:7', IPv6.create('0:1:2:3:4:5:6:7').to_s_canonical)
58
+ assert_equal('1:2:3:4:5:6:7:0', IPv6.create('1:2:3:4:5:6:7:0').to_s_canonical)
59
+ end
60
+
61
+ # RFC 5952 section 4.2.3: the longest run is shortened. If there's a tie,
62
+ # shorten the first run.
63
+ def test_longest_zero_run_shortened
64
+ assert_equal('1:0:0:1::1', IPv6.create('1:0:0:1:0:0:0:1').to_s_canonical)
65
+ assert_equal('1::1:0:0:1:1', IPv6.create('1:0:0:1:0:0:1:1').to_s_canonical)
66
+ assert_equal('2001:db8::1:0:0:1', IPv6.create('2001:db8:0:0:1:0:0:1').to_s_canonical)
67
+ end
68
+
69
+ # RFC 5952 section 4.3: downcase hexadecimal digits
70
+ def test_lowercase_hex
71
+ assert_equal('2001:db8::ab', IPv6.create('2001:0DB8::AB').to_s_canonical)
72
+ assert_equal('fe80::abcd:ef01', IPv6.create('FE80::ABCD:EF01').to_s_canonical)
73
+ assert_equal('2606:4700:20::681a:205', IPv6.create('2606:4700:20::681A:205').to_s_canonical)
74
+ end
75
+
76
+ # RFC 5952 section 5: an embedded IPv4 address under the well-known
77
+ # ::ffff:0:0/96 prefix uses mixed notation
78
+ def test_ipv4_mapped_mixed_notation
79
+ assert_equal('::ffff:192.0.2.1', IPv6.create('::ffff:192.0.2.1').to_s_canonical)
80
+ assert_equal('::ffff:192.0.2.1', IPv6.create('0:0:0:0:0:ffff:c000:201').to_s_canonical)
81
+ assert_equal('::ffff:0.0.0.0', IPv6.create('::ffff:0:0').to_s_canonical)
82
+ end
83
+
84
+ # Only ::ffff:0:0/96 gets the mixed notation. IPv4-compatible addresses
85
+ # (::/96) are deprecated by RFC 4291 section 2.5.5.1, so a dotted quad there
86
+ # would imply a semantic that no longer exists - they keep the hexadecimal
87
+ # form. Note this is a deliberate departure from IPAddr and inet_ntop, which
88
+ # write ::a:b as ::0.10.0.11.
89
+ def test_non_mapped_addresses_stay_hexadecimal
90
+ assert_equal('::', IPv6.create('::').to_s_canonical)
91
+ assert_equal('::1', IPv6.create('::1').to_s_canonical)
92
+ assert_equal('::102:304', IPv6.create('::1.2.3.4').to_s_canonical)
93
+ assert_equal('64:ff9b::102:304', IPv6.create('64:ff9b::1.2.3.4').to_s_canonical)
94
+ end
95
+
96
+ # The mixed notation requires the whole ::ffff:0:0/96 prefix, not just 0xffff
97
+ # in the sixth field
98
+ def test_mixed_notation_requires_the_full_prefix
99
+ assert_equal('1::ffff:102:304', IPv6.create('1::ffff:1.2.3.4').to_s_canonical)
100
+ assert_equal('::1:ffff:102:304', IPv6.create('::1:ffff:1.2.3.4').to_s_canonical)
101
+ assert_equal('::fffe:102:304', IPv6.create('::fffe:1.2.3.4').to_s_canonical)
102
+ end
103
+
104
+ # Whatever we emit must be readable back as the same address
105
+ def test_output_round_trips_through_create
106
+ [
107
+ '2001:0DB8:0:8002::2000:1',
108
+ '2001:db8:0:1:1:1:1:1',
109
+ '1:0:0:1:0:0:0:1',
110
+ '::ffff:192.0.2.1',
111
+ '::',
112
+ '::1',
113
+ '1::',
114
+ 'fe80::1',
115
+ ].each do |text|
116
+ address = IPv6.create(text)
117
+ assert_equal(address, IPv6.create(address.to_s_canonical), "#{text} did not survive canonical formatting / create")
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,382 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'timeout'
6
+
7
+ class KeyCacheIdentityTest < Minitest::Test
8
+ # Test-only keys with different public-key material but the same DNSKEY tag
9
+ # (48708 with flags 257, protocol 3 and algorithm 13). Fixed keys avoid a
10
+ # probabilistic collision search and make the cryptographic checks repeatable.
11
+ PRIVATE_KEYS = [<<~PEM, <<~PEM].freeze
12
+ -----BEGIN EC PRIVATE KEY-----
13
+ MHcCAQEEICU+8aMcoyz9AMcy/h7MbFCYLKxDS/EtQPgHaXSUhzu8oAoGCCqGSM49
14
+ AwEHoUQDQgAE2dAvoL88tZbfwgPEytuVr9EsFi79Hw3l5YtxTtu1QO2NIzjD8Gdb
15
+ WBuInfkty8P9k2zCOr7ngwuKEFCNHx5Dug==
16
+ -----END EC PRIVATE KEY-----
17
+ PEM
18
+ -----BEGIN EC PRIVATE KEY-----
19
+ MHcCAQEEIAL8ZvOFzy7oNwn/0NhgQ+hWu4UrvSJXK5nKPsAo5evFoAoGCCqGSM49
20
+ AwEHoUQDQgAE1fdv3FiCisktVXjdRZRD7QjneeVdiLSLjFOpp1pynxXBmw0a+1Tw
21
+ 4LJ0KKrSuwVVv66IIXXWMRoyOxIfZsOVBA==
22
+ -----END EC PRIVATE KEY-----
23
+ PEM
24
+
25
+ DSA_PRIVATE_KEY = <<~PEM.freeze
26
+ -----BEGIN DSA PRIVATE KEY-----
27
+ MIIBvAIBAAKBgQD9x9CuGC3tbWplxT+2Zp030mynG3KnzyBPIKfYRx+TDprNHQH4
28
+ j/ptA/SPEPboIwO4QvQ52jsA94Gz9Frcwc/IxdavY6rS6L5N3y327ogD9Ry30jpB
29
+ kvaHtKjKlJjlnG1jzzj+fKoHImG2B+Be3AmmKuXw2f1wlMdO+FkcNPqdLwIVAOI9
30
+ 73RMLRrptdT9VxtgmB2FZjrrAoGBAOG6M628TvLmfGPdlRUlsL6XvW01swv21+e/
31
+ n/wi6UIqmaQZrh0hOQ9PW97/po2mMViG79xcvcbzMshgxF7nNsGethGQ9zbK3ynd
32
+ gxonua6RvQa/6aGvSJ3Yx2MSeeVdyVGhz+kARxumg9lHBNftx6P29NGxqbYHcWhE
33
+ +59wwlQhAoGBAMFmlnzv86whfLgToGZKl9tl6LEMbvR7J9KQZ4czX+LTguMGprtQ
34
+ 5WAU3oC9FLAPyUFyBhmSv2VVe5ob+07z/Ofpx3PtWAbZYlJdrgh7ZVtluVWewfGN
35
+ DqPy94Ncz3rORtR96NsGOmct1jmoZOp87KPKTuftb3bzSroLLn/6V1dtAhR77/9H
36
+ DOw8H1aQpmF7D4JVCnyExQ==
37
+ -----END DSA PRIVATE KEY-----
38
+ PEM
39
+
40
+ def setup
41
+ @now = Time.now
42
+ @private_keys = PRIVATE_KEYS.map { |pem| OpenSSL::PKey::EC.new(pem) }
43
+ @a, @b = @private_keys.map do |key|
44
+ bytes = key.public_key.to_octet_string(:uncompressed).byteslice(1, 64)
45
+ Dnsruby::RR.create("collision.invalid. 300 IN DNSKEY 257 3 13 #{Base64.strict_encode64(bytes)}")
46
+ end
47
+ @cache = Dnsruby::KeyCache.new
48
+ @verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
49
+ @record = Dnsruby::RR.create('www.collision.invalid. 300 IN A 192.0.2.1')
50
+ end
51
+
52
+ def test_collision_storage_and_exact_deletion_ignore_ttl_and_owner_case
53
+ refute_equal(@a.key, @b.key)
54
+ assert_equal(@a.key_tag, @b.key_tag)
55
+ @cache.add_key_with_expiration(@a, @now.to_i + 60)
56
+ @cache.add_key_with_expiration(@b, @now.to_i + 120)
57
+ assert_equal([@a, @b], @cache.keys)
58
+
59
+ equivalent = Dnsruby::RR.create(@a.to_s)
60
+ equivalent.name = 'COLLISION.INVALID.'
61
+ equivalent.ttl += 1
62
+ assert_equal(@a, @cache.delete(equivalent))
63
+ assert_equal([@b], @cache.keys)
64
+ assert_nil(@cache.delete(equivalent))
65
+ end
66
+
67
+ def test_equivalent_additions_retain_longest_expiration
68
+ equivalent = Dnsruby::RR.create(@a.to_s)
69
+ equivalent.ttl += 1
70
+ @cache.add_key_with_expiration(@a, @now.to_i + 120)
71
+ @cache.add_key_with_expiration(equivalent, @now.to_i + 60)
72
+ Time.stub(:now, @now + 90) { assert_equal([@a], @cache.keys) }
73
+ @cache.add_key_with_expiration(equivalent, @now.to_i + 240)
74
+ Time.stub(:now, @now + 180) { assert_equal([equivalent], @cache.keys) }
75
+ Time.stub(:now, @now + 241) { assert_empty(@cache.keys) }
76
+ end
77
+
78
+ def test_copy_retains_both_colliding_keys_and_their_individual_expirations
79
+ @cache.add_key_with_expiration(@a, @now.to_i + 60)
80
+ @cache.add_key_with_expiration(@b, @now.to_i + 120)
81
+ copy = Dnsruby::KeyCache.new(@cache)
82
+ assert_equal([[@now.to_i + 60, @a], [@now.to_i + 120, @b]], copy.keys_and_expirations)
83
+ copy.keys_and_expirations.first[0] = 0
84
+ @cache.delete(@b)
85
+ assert_equal([@a, @b], copy.keys)
86
+ Time.stub(:now, @now + 90) { assert_equal([@b], copy.keys) }
87
+ Time.stub(:now, @now + 121) { assert_empty(copy.keys) }
88
+ end
89
+
90
+ def test_copy_omits_expired_keys
91
+ @cache.add_key_with_expiration(@a, @now.to_i - 1)
92
+ @cache.add_key_with_expiration(@b, @now.to_i + 120)
93
+ assert_equal([@b], Dnsruby::KeyCache.new(@cache).keys)
94
+ end
95
+
96
+ def test_other_rrset_signatures_are_not_changed_or_added_to_key_cache
97
+ records = signed(@record, @a, 0)
98
+ @cache.add_rrset(records)
99
+ assert_empty(@cache.keys)
100
+ assert_equal(Dnsruby::Types.A, records.sigs.first.type_covered)
101
+ end
102
+
103
+ def test_both_same_zone_colliding_keys_can_verify_in_either_order
104
+ [0, 1].each do |signer|
105
+ records = signed(@record, [@a, @b][signer], signer)
106
+ [[@a, @b], [@b, @a]].each do |keys|
107
+ assert(@verifier.verify_rrset(records, keys))
108
+ assert(@verifier.verify_rrset(records, Dnsruby::RRSet.new(keys)))
109
+ end
110
+ end
111
+ [@a, @b].each { |key| @verifier.add_trust_anchor(key) }
112
+ assert(@verifier.verify_rrset(signed(@record, @a, 0)))
113
+ assert(@verifier.verify_rrset(signed(@record, @b, 1)))
114
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(signed(@record, @a, 0), @b) }
115
+ end
116
+
117
+ def test_dnskey_signer_membership_uses_full_identity_and_keeps_candidate_fallback
118
+ # A valid self-signed keyset and a different ordinary key sharing its tag.
119
+ records = signed(@a, @a, 0)
120
+ refute_equal(Dnsruby::KeyCache.key_identity(@a), Dnsruby::KeyCache.key_identity(@b))
121
+ assert_equal(@a.key_tag, @b.key_tag)
122
+ attempted = []
123
+ original = @verifier.method(:verify_rrset_with_key)
124
+ @verifier.stub(:verify_rrset_with_key, lambda { |set, key, *args|
125
+ attempted << Dnsruby::KeyCache.key_identity(key)
126
+ original.call(set, key, *args)
127
+ }) do
128
+ assert(@verifier.verify(records, [@b, @a]))
129
+ assert_equal([Dnsruby::KeyCache.key_identity(@a)], attempted)
130
+ attempted.clear
131
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, @b) }
132
+ assert_empty(attempted)
133
+ end
134
+ end
135
+
136
+ def test_same_public_key_at_another_owner_cannot_verify
137
+ foreign_key = Dnsruby::RR.create(@a.to_s)
138
+ foreign_key.name = 'unrelated.invalid.'
139
+ records = signed(@record, @a, 0)
140
+ error = assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, foreign_key) }
141
+ assert_match(/Signing key not found/, error.message)
142
+ assert(@verifier.verify_rrset(records, [foreign_key, @a]))
143
+ end
144
+
145
+ def test_wire_decoded_malformed_collision_does_not_hide_valid_signing_key
146
+ malformed = Dnsruby::RR.create(@a.to_s)
147
+ # These bytes have the same key tag but are not a valid P-256 point. Decode
148
+ # from wire to exercise lazy public-key parsing without an OpenSSL cache.
149
+ malformed.from_data([malformed.flags, malformed.protocol, malformed.algorithm,
150
+ "\0" * 62 + [@a.key_tag - 1038].pack('n')])
151
+ message = Dnsruby::Message.new
152
+ message.add_answer(malformed)
153
+ malformed = Dnsruby::Message.decode(message.encode).answer.first
154
+ assert_equal(@a.key_tag, malformed.key_tag)
155
+ refute_equal(@a.key, malformed.key)
156
+ records = signed(@record, @a, 0)
157
+ [[malformed, @a], [@a, malformed]].each do |keys|
158
+ assert(@verifier.verify_rrset(records, keys))
159
+ end
160
+ error = assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, malformed) }
161
+ assert_match(/cryptographically verify/, error.message)
162
+ end
163
+
164
+ def test_invalid_signature_candidates_do_not_hide_a_valid_signature
165
+ [:expired, :future, :corrupted].each do |mode|
166
+ valid = signed(@record, @a, 0).sigs.first
167
+ invalid = invalid_signature(valid, mode)
168
+ [[invalid, valid], [valid, invalid]].each do |signatures|
169
+ records = Dnsruby::RRSet.new([@record] + signatures)
170
+ assert(@verifier.verify_rrset(records, @a))
171
+ end
172
+ records = Dnsruby::RRSet.new([@record, invalid])
173
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, @a) }
174
+ end
175
+ end
176
+
177
+ def test_truncated_wire_rsa_and_dsa_keys_do_not_hide_another_valid_candidate
178
+ valid = signed(@record, @a, 0).sigs.first
179
+ [5, 8, 3].each do |algorithm|
180
+ ['', "\0", "\xff", "\0\xff", "\xff\0\0", "\0\xff\xff\0"].each do |bytes|
181
+ malformed = wire_key(algorithm, bytes.b)
182
+ assert_raises(OpenSSL::PKey::PKeyError) { malformed.public_key }
183
+ invalid = valid.clone
184
+ invalid.algorithm = algorithm
185
+ invalid.key_tag = malformed.key_tag
186
+ invalid.signature = algorithm == 3 ? [bytes.getbyte(0) || 0].pack('C') + "\0" * 40 : "\0" * 64
187
+ records = Dnsruby::RRSet.new([@record, invalid, valid])
188
+ assert(@verifier.verify_rrset(records, [malformed, @a]))
189
+ assert(@verifier.verify_rrset(records, [@a, malformed]))
190
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, malformed) }
191
+ end
192
+ end
193
+ end
194
+
195
+ def test_extended_rsa_exponent_length_preserves_public_key_numbers
196
+ fixture = File.join(__dir__, 'fixtures', 'root-dnskey-2026-11-05.zone')
197
+ original = Dnsruby::RR.create(File.readlines(fixture).find { |line| line.include?(' IN DNSKEY ') })
198
+ length = original.key.getbyte(0)
199
+ extended = wire_key(8, "\0" + [length].pack('n') + original.key.byteslice(1..-1))
200
+ assert_raises(OpenSSL::PKey::PKeyError) { extended.public_key }
201
+
202
+ # Exercise both octets of the extended length with a 257-byte exponent.
203
+ exponent = "\x01" + "\0" * 255 + "\x03"
204
+ modulus = "\x7f" + "\xff" * 383
205
+ extended = wire_key(8, "\0" + [exponent.bytesize].pack('n') + exponent + modulus)
206
+ assert_equal(OpenSSL::BN.new(exponent, 2), extended.public_key.e)
207
+ assert_equal(OpenSSL::BN.new(modulus, 2), extended.public_key.n)
208
+ end
209
+
210
+ def test_dsa_is_rejected_and_does_not_hide_a_supported_signature
211
+ private_key = OpenSSL::PKey::DSA.new(DSA_PRIVATE_KEY)
212
+ bytes = [8].pack('C') + private_key.q.to_s(2).rjust(20, "\0") +
213
+ [:p, :g, :pub_key].map { |part| private_key.public_send(part).to_s(2).rjust(128, "\0") }.join
214
+ key = wire_key(3, bytes)
215
+ valid_ec = signed(@record, @a, 0).sigs.first
216
+ valid_dsa = valid_ec.clone
217
+ valid_dsa.algorithm = 3
218
+ valid_dsa.key_tag = key.key_tag
219
+ data = Dnsruby::MessageEncoder.new { |encoder| encoder.put_rr(@record, true) }.to_s
220
+ der = private_key.sign(OpenSSL::Digest::SHA1.new, valid_dsa.sig_data + data)
221
+ valid_dsa.signature = [8].pack('C') +
222
+ OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(20, "\0") }.join
223
+ error = assert_raises(Dnsruby::VerifyError) do
224
+ @verifier.verify_rrset(Dnsruby::RRSet.new([@record, valid_dsa]), key)
225
+ end
226
+ assert_match(/prohibited/, error.message)
227
+
228
+ ['', "\0", [7].pack('C') + valid_dsa.signature.byteslice(1..-1), [8].pack('C') + "\0" * 40].each do |signature|
229
+ invalid = valid_dsa.clone
230
+ invalid.signature = signature
231
+ records = Dnsruby::RRSet.new([@record, invalid, valid_ec])
232
+ assert(@verifier.verify_rrset(records, [key, @a]))
233
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, key) }
234
+ end
235
+ end
236
+
237
+ def test_unsupported_signature_candidate_does_not_hide_supported_signature
238
+ unsupported = Dnsruby::RR.create("collision.invalid. 300 IN DNSKEY 257 3 17 #{Base64.strict_encode64(@a.key)}")
239
+ valid = signed(@record, @a, 0).sigs.first
240
+ invalid = valid.clone
241
+ invalid.algorithm = 17
242
+ invalid.key_tag = unsupported.key_tag
243
+ records = Dnsruby::RRSet.new([@record, invalid, valid])
244
+ assert(@verifier.verify_rrset(records, [unsupported, @a]))
245
+ assert(@verifier.verify_rrset(records, [@a, unsupported]))
246
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, unsupported) }
247
+ end
248
+
249
+ def test_invalid_signature_metadata_does_not_control_ds_discovery_expiration
250
+ [:expired, :future].each do |mode|
251
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
252
+ verifier.add_trust_anchor(Dnsruby::RR::DS.from_key(@a, 2))
253
+ valid = signed(@a, @a, 0).sigs.first
254
+ invalid = invalid_signature(valid, mode)
255
+ records = Dnsruby::RRSet.new([@a, invalid])
256
+ assert_raises(Dnsruby::VerifyError) { verifier.verify(records) }
257
+ assert_empty(verifier.get_keys_to_check)
258
+ records = Dnsruby::RRSet.new([@a, invalid, valid])
259
+ assert(verifier.verify(records))
260
+ assert_equal([Dnsruby::KeyCache.key_identity(@a)],
261
+ verifier.get_keys_to_check.map { |key| Dnsruby::KeyCache.key_identity(key) })
262
+ Time.stub(:now, @now + 3601) { assert_empty(verifier.get_keys_to_check) }
263
+ end
264
+ end
265
+
266
+ def test_key_class_must_match_signature_class
267
+ foreign_key = Dnsruby::RR.create(@a.to_s)
268
+ foreign_key.klass = Dnsruby::Classes.CH
269
+ records = signed(@record, @a, 0)
270
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, foreign_key) }
271
+ assert(@verifier.verify_rrset(records, [foreign_key, @a]))
272
+ end
273
+
274
+ def test_removing_one_collision_preserves_other_anchor_and_revokes_learned_trust
275
+ records_a, records_b = signed(@a, @a, 0), signed(@b, @b, 1)
276
+ [@a, @b].each { |key| @verifier.add_trust_anchor(key) }
277
+ assert(@verifier.verify(records_a))
278
+ assert(@verifier.verify(records_b))
279
+ assert_equal(@a, @verifier.remove_trust_anchor(@a))
280
+ assert_equal([@b], @verifier.trust_anchors)
281
+ assert_empty(@verifier.trusted_keys)
282
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(records_a) }
283
+ assert(@verifier.verify(records_b))
284
+ end
285
+
286
+ def test_removing_an_expired_pruned_anchor_revokes_its_learned_keys
287
+ @verifier.add_trust_anchor_with_expiration(@a, @now.to_i + 1)
288
+ @verifier.add_trust_anchor_with_expiration(@b, @now.to_i + 120)
289
+ records = signed(@a, @a, 0)
290
+ Time.stub(:now, @now) { assert(@verifier.verify(records)) }
291
+ Time.stub(:now, @now + 2) do
292
+ assert_equal([@b], @verifier.trust_anchors) # Naturally expire and prune A.
293
+ assert_empty(@verifier.trusted_keys)
294
+ assert_nil(@verifier.remove_trust_anchor(@a))
295
+ assert_equal([@b], @verifier.trust_anchors)
296
+ assert_empty(@verifier.trusted_keys)
297
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(records) }
298
+ end
299
+ Time.stub(:now, @now + 121) do
300
+ assert_empty(@verifier.trust_anchors)
301
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(records) }
302
+ end
303
+ end
304
+
305
+ def test_expired_anchor_removal_prevents_in_flight_key_republication
306
+ @verifier.add_trust_anchor_with_expiration(@a, @now.to_i + 1)
307
+ @verifier.add_trust_anchor_with_expiration(@b, @now.to_i + 120)
308
+ records = signed(@a, @a, 0)
309
+ clock = @now
310
+ Time.stub(:now, -> { clock }) do
311
+ mutation = lambda do
312
+ clock = @now + 2
313
+ assert_equal([@b], @verifier.trust_anchors)
314
+ assert_nil(@verifier.remove_trust_anchor(@a))
315
+ end
316
+ pause_after_verification(mutation) { assert(@verifier.verify(records)) }
317
+ assert_equal([@b], @verifier.trust_anchors)
318
+ assert_empty(@verifier.trusted_keys)
319
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(records) }
320
+ end
321
+ end
322
+
323
+ private
324
+
325
+ def wire_key(algorithm, bytes)
326
+ key = Dnsruby::RR.create(@a.to_s)
327
+ key.algorithm = algorithm
328
+ key.instance_variable_set(:@key, bytes.b)
329
+ message = Dnsruby::Message.new
330
+ message.add_answer(key)
331
+ Dnsruby::Message.decode(message.encode).answer.first
332
+ end
333
+
334
+ def invalid_signature(valid, mode)
335
+ invalid = valid.clone
336
+ case mode
337
+ when :expired
338
+ invalid.expiration = @now.to_i - 1
339
+ when :future
340
+ invalid.inception = @now.to_i + 3600
341
+ invalid.expiration = @now.to_i + 7200
342
+ when :corrupted
343
+ invalid.signature = "\0" * 64
344
+ end
345
+ invalid
346
+ end
347
+
348
+ def signed(record, key, signer)
349
+ signature = Dnsruby::RR.create(name: record.name, type: 'RRSIG', ttl: 300,
350
+ type_covered: record.type, algorithm: 13, labels: record.name.labels.length,
351
+ original_ttl: 300, expiration: @now.to_i + 3600, inception: @now.to_i - 60,
352
+ key_tag: key.key_tag, signers_name: key.name)
353
+ data = Dnsruby::MessageEncoder.new { |encoder| encoder.put_rr(record, true) }.to_s
354
+ der = @private_keys[signer].dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + data))
355
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
356
+ Dnsruby::RRSet.new([record, signature])
357
+ end
358
+
359
+ def pause_after_verification(mutation)
360
+ arrived, resume, start = Queue.new, Queue.new, Queue.new
361
+ original = @verifier.method(:verify_rrset)
362
+ thread = nil
363
+ @verifier.define_singleton_method(:verify_rrset) do |*args|
364
+ result = original.call(*args)
365
+ if Thread.current.equal?(thread)
366
+ arrived << true
367
+ resume.pop
368
+ end
369
+ result
370
+ end
371
+ thread = Thread.new { start.pop; yield }
372
+ start << true
373
+ Timeout.timeout(3) { arrived.pop }
374
+ Timeout.timeout(3) { mutation.call }
375
+ resume << true
376
+ Timeout.timeout(3) { thread.value }
377
+ ensure
378
+ @verifier.singleton_class.remove_method(:verify_rrset) if original
379
+ thread.kill if thread && thread.alive?
380
+ thread.join if thread
381
+ end
382
+ end