pending-dns 1.2.5 → 1.4.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 (52) hide show
  1. package/.github/codeql/codeql-config.yml +11 -0
  2. package/.github/workflows/codeql.yml +52 -0
  3. package/.github/workflows/deploy.yml +16 -3
  4. package/.github/workflows/release.yaml +43 -0
  5. package/.github/workflows/test.yml +75 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/CHANGELOG.md +16 -0
  8. package/CLAUDE.md +109 -0
  9. package/README.md +111 -9
  10. package/SECURITY.md +88 -0
  11. package/SECURITY.txt +27 -0
  12. package/bin/pending-dns.js +1 -1
  13. package/config/default.toml +43 -0
  14. package/config/test.toml +35 -0
  15. package/eslint.config.js +38 -0
  16. package/lib/api-server.js +198 -23
  17. package/lib/cached-resolver.js +5 -3
  18. package/lib/certs.js +12 -20
  19. package/lib/dns-handler.js +362 -32
  20. package/lib/dns-server.js +120 -43
  21. package/lib/dns-tcp-server.js +1 -1
  22. package/lib/dns-udp-server.js +1 -1
  23. package/lib/dnssec-wire.js +321 -0
  24. package/lib/dnssec.js +461 -0
  25. package/lib/lock.js +37 -0
  26. package/lib/logger.js +3 -0
  27. package/lib/public-server.js +20 -2
  28. package/lib/sentry.js +72 -0
  29. package/lib/tools.js +1 -1
  30. package/lib/zone-store.js +90 -7
  31. package/package.json +46 -33
  32. package/release-please-config.json +14 -0
  33. package/server.js +5 -24
  34. package/systemd/pending-dns.service +4 -4
  35. package/test/api.test.js +231 -0
  36. package/test/cached-resolver.test.js +57 -0
  37. package/test/certs.test.js +34 -0
  38. package/test/dns-handler.test.js +171 -0
  39. package/test/dns-server.test.js +162 -0
  40. package/test/dnssec-handler.test.js +550 -0
  41. package/test/dnssec-wire.test.js +163 -0
  42. package/test/dnssec.test.js +213 -0
  43. package/test/helpers.js +27 -0
  44. package/test/sentry.test.js +21 -0
  45. package/test/tools.test.js +48 -0
  46. package/test/zone-store.test.js +245 -0
  47. package/workers/api.js +3 -1
  48. package/workers/dns.js +2 -24
  49. package/workers/health.js +3 -26
  50. package/workers/public.js +3 -25
  51. package/.eslintrc +0 -14
  52. package/Gruntfile.js +0 -16
@@ -0,0 +1,550 @@
1
+ 'use strict';
2
+
3
+ /* eslint-disable no-bitwise */
4
+
5
+ // End-to-end DNSSEC signing through the DNS handler (Redis db 15). RRSIGs are
6
+ // verified by reconstructing the canonical signing input and checking the
7
+ // signature with the public half of the zone key.
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const crypto = require('crypto');
12
+
13
+ const dns2 = require('dns2');
14
+ const Packet = dns2.Packet;
15
+
16
+ const dnsHandler = require('../lib/dns-handler');
17
+ const dnssec = require('../lib/dnssec');
18
+ const wire = require('../lib/dnssec-wire');
19
+ const { zoneStore } = require('../lib/zone-store');
20
+ const { config, flushTestDb, closeDb } = require('./helpers');
21
+
22
+ test.after(async () => {
23
+ await closeDb();
24
+ });
25
+
26
+ const DO = { hasOpt: true, doFlag: true };
27
+
28
+ const buildRequest = (name, type) => {
29
+ const req = new Packet({});
30
+ req.questions = [{ name, type: typeof type === 'number' ? type : Packet.TYPE[type], class: Packet.CLASS.IN }];
31
+ req.source = { type: 'udp', address: '127.0.0.1', port: 5353 };
32
+ return req;
33
+ };
34
+
35
+ const parseRRSIG = data => {
36
+ let off = 18;
37
+ while (data[off] !== 0) {
38
+ off += 1 + data[off];
39
+ }
40
+ off += 1;
41
+ return {
42
+ typeCovered: data.readUInt16BE(0),
43
+ algorithm: data.readUInt8(2),
44
+ labels: data.readUInt8(3),
45
+ originalTtl: data.readUInt32BE(4),
46
+ keyTag: data.readUInt16BE(16),
47
+ preimage: data.slice(0, off),
48
+ signature: data.slice(off)
49
+ };
50
+ };
51
+
52
+ // Find the RRSIG covering `typeNum` at `owner` in a section and verify it the
53
+ // way a real validator would. `signingOwner` is the name the signature was
54
+ // computed over: for a wildcard expansion that is the wildcard owner
55
+ // (`*.zone`), reconstructed from the RRSIG labels (RFC 4035 5.3.2), not the
56
+ // expanded wire owner.
57
+ const verifyRRSIG = async (section, owner, typeNum, zone, signingOwner) => {
58
+ signingOwner = signingOwner || owner;
59
+ const signer = await dnssec.getSigner(zone);
60
+ const rrs = section.filter(rr => rr.name === owner && rr.type === typeNum);
61
+ assert.ok(rrs.length, `expected ${typeNum} records at ${owner}`);
62
+ const rrsig = section.find(rr => rr.type === wire.TYPE.RRSIG && rr.name === owner && rr.data.readUInt16BE(0) === typeNum);
63
+ assert.ok(rrsig, `expected an RRSIG covering ${typeNum} at ${owner}`);
64
+
65
+ const parsed = parseRRSIG(rrsig.data);
66
+ const canonical = rrs
67
+ .map(rr => wire.canonicalRdata(typeNum, rr))
68
+ .sort(wire.compareCanonicalRdata)
69
+ .map(rdata => wire.canonicalRR(signingOwner, typeNum, 1, parsed.originalTtl, rdata));
70
+ const tbs = Buffer.concat([parsed.preimage, ...canonical]);
71
+ // Pick the key whose algorithm matches this RRSIG (a zone mid-rollover has
72
+ // one signing key per algorithm).
73
+ const key = signer.keys.find(k => k.algorithm === parsed.algorithm) || signer.keys[0];
74
+ const publicKey = crypto.createPublicKey(key.privateKeyObj);
75
+ assert.ok(wire.ALGS[parsed.algorithm].verify(tbs, publicKey, parsed.signature), `RRSIG over ${typeNum} must verify`);
76
+ return parsed;
77
+ };
78
+
79
+ test('a signed A answer carries a verifiable RRSIG (and no AD bit)', async () => {
80
+ await flushTestDb();
81
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
82
+ await dnssec.enableZone('example.com', { algorithm: 13 });
83
+
84
+ const response = await dnsHandler(buildRequest('example.com', 'A'), DO);
85
+ // AD is a validating-resolver signal (RFC 6840 5.7); an authoritative server
86
+ // leaves it clear.
87
+ assert.notEqual(response.header.ad, 1);
88
+ assert.ok(response.answers.some(a => a.type === Packet.TYPE.A));
89
+ await verifyRRSIG(response.answers, 'example.com', Packet.TYPE.A, 'example.com');
90
+ });
91
+
92
+ test('a DNSKEY query returns a self-signed DNSKEY RRset', async () => {
93
+ await flushTestDb();
94
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
95
+ await dnssec.enableZone('example.com', { algorithm: 13 });
96
+
97
+ const response = await dnsHandler(buildRequest('example.com', 'DNSKEY'), DO);
98
+ assert.ok(
99
+ response.answers.some(a => a.type === wire.TYPE.DNSKEY),
100
+ 'DNSKEY present'
101
+ );
102
+ await verifyRRSIG(response.answers, 'example.com', wire.TYPE.DNSKEY, 'example.com');
103
+ });
104
+
105
+ test('a signed CAA answer carries a verifiable RRSIG', async () => {
106
+ await flushTestDb();
107
+ await zoneStore.add('example.com', '', 'CAA', ['letsencrypt.org', 'issue', 0]);
108
+ await dnssec.enableZone('example.com', { algorithm: 13 });
109
+
110
+ const response = await dnsHandler(buildRequest('example.com', 'CAA'), DO);
111
+ assert.ok(
112
+ response.answers.some(a => a.type === Packet.TYPE.CAA),
113
+ 'CAA answer is returned'
114
+ );
115
+ // CAA uses dns2's native encoder; the RRSIG must verify over its canonical RDATA.
116
+ await verifyRRSIG(response.answers, 'example.com', Packet.TYPE.CAA, 'example.com');
117
+ });
118
+
119
+ test('a signed TLSA answer carries a verifiable RRSIG (raw-RDATA type)', async () => {
120
+ await flushTestDb();
121
+ const certHex = '92003ba34942dc74152e2f2c408d29eca5a520e7f2e06bb944f4dca346baf63c';
122
+ await zoneStore.add('example.com', '_443._tcp.www', 'TLSA', [3, 1, 1, certHex]);
123
+ await dnssec.enableZone('example.com', { algorithm: 13 });
124
+
125
+ const response = await dnsHandler(buildRequest('_443._tcp.www.example.com', wire.TYPE.TLSA), DO);
126
+ assert.ok(
127
+ response.answers.some(a => a.type === wire.TYPE.TLSA),
128
+ 'TLSA answer is returned'
129
+ );
130
+ // TLSA is emitted as pre-built raw RDATA (the {type, data} path); the RRSIG must
131
+ // verify over those exact bytes.
132
+ await verifyRRSIG(response.answers, '_443._tcp.www.example.com', wire.TYPE.TLSA, 'example.com');
133
+ });
134
+
135
+ test('NODATA is proven with a signed SOA and NSEC (NOERROR)', async () => {
136
+ await flushTestDb();
137
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
138
+ await dnssec.enableZone('example.com', { algorithm: 13 });
139
+
140
+ const response = await dnsHandler(buildRequest('example.com', 'TXT'), DO);
141
+ assert.equal(response.header.rcode || 0, 0, 'NODATA is NOERROR');
142
+ assert.equal(response.answers.length, 0);
143
+
144
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC);
145
+ assert.ok(nsec, 'NSEC present in authority');
146
+ assert.ok(
147
+ response.authorities.some(a => a.type === Packet.TYPE.SOA),
148
+ 'SOA present in authority'
149
+ );
150
+
151
+ // bitmap must include A (exists) and CAA (synthesized for every name) but
152
+ // not TXT (queried, absent)
153
+ const bitmap = nsec.data.slice(wire.encodeName('example.com').length);
154
+ const present = decodeBitmap(bitmap);
155
+ assert.ok(present.has(Packet.TYPE.A), 'A is listed');
156
+ assert.ok(present.has(Packet.TYPE.CAA), 'CAA is listed (synthesized for any name)');
157
+ assert.ok(!present.has(Packet.TYPE.TXT), 'TXT is not listed');
158
+
159
+ // NSEC TTL tracks the SOA minimum so the proof and the negative answer
160
+ // expire together (RFC 2308).
161
+ assert.equal(nsec.ttl, config.soa.minimum, 'NSEC TTL equals the SOA minimum');
162
+
163
+ await verifyRRSIG(response.authorities, 'example.com', wire.TYPE.NSEC, 'example.com');
164
+ await verifyRRSIG(response.authorities, 'example.com', Packet.TYPE.SOA, 'example.com');
165
+ });
166
+
167
+ test('a nonexistent name is denied with signed NODATA (NOERROR, black lies)', async () => {
168
+ await flushTestDb();
169
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
170
+ await dnssec.enableZone('example.com', { algorithm: 13 });
171
+
172
+ const response = await dnsHandler(buildRequest('nope.example.com', 'A'), DO);
173
+ // Denial is always NOERROR - never wire NXDOMAIN - because the server can
174
+ // synthesize CAA/NS/SOA for any name, so no name is truly nonexistent.
175
+ assert.equal(response.header.rcode || 0, 0, 'denial is NOERROR, never NXDOMAIN');
176
+ assert.equal(response.answers.length, 0);
177
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'nope.example.com');
178
+ assert.ok(nsec, 'NSEC at the queried name');
179
+ await verifyRRSIG(response.authorities, 'nope.example.com', wire.TYPE.NSEC, 'example.com');
180
+ });
181
+
182
+ test('a name covered only by a wildcard is NODATA (NOERROR) for other types', async () => {
183
+ await flushTestDb();
184
+ await zoneStore.add('example.com', '*', 'A', ['1.2.3.4']);
185
+ await dnssec.enableZone('example.com', { algorithm: 13 });
186
+
187
+ const a = await dnsHandler(buildRequest('nope.example.com', 'A'), DO);
188
+ assert.ok(
189
+ a.answers.some(rr => rr.type === Packet.TYPE.A),
190
+ 'the wildcard answers A'
191
+ );
192
+
193
+ // The same name queried for a type the wildcard does not supply must be
194
+ // NODATA (NOERROR), not NXDOMAIN - the name exists via the wildcard.
195
+ const aaaa = await dnsHandler(buildRequest('nope.example.com', 'AAAA'), DO);
196
+ assert.equal(aaaa.header.rcode || 0, 0, 'AAAA at a wildcard-covered name is NODATA, not NXDOMAIN');
197
+ });
198
+
199
+ test('a wildcard answer is signed over the wildcard owner with reduced labels and a proving NSEC', async () => {
200
+ await flushTestDb();
201
+ await zoneStore.add('example.com', '*', 'A', ['1.1.1.1']);
202
+ await dnssec.enableZone('example.com', { algorithm: 13 });
203
+
204
+ const response = await dnsHandler(buildRequest('foo.example.com', 'A'), DO);
205
+ // The signature must verify when reconstructed with the wildcard owner
206
+ // (*.example.com), which is what a real validator does (RFC 4035 5.3.2).
207
+ const parsed = await verifyRRSIG(response.answers, 'foo.example.com', Packet.TYPE.A, 'example.com', '*.example.com');
208
+ assert.equal(parsed.labels, 2, 'RRSIG labels reflect the wildcard owner, not the qname');
209
+
210
+ // It must NOT verify when reconstructed with the expanded query name - that
211
+ // would be the bug where the server signed over the wrong owner.
212
+ const signer = await dnssec.getSigner('example.com');
213
+ const aRecords = response.answers.filter(rr => rr.name === 'foo.example.com' && rr.type === Packet.TYPE.A);
214
+ const rrsig = response.answers.find(rr => rr.type === wire.TYPE.RRSIG && rr.name === 'foo.example.com');
215
+ const p = parseRRSIG(rrsig.data);
216
+ const expandedCanonical = aRecords
217
+ .map(rr => wire.canonicalRdata(Packet.TYPE.A, rr))
218
+ .sort(wire.compareCanonicalRdata)
219
+ .map(rd => wire.canonicalRR('foo.example.com', Packet.TYPE.A, 1, p.originalTtl, rd));
220
+ const pub = crypto.createPublicKey(signer.keys[0].privateKeyObj);
221
+ assert.equal(
222
+ wire.ALGS[p.algorithm].verify(Buffer.concat([p.preimage, ...expandedCanonical]), pub, p.signature),
223
+ false,
224
+ 'must NOT verify when reconstructed with the expanded owner'
225
+ );
226
+
227
+ assert.ok(
228
+ response.authorities.some(a => a.type === wire.TYPE.NSEC && a.name === 'foo.example.com'),
229
+ 'a proving NSEC for the exact name is included'
230
+ );
231
+ });
232
+
233
+ test('an IDN zone signs with punycode (A-label) names', async () => {
234
+ await flushTestDb();
235
+ await zoneStore.add('xn--mnchen-3ya.example', '', 'A', ['9.8.7.6']);
236
+ await dnssec.enableZone('xn--mnchen-3ya.example', { algorithm: 13 });
237
+
238
+ const signer = await dnssec.getSigner('xn--mnchen-3ya.example');
239
+ assert.equal(signer.zone, 'xn--mnchen-3ya.example', 'signer name is the A-label form, not Unicode');
240
+
241
+ // Query using the Unicode form; the answer + RRSIG must use A-label names so
242
+ // a validator that follows the punycode delegation can verify.
243
+ // Query in the Unicode form (escaped so the source stays printable ASCII per
244
+ // CLAUDE.md); 'm\u00fcnchen.example' is the IDN for the xn--mnchen-3ya zone above.
245
+ const response = await dnsHandler(buildRequest('m\u00fcnchen.example', 'A'), DO);
246
+ await verifyRRSIG(response.answers, 'xn--mnchen-3ya.example', Packet.TYPE.A, 'xn--mnchen-3ya.example');
247
+ });
248
+
249
+ test('a delegation (non-apex) NS RRset is not signed; apex NS is', async () => {
250
+ await flushTestDb();
251
+ await zoneStore.add('example.com', '', 'NS', ['ns1.example.com']);
252
+ await zoneStore.add('example.com', 'sub', 'NS', ['ns1.other.example']);
253
+ await dnssec.enableZone('example.com', { algorithm: 13 });
254
+
255
+ const apex = await dnsHandler(buildRequest('example.com', 'NS'), DO);
256
+ assert.ok(
257
+ apex.answers.some(rr => rr.type === wire.TYPE.RRSIG && rr.name === 'example.com' && rr.data.readUInt16BE(0) === Packet.TYPE.NS),
258
+ 'apex NS is signed'
259
+ );
260
+
261
+ const deleg = await dnsHandler(buildRequest('sub.example.com', 'NS'), DO);
262
+ assert.ok(
263
+ deleg.answers.some(rr => rr.type === Packet.TYPE.NS && rr.name === 'sub.example.com'),
264
+ 'delegation NS is returned'
265
+ );
266
+ assert.ok(!deleg.answers.some(rr => rr.type === wire.TYPE.RRSIG && rr.name === 'sub.example.com'), 'delegation NS RRset is not signed (RFC 4035 2.2)');
267
+ });
268
+
269
+ test('after an algorithm rollover every RRset carries an RRSIG per algorithm', async () => {
270
+ await flushTestDb();
271
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
272
+ await dnssec.enableZone('example.com', { algorithm: 13 });
273
+ await dnssec.enableZone('example.com', { algorithm: 15 });
274
+
275
+ const response = await dnsHandler(buildRequest('example.com', 'A'), DO);
276
+ const sigs = response.answers.filter(rr => rr.type === wire.TYPE.RRSIG && rr.name === 'example.com' && rr.data.readUInt16BE(0) === Packet.TYPE.A);
277
+ assert.equal(sigs.length, 2, 'one A RRSIG per algorithm during rollover');
278
+ assert.deepEqual(
279
+ sigs.map(s => parseRRSIG(s.data).algorithm).sort((a, b) => a - b),
280
+ [13, 15]
281
+ );
282
+
283
+ // Both RRSIGs must verify with their respective key.
284
+ const signer = await dnssec.getSigner('example.com');
285
+ const aRecords = response.answers.filter(rr => rr.name === 'example.com' && rr.type === Packet.TYPE.A);
286
+ for (const sig of sigs) {
287
+ const parsed = parseRRSIG(sig.data);
288
+ const canonical = aRecords
289
+ .map(rr => wire.canonicalRdata(Packet.TYPE.A, rr))
290
+ .sort(wire.compareCanonicalRdata)
291
+ .map(rd => wire.canonicalRR('example.com', Packet.TYPE.A, 1, parsed.originalTtl, rd));
292
+ const key = signer.keys.find(k => k.algorithm === parsed.algorithm);
293
+ const pub = crypto.createPublicKey(key.privateKeyObj);
294
+ assert.ok(
295
+ wire.ALGS[parsed.algorithm].verify(Buffer.concat([parsed.preimage, ...canonical]), pub, parsed.signature),
296
+ `alg ${parsed.algorithm} RRSIG verifies`
297
+ );
298
+ }
299
+ });
300
+
301
+ test('without the DO bit the response is unsigned', async () => {
302
+ await flushTestDb();
303
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
304
+ await dnssec.enableZone('example.com', { algorithm: 13 });
305
+
306
+ const response = await dnsHandler(buildRequest('example.com', 'A'));
307
+ assert.ok(!response.answers.some(a => a.type === wire.TYPE.RRSIG), 'no RRSIG without DO');
308
+ assert.notEqual(response.header.ad, 1);
309
+ });
310
+
311
+ test('a duplicate-valued RRset is signed over the de-duplicated set (RFC 4034 6.3)', async () => {
312
+ await flushTestDb();
313
+ // The API/store assigns a fresh hid per add, so the same value can be stored twice.
314
+ await zoneStore.add('example.com', 'dup', 'A', ['1.2.3.4']);
315
+ await zoneStore.add('example.com', 'dup', 'A', ['1.2.3.4']);
316
+ await dnssec.enableZone('example.com', { algorithm: 13 });
317
+
318
+ const response = await dnsHandler(buildRequest('dup.example.com', 'A'), DO);
319
+ const aRecords = response.answers.filter(rr => rr.name === 'dup.example.com' && rr.type === Packet.TYPE.A);
320
+ assert.equal(aRecords.length, 2, 'both duplicate A records are returned');
321
+
322
+ const rrsig = response.answers.find(rr => rr.type === wire.TYPE.RRSIG && rr.name === 'dup.example.com' && rr.data.readUInt16BE(0) === Packet.TYPE.A);
323
+ assert.ok(rrsig, 'an A RRSIG is present');
324
+ const parsed = parseRRSIG(rrsig.data);
325
+
326
+ // Reconstruct the way a validator does: de-duplicate identical RRs first.
327
+ const dedup = [...new Map(aRecords.map(rr => [wire.canonicalRdata(Packet.TYPE.A, rr).toString('hex'), rr])).values()];
328
+ assert.equal(dedup.length, 1, 'the RRset de-duplicates to a single RR');
329
+ const canonical = dedup
330
+ .map(rr => wire.canonicalRdata(Packet.TYPE.A, rr))
331
+ .sort(wire.compareCanonicalRdata)
332
+ .map(rd => wire.canonicalRR('dup.example.com', Packet.TYPE.A, 1, parsed.originalTtl, rd));
333
+ const signer = await dnssec.getSigner('example.com');
334
+ const pub = crypto.createPublicKey(signer.keys[0].privateKeyObj);
335
+ assert.ok(
336
+ wire.ALGS[parsed.algorithm].verify(Buffer.concat([parsed.preimage, ...canonical]), pub, parsed.signature),
337
+ 'RRSIG verifies over the de-duplicated RRset'
338
+ );
339
+ });
340
+
341
+ test('non-apex NODATA NSEC bitmap lists SOA but not NS', async () => {
342
+ await flushTestDb();
343
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
344
+ await dnssec.enableZone('example.com', { algorithm: 13 });
345
+
346
+ const response = await dnsHandler(buildRequest('nope.example.com', 'A'), DO);
347
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'nope.example.com');
348
+ assert.ok(nsec, 'NSEC at the queried name');
349
+ const present = decodeBitmap(nsec.data.slice(wire.encodeName('nope.example.com').length));
350
+ assert.ok(present.has(Packet.TYPE.SOA), 'SOA is listed (synthesized for any name)');
351
+ assert.ok(present.has(Packet.TYPE.CAA), 'CAA is listed');
352
+ assert.ok(!present.has(Packet.TYPE.NS), 'NS is NOT listed below the apex (would signal a delegation)');
353
+ });
354
+
355
+ test('a wildcard-covered NODATA bitmap lists the wildcard type so it cannot be suppressed', async () => {
356
+ await flushTestDb();
357
+ await zoneStore.add('example.com', '*', 'A', ['1.2.3.4']);
358
+ await dnssec.enableZone('example.com', { algorithm: 13 });
359
+
360
+ // Query a type the wildcard does NOT supply -> NODATA. The bitmap must still
361
+ // list A so an RFC 8198 aggressive-NSEC resolver cannot synthesize a NODATA
362
+ // that suppresses the wildcard A on a later query.
363
+ const response = await dnsHandler(buildRequest('foo.example.com', 'AAAA'), DO);
364
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'foo.example.com');
365
+ assert.ok(nsec, 'NSEC at the queried name');
366
+ const present = decodeBitmap(nsec.data.slice(wire.encodeName('foo.example.com').length));
367
+ assert.ok(present.has(Packet.TYPE.A), 'the wildcard-supplied A is listed');
368
+ assert.ok(!present.has(Packet.TYPE.AAAA), 'the queried-absent AAAA is not listed');
369
+ });
370
+
371
+ test('a wildcard-positive proving NSEC excludes the answered type', async () => {
372
+ await flushTestDb();
373
+ await zoneStore.add('example.com', '*', 'A', ['1.1.1.1']);
374
+ await dnssec.enableZone('example.com', { algorithm: 13 });
375
+
376
+ const response = await dnsHandler(buildRequest('foo.example.com', 'A'), DO);
377
+ assert.ok(
378
+ response.answers.some(rr => rr.type === Packet.TYPE.A && rr.name === 'foo.example.com'),
379
+ 'the wildcard answers A'
380
+ );
381
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'foo.example.com');
382
+ assert.ok(nsec, 'a proving NSEC is included');
383
+ const present = decodeBitmap(nsec.data.slice(wire.encodeName('foo.example.com').length));
384
+ assert.ok(!present.has(Packet.TYPE.A), 'the proving NSEC must not list the wildcard-answered type');
385
+ });
386
+
387
+ test('a below-apex record-less NS query is a signed NODATA (not unsigned NS)', async () => {
388
+ await flushTestDb();
389
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
390
+ await dnssec.enableZone('example.com', { algorithm: 13 });
391
+
392
+ const response = await dnsHandler(buildRequest('sub.example.com', 'NS'), DO);
393
+ // No synthesized (unsigned) NS in the answer below the apex.
394
+ assert.ok(!response.answers.some(rr => rr.type === Packet.TYPE.NS), 'no NS records in the answer');
395
+ assert.equal(response.header.rcode || 0, 0, 'NODATA is NOERROR');
396
+
397
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'sub.example.com');
398
+ assert.ok(nsec, 'a signed NSEC denial is present at the queried name');
399
+ assert.ok(
400
+ response.authorities.some(a => a.type === Packet.TYPE.SOA),
401
+ 'SOA present in authority'
402
+ );
403
+
404
+ // The NSEC bitmap must NOT set the NS bit below the apex (RFC 4034 4.1.3).
405
+ const present = decodeBitmap(nsec.data.slice(wire.encodeName('sub.example.com').length));
406
+ assert.ok(!present.has(Packet.TYPE.NS), 'NSEC bitmap does not list NS below the apex');
407
+
408
+ await verifyRRSIG(response.authorities, 'sub.example.com', wire.TYPE.NSEC, 'example.com');
409
+ await verifyRRSIG(response.authorities, 'example.com', Packet.TYPE.SOA, 'example.com');
410
+ });
411
+
412
+ test('an apex NS query is still answered with signed (synthesized) NS', async () => {
413
+ await flushTestDb();
414
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
415
+ await dnssec.enableZone('example.com', { algorithm: 13 });
416
+
417
+ const response = await dnsHandler(buildRequest('example.com', 'NS'), DO);
418
+ assert.ok(
419
+ response.answers.some(rr => rr.type === Packet.TYPE.NS && rr.name === 'example.com'),
420
+ 'apex NS is synthesized'
421
+ );
422
+ await verifyRRSIG(response.answers, 'example.com', Packet.TYPE.NS, 'example.com');
423
+ });
424
+
425
+ test('a multi-question NODATA packet emits a single SOA and NSEC per owner', async () => {
426
+ await flushTestDb();
427
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
428
+ await dnssec.enableZone('example.com', { algorithm: 13 });
429
+
430
+ const req = new Packet({});
431
+ req.questions = [
432
+ { name: 'nope.example.com', type: Packet.TYPE.TXT, class: Packet.CLASS.IN },
433
+ { name: 'nope.example.com', type: Packet.TYPE.MX, class: Packet.CLASS.IN }
434
+ ];
435
+ req.source = { type: 'udp', address: '127.0.0.1', port: 5353 };
436
+
437
+ const response = await dnsHandler(req, DO);
438
+ const nsec = response.authorities.filter(a => a.type === wire.TYPE.NSEC && a.name === 'nope.example.com');
439
+ const soa = response.authorities.filter(a => a.type === Packet.TYPE.SOA && a.name === 'example.com');
440
+ assert.equal(nsec.length, 1, 'exactly one NSEC at the owner');
441
+ assert.equal(soa.length, 1, 'exactly one SOA in the authority');
442
+ await verifyRRSIG(response.authorities, 'nope.example.com', wire.TYPE.NSEC, 'example.com');
443
+ });
444
+
445
+ test('a multi-question wildcard+NODATA packet emits one consistent NSEC per owner', async () => {
446
+ await flushTestDb();
447
+ await zoneStore.add('example.com', '*', 'A', ['1.2.3.4']);
448
+ await dnssec.enableZone('example.com', { algorithm: 13 });
449
+
450
+ // {A (wildcard hit), AAAA (NODATA)} at the same name previously produced two
451
+ // contradictory NSEC RDATAs (one with the A bit, one without) signed as one RRset.
452
+ const req = new Packet({});
453
+ req.questions = [
454
+ { name: 'foo.example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN },
455
+ { name: 'foo.example.com', type: Packet.TYPE.AAAA, class: Packet.CLASS.IN }
456
+ ];
457
+ req.source = { type: 'udp', address: '127.0.0.1', port: 5353 };
458
+
459
+ const response = await dnsHandler(req, DO);
460
+ const nsec = response.authorities.filter(a => a.type === wire.TYPE.NSEC && a.name === 'foo.example.com');
461
+ assert.equal(nsec.length, 1, 'exactly one NSEC at foo.example.com (no contradictory pair)');
462
+ assert.ok(
463
+ response.answers.some(rr => rr.type === Packet.TYPE.A && rr.name === 'foo.example.com'),
464
+ 'wildcard A is still answered'
465
+ );
466
+ await verifyRRSIG(response.authorities, 'foo.example.com', wire.TYPE.NSEC, 'example.com');
467
+ });
468
+
469
+ test('an ANY query for a record-less name is a signed NODATA', async () => {
470
+ await flushTestDb();
471
+ await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
472
+ await dnssec.enableZone('example.com', { algorithm: 13 });
473
+
474
+ const response = await dnsHandler(buildRequest('ghost.example.com', 'ANY'), DO);
475
+ assert.equal(response.answers.length, 0, 'no answers');
476
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'ghost.example.com');
477
+ assert.ok(nsec, 'a signed NSEC denial is present for ANY NODATA');
478
+ assert.ok(
479
+ response.authorities.some(a => a.type === Packet.TYPE.SOA),
480
+ 'SOA present in authority'
481
+ );
482
+ await verifyRRSIG(response.authorities, 'ghost.example.com', wire.TYPE.NSEC, 'example.com');
483
+ });
484
+
485
+ test('an ANY query at a name with records returns them with no denial', async () => {
486
+ await flushTestDb();
487
+ await zoneStore.add('example.com', 'host', 'A', ['9.8.7.6']);
488
+ await dnssec.enableZone('example.com', { algorithm: 13 });
489
+
490
+ const response = await dnsHandler(buildRequest('host.example.com', 'ANY'), DO);
491
+ assert.ok(
492
+ response.answers.some(rr => rr.type === Packet.TYPE.A && rr.name === 'host.example.com'),
493
+ 'A is returned for ANY'
494
+ );
495
+ assert.ok(!response.authorities.some(a => a.type === wire.TYPE.NSEC), 'no NSEC denial when records exist');
496
+ });
497
+
498
+ test('a URL record does not put an unanswerable AAAA in the NODATA NSEC bitmap', async () => {
499
+ await flushTestDb();
500
+ // config.public.hosts.AAAA defaults to [] - a URL record answers no AAAA, so an
501
+ // AAAA query is NODATA. The NSEC bitmap must NOT claim AAAA exists, or a
502
+ // validating resolver treats the (denied-but-listed) AAAA answer as bogus.
503
+ await zoneStore.add('example.com', 'www', 'URL', ['https://example.com/', 301, false]);
504
+ await dnssec.enableZone('example.com', { algorithm: 13 });
505
+
506
+ const response = await dnsHandler(buildRequest('www.example.com', 'AAAA'), DO);
507
+ assert.equal(response.answers.filter(a => a.type === Packet.TYPE.AAAA).length, 0, 'AAAA is NODATA when public.hosts.AAAA is empty');
508
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'www.example.com');
509
+ assert.ok(nsec, 'a signed NSEC denial is present at the queried name');
510
+ const present = decodeBitmap(nsec.data.slice(wire.encodeName('www.example.com').length));
511
+ assert.ok(!present.has(Packet.TYPE.AAAA), 'AAAA must NOT be listed (unanswerable + queried-absent)');
512
+ assert.ok(present.has(Packet.TYPE.A), 'A is listed (the URL answers A from a non-empty public.hosts.A)');
513
+ await verifyRRSIG(response.authorities, 'www.example.com', wire.TYPE.NSEC, 'example.com');
514
+ });
515
+
516
+ test('an ANAME with no resolvable AAAA gives a NODATA NSEC without the AAAA bit', async () => {
517
+ await flushTestDb();
518
+ // The ANAME target does not resolve (RFC 6761 reserved .invalid TLD), so the
519
+ // AAAA query is NODATA. The queried-absent type must be excluded from the bitmap.
520
+ await zoneStore.add('example.com', 'alias', 'ANAME', ['no-such-host.invalid']);
521
+ await dnssec.enableZone('example.com', { algorithm: 13 });
522
+
523
+ const response = await dnsHandler(buildRequest('alias.example.com', 'AAAA'), DO);
524
+ assert.equal(response.answers.filter(a => a.type === Packet.TYPE.AAAA).length, 0, 'AAAA is NODATA');
525
+ const nsec = response.authorities.find(a => a.type === wire.TYPE.NSEC && a.name === 'alias.example.com');
526
+ assert.ok(nsec, 'a signed NSEC denial is present at the queried name');
527
+ const present = decodeBitmap(nsec.data.slice(wire.encodeName('alias.example.com').length));
528
+ assert.ok(!present.has(Packet.TYPE.AAAA), 'AAAA must NOT be listed when the ANAME yields no AAAA');
529
+ await verifyRRSIG(response.authorities, 'alias.example.com', wire.TYPE.NSEC, 'example.com');
530
+ });
531
+
532
+ // Minimal NSEC type-bitmap decoder for assertions.
533
+ function decodeBitmap(buf) {
534
+ const types = new Set();
535
+ let i = 0;
536
+ while (i < buf.length) {
537
+ const window = buf[i++];
538
+ const len = buf[i++];
539
+ for (let b = 0; b < len; b++) {
540
+ const byte = buf[i + b];
541
+ for (let bit = 0; bit < 8; bit++) {
542
+ if (byte & (0x80 >> bit)) {
543
+ types.add((window << 8) | (b * 8 + bit));
544
+ }
545
+ }
546
+ }
547
+ i += len;
548
+ }
549
+ return types;
550
+ }