pending-dns 1.3.0 → 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.
package/lib/dnssec.js ADDED
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ // Online (live) DNSSEC signing. Per-zone keys live in Redis; RRsets are signed
4
+ // at query time and denial-of-existence is synthesized with compact NSEC
5
+ // "black lies" - no pre-signed zone, no stored RRSIG/NSEC chains.
6
+ //
7
+ // Signatures are always computed over the canonical wire form from
8
+ // dnssec-wire.js (uncompressed, lowercased), never over dns2's serialization.
9
+
10
+ const config = require('wild-config');
11
+ const crypto = require('crypto');
12
+ const util = require('util');
13
+ const punycode = require('punycode/');
14
+ const db = require('./db');
15
+ const { waitAcquireLock, releaseLock } = require('./lock');
16
+ const { zoneStore } = require('./zone-store');
17
+ const logger = require('./logger').child({ component: 'dnssec' });
18
+ const wire = require('./dnssec-wire');
19
+
20
+ const generateKeyPairAsync = util.promisify(crypto.generateKeyPair);
21
+
22
+ // Parsing a PEM into a KeyObject is relatively expensive; cache by PEM string
23
+ // (a PEM uniquely identifies its key, so this is always safe and never stale).
24
+ // Bounded LRU so the cache cannot grow without limit as keys roll over: on a
25
+ // hit we refresh recency, and once over the cap we drop the oldest entry.
26
+ // Bounded-LRU insert: (re)insert the key as most-recent and evict the oldest
27
+ // entry once over the cap. Re-inserting an existing key refreshes its recency.
28
+ const lruSet = (map, key, value, max) => {
29
+ map.delete(key);
30
+ map.set(key, value);
31
+ if (map.size > max) {
32
+ map.delete(map.keys().next().value);
33
+ }
34
+ };
35
+
36
+ const PRIVATE_KEY_CACHE_MAX = 1000;
37
+ const privateKeyCache = new Map();
38
+ const cachedPrivateKey = pem => {
39
+ let key = privateKeyCache.get(pem) || crypto.createPrivateKey(pem);
40
+ lruSet(privateKeyCache, pem, key, PRIVATE_KEY_CACHE_MAX);
41
+ return key;
42
+ };
43
+
44
+ // `<name>` is the label-reversed zone name, matching the d:<name>:z keyspace.
45
+ const zoneName = zone => zoneStore.domainToName(zone);
46
+ const stateKey = name => `d:dnssec:${name}`;
47
+ const keysKey = name => `d:dnssec:${name}:keys`;
48
+
49
+ const dnssecConfig = () => config.dnssec || {};
50
+
51
+ // keyTag is a number when freshly generated but a string after a JSON/Redis round
52
+ // trip or as a route param, so compare key tags through one string coercion.
53
+ const sameTag = (a, b) => String(a) === String(b);
54
+
55
+ // A key is "preferred" within its algorithm when it is the recorded active key
56
+ // (by tag) or, lacking that, one explicitly marked active. Shared by getSigner
57
+ // so the per-algorithm selection rule lives in one place.
58
+ const isPreferredKey = (key, activeKeyTag) => sameTag(key.keyTag, activeKeyTag) || key.status === 'active';
59
+
60
+ // Assembled-signer cache (zone reversed-name -> { signer, expires }). getSigner
61
+ // is on the DNS hot path and otherwise re-reads + re-parses the zone keys on
62
+ // every DO query (including null for unsigned zones). The API and DNS subsystems
63
+ // run as separate workers, so a mutation in the API worker cannot invalidate
64
+ // this cache directly - a short TTL ([dnssec] signerCacheTtl) bounds staleness
65
+ // and DNSSEC key changes are rare. Within one process the mutating calls below
66
+ // invalidate the entry explicitly.
67
+ const SIGNER_CACHE_MAX = 1000;
68
+ const signerCache = new Map();
69
+ const invalidateSigner = name => signerCache.delete(name);
70
+
71
+ const getKeysRaw = async (name, client) => {
72
+ const stored = await (client || db.redisRead).hgetall(keysKey(name));
73
+ const keys = [];
74
+ for (const hid of Object.keys(stored || {})) {
75
+ try {
76
+ keys.push(Object.assign({ hid }, JSON.parse(stored[hid])));
77
+ } catch (err) {
78
+ logger.error({ msg: 'Failed to parse DNSSEC key', name, hid, err });
79
+ }
80
+ }
81
+ return keys;
82
+ };
83
+
84
+ // Generate a single Combined Signing Key (KSK+ZSK in one) for a zone.
85
+ const generateCsk = async (zone, algorithm) => {
86
+ const alg = wire.ALGS[algorithm];
87
+ if (!alg) {
88
+ throw new Error(`Unsupported DNSSEC algorithm ${algorithm}`);
89
+ }
90
+
91
+ const { publicKey, privateKey } = await generateKeyPairAsync(
92
+ alg.generate.type,
93
+ Object.assign({}, alg.generate.options, {
94
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
95
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
96
+ })
97
+ );
98
+
99
+ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
100
+ const pubkey = alg.pubkeyFromJwk(jwk);
101
+
102
+ const flags = wire.DNSKEY_FLAGS_CSK;
103
+ const protocol = wire.DNSKEY_PROTOCOL;
104
+ const dnskeyRdata = wire.encodeDNSKEYRdata({ flags, protocol, algorithm, pubkey });
105
+
106
+ return {
107
+ hid: crypto.randomUUID(),
108
+ keyTag: wire.dnskeyKeyTag(dnskeyRdata),
109
+ algorithm,
110
+ flags,
111
+ protocol,
112
+ privateKey,
113
+ publicKeyDnssec: pubkey.toString('base64'),
114
+ status: 'active',
115
+ created: new Date().toISOString()
116
+ };
117
+ };
118
+
119
+ // DNSKEY RDATA Buffer for a stored key.
120
+ const keyDnskeyRdata = key =>
121
+ wire.encodeDNSKEYRdata({
122
+ flags: key.flags,
123
+ protocol: key.protocol,
124
+ algorithm: key.algorithm,
125
+ pubkey: Buffer.from(key.publicKeyDnssec, 'base64')
126
+ });
127
+
128
+ // Presentation + component form of a key's DS record (digest computed by us;
129
+ // the operator pastes this at the registrar).
130
+ const keyDsRecord = (zone, key) => {
131
+ const digestType = wire.ALGS[key.algorithm].dsDigestType;
132
+ // The DS digest covers the canonical (punycode A-label) DNSKEY owner name,
133
+ // regardless of whether the operator passed an IDN zone in Unicode form.
134
+ const digest = wire.dsDigest(punycode.toASCII(zone), keyDnskeyRdata(key), digestType).toString('hex');
135
+ return {
136
+ keyTag: key.keyTag,
137
+ algorithm: key.algorithm,
138
+ digestType,
139
+ digest,
140
+ presentation: `${key.keyTag} ${key.algorithm} ${digestType} ${digest}`
141
+ };
142
+ };
143
+
144
+ // DNSKEY presentation form of a key.
145
+ const keyDnskeyRecord = key => ({
146
+ flags: key.flags,
147
+ protocol: key.protocol,
148
+ algorithm: key.algorithm,
149
+ publicKey: key.publicKeyDnssec,
150
+ keyTag: key.keyTag,
151
+ presentation: `${key.flags} ${key.protocol} ${key.algorithm} ${key.publicKeyDnssec}`
152
+ });
153
+
154
+ // Pick the active key for a zone from its stored key set + state hash.
155
+ const activeKey = (state, keys) => {
156
+ const activeKeyTag = state && state.activeKeyTag;
157
+ return keys.find(key => sameTag(key.keyTag, activeKeyTag)) || keys.find(key => key.status === 'active') || keys[0] || null;
158
+ };
159
+
160
+ const summarize = (zone, state, keys) => {
161
+ const active = activeKey(state, keys);
162
+ return {
163
+ zone,
164
+ enabled: !!(state && state.enabled === '1'),
165
+ // Report the active key's algorithm/keyTag (during an algorithm rollover
166
+ // several keys of different algorithms coexist); ds/dnskey list them all.
167
+ algorithm: active ? active.algorithm : Number((state && state.algorithm) || dnssecConfig().algorithm || 13),
168
+ keyTag: active ? active.keyTag : null,
169
+ ds: keys.map(key => keyDsRecord(zone, key)),
170
+ dnskey: keys.map(keyDnskeyRecord)
171
+ };
172
+ };
173
+
174
+ // Enable DNSSEC for a zone, generating a CSK on first call. Idempotent for the
175
+ // same algorithm. Requesting a different algorithm performs an algorithm
176
+ // rollover: a new CSK is generated and made active while the old key is kept, so
177
+ // the zone keeps being signed with every algorithm in the DNSKEY RRset
178
+ // (RFC 6840 5.11) until the operator removes the old key (removeKey) after
179
+ // updating the parent DS.
180
+ const enableZone = async (zone, opts = {}) => {
181
+ // The query-time signing path is additionally gated on config.dnssec.enabled
182
+ // (see dns-handler.js). Refuse here when the global switch is off so the API
183
+ // never hands back a DS the server will not honour - publishing that DS would
184
+ // SERVFAIL the whole zone.
185
+ if (!dnssecConfig().enabled) {
186
+ throw Object.assign(new Error('DNSSEC is disabled globally; set [dnssec] enabled = true to enable signing'), {
187
+ statusCode: 400,
188
+ code: 'DnssecGloballyDisabled'
189
+ });
190
+ }
191
+
192
+ const name = zoneName(zone);
193
+ const requestedAlgorithm = opts.algorithm || dnssecConfig().algorithm || 13;
194
+
195
+ const lock = await waitAcquireLock(`dnssec:${name}`, 60 * 1000, 60 * 1000);
196
+ if (!lock || !lock.success) {
197
+ throw new Error('Failed to acquire DNSSEC lock');
198
+ }
199
+ try {
200
+ // Read from the write client: this is a mutating, lock-held section and
201
+ // must not act on a possibly-stale replica view.
202
+ let keys = await getKeysRaw(name, db.redisWrite);
203
+ // state is only needed to preserve the active key when no algorithm is
204
+ // requested; skip the read on the explicit-algorithm (rollover) path.
205
+ const state = opts.algorithm ? null : await db.redisWrite.hgetall(stateKey(name));
206
+
207
+ const haveRequestedAlg = keys.some(key => Number(key.algorithm) === Number(requestedAlgorithm));
208
+ // Generate a key when none exist, or when the caller explicitly asked for
209
+ // an algorithm we do not yet have (rollover - keep the existing keys).
210
+ if (!keys.length || (opts.algorithm && !haveRequestedAlg)) {
211
+ const key = await generateCsk(zone, requestedAlgorithm);
212
+ await db.redisWrite.hset(keysKey(name), key.hid, JSON.stringify(key));
213
+ keys.push(key);
214
+ logger.info({ msg: 'Generated DNSSEC key', zone, algorithm: requestedAlgorithm, keyTag: key.keyTag, rollover: keys.length > 1 });
215
+ }
216
+
217
+ // When an algorithm was explicitly requested, that key becomes active (the
218
+ // freshly generated one on a rollover). With no algorithm in the request,
219
+ // preserve the current active key rather than reverting to the config
220
+ // default - re-enabling a zone mid-rollover must not flip the active key
221
+ // away from the one the operator rolled to.
222
+ const active = opts.algorithm ? keys.find(key => Number(key.algorithm) === Number(requestedAlgorithm)) || keys[0] : activeKey(state, keys);
223
+ await db.redisWrite.hmset(stateKey(name), {
224
+ enabled: '1',
225
+ algorithm: String(active.algorithm),
226
+ activeKeyTag: String(active.keyTag),
227
+ created: new Date().toISOString()
228
+ });
229
+ invalidateSigner(name);
230
+
231
+ return summarize(zone, { enabled: '1', activeKeyTag: String(active.keyTag) }, keys);
232
+ } finally {
233
+ await releaseLock(lock);
234
+ }
235
+ };
236
+
237
+ // Remove a (non-active) signing key from a zone, e.g. to finish an algorithm
238
+ // rollover once the new DS is published at the parent. Refuses to remove the
239
+ // active key or the last remaining key so a signed zone never loses its signer.
240
+ const removeKey = async (zone, keyTag) => {
241
+ const name = zoneName(zone);
242
+
243
+ const lock = await waitAcquireLock(`dnssec:${name}`, 60 * 1000, 60 * 1000);
244
+ if (!lock || !lock.success) {
245
+ throw new Error('Failed to acquire DNSSEC lock');
246
+ }
247
+ try {
248
+ // Read from the write client inside the lock: the active-key guard below
249
+ // must not be decided on a stale replica view, or it could permit
250
+ // removing the key that is actually active.
251
+ const keys = await getKeysRaw(name, db.redisWrite);
252
+ const target = keys.find(key => sameTag(key.keyTag, keyTag));
253
+ if (!target) {
254
+ return false;
255
+ }
256
+ if (keys.length <= 1) {
257
+ throw Object.assign(new Error('Cannot remove the last remaining key; disable the zone instead'), {
258
+ statusCode: 400,
259
+ code: 'CannotRemoveLastKey'
260
+ });
261
+ }
262
+ const state = await db.redisWrite.hgetall(stateKey(name));
263
+ if (state && sameTag(state.activeKeyTag, keyTag)) {
264
+ throw Object.assign(new Error('Cannot remove the active key; roll to a new key first'), {
265
+ statusCode: 400,
266
+ code: 'CannotRemoveActiveKey'
267
+ });
268
+ }
269
+ await db.redisWrite.hdel(keysKey(name), target.hid);
270
+ invalidateSigner(name);
271
+ logger.info({ msg: 'Removed DNSSEC key', zone, keyTag });
272
+ return true;
273
+ } finally {
274
+ await releaseLock(lock);
275
+ }
276
+ };
277
+
278
+ const disableZone = async zone => {
279
+ const name = zoneName(zone);
280
+
281
+ // Take the same per-zone lock enableZone/removeKey hold so a disable cannot race
282
+ // a concurrent enable into a lost update - which could leave the zone unsigned
283
+ // after the operator already published the DS returned by enable, SERVFAILing
284
+ // the zone at validators.
285
+ const lock = await waitAcquireLock(`dnssec:${name}`, 60 * 1000, 60 * 1000);
286
+ if (!lock || !lock.success) {
287
+ throw new Error('Failed to acquire DNSSEC lock');
288
+ }
289
+ try {
290
+ await db.redisWrite.hset(stateKey(name), 'enabled', '0');
291
+ invalidateSigner(name);
292
+ return true;
293
+ } finally {
294
+ await releaseLock(lock);
295
+ }
296
+ };
297
+
298
+ const getZoneStatus = async zone => {
299
+ const name = zoneName(zone);
300
+ // Read from the master, not a replica: a status check right after enable or a
301
+ // rollover must not return a stale/empty DS due to replication lag, since the
302
+ // operator copies this DS to the registrar.
303
+ const state = await db.redisWrite.hgetall(stateKey(name));
304
+ const keys = await getKeysRaw(name, db.redisWrite);
305
+ return summarize(zone, state, keys);
306
+ };
307
+
308
+ const isZoneSigned = async zone => {
309
+ const name = zoneName(zone);
310
+ // Master read, consistent with getZoneStatus and the lock-held mutators.
311
+ const enabled = await db.redisWrite.hget(stateKey(name), 'enabled');
312
+ return enabled === '1';
313
+ };
314
+
315
+ // Resolve the signing context for a zone (or null when not signed). Returns one
316
+ // signing key per distinct algorithm present (the active key within each
317
+ // algorithm) so every RRset gets an RRSIG from every algorithm in the DNSKEY
318
+ // RRset (RFC 6840 5.11). `zone` is the punycode A-label form - it is the RRSIG
319
+ // signer name and the DNSKEY owner, so it must be canonical even for IDN zones.
320
+ const buildSigner = async (zone, name) => {
321
+ // state and keys are independent reads; fetch them together to halve the
322
+ // signer-cache-miss latency on the DO query hot path.
323
+ const [state, keys] = await Promise.all([db.redisRead.hgetall(stateKey(name)), getKeysRaw(name)]);
324
+ if (!state || state.enabled !== '1') {
325
+ return null;
326
+ }
327
+ if (!keys.length) {
328
+ return null;
329
+ }
330
+
331
+ // One key per algorithm; prefer the active key (by tag, then status) within
332
+ // an algorithm so a same-algorithm key set still picks deterministically.
333
+ const byAlgorithm = new Map();
334
+ for (const key of keys) {
335
+ const alg = Number(key.algorithm);
336
+ const current = byAlgorithm.get(alg);
337
+ if (!current || (isPreferredKey(key, state.activeKeyTag) && !isPreferredKey(current, state.activeKeyTag))) {
338
+ byAlgorithm.set(alg, key);
339
+ }
340
+ }
341
+
342
+ const signingKeys = [...byAlgorithm.values()].map(key => ({
343
+ algorithm: Number(key.algorithm),
344
+ keyTag: key.keyTag,
345
+ flags: key.flags,
346
+ protocol: key.protocol,
347
+ publicKeyDnssec: key.publicKeyDnssec,
348
+ privateKeyObj: cachedPrivateKey(key.privateKey)
349
+ }));
350
+ if (!signingKeys.length) {
351
+ return null;
352
+ }
353
+
354
+ return {
355
+ zone: punycode.toASCII(zone), // also the RRSIG signer name (canonical A-label form)
356
+ activeKeyTag: state.activeKeyTag,
357
+ keys: signingKeys
358
+ };
359
+ };
360
+
361
+ const getSigner = async zone => {
362
+ const name = zoneName(zone);
363
+
364
+ const cached = signerCache.get(name);
365
+ if (cached && cached.expires > Date.now()) {
366
+ lruSet(signerCache, name, cached, SIGNER_CACHE_MAX); // refresh recency
367
+ return cached.signer;
368
+ }
369
+
370
+ const signer = await buildSigner(zone, name);
371
+
372
+ const ttlSeconds = dnssecConfig().signerCacheTtl;
373
+ const ttl = (typeof ttlSeconds === 'number' ? ttlSeconds : 5) * 1000;
374
+ if (ttl > 0) {
375
+ lruSet(signerCache, name, { signer, expires: Date.now() + ttl }, SIGNER_CACHE_MAX);
376
+ }
377
+ return signer;
378
+ };
379
+
380
+ // Sign one RRset, returning one RRSIG resource (raw type 46) per signing key -
381
+ // i.e. one per algorithm in the zone (RFC 6840 5.11). `rrs` are normalized
382
+ // answer objects sharing the owner name + numeric type.
383
+ //
384
+ // `wireOwner` is the on-the-wire owner name the RRSIG is attached to (for a
385
+ // wildcard expansion this is the expanded query name). `signingOwner` is the
386
+ // name the signature is computed over: the wildcard owner (`*.zone`) for an
387
+ // expansion, otherwise the same as `wireOwner`. The RRSIG Labels field is the
388
+ // label count of `signingOwner`, so a validator can reconstruct the wildcard
389
+ // owner and verify the signature (RFC 4035 5.3.2).
390
+ const signRRset = (signer, wireOwner, signingOwner, typeNum, ttl, rrs) => {
391
+ const now = Math.floor(Date.now() / 1000);
392
+ const cfg = dnssecConfig();
393
+ const labels = wire.nameLabelCount(signingOwner);
394
+ // typeof guards so a configured 0 (e.g. no inception backdating) is honored
395
+ // rather than silently reverting to the default. These do not vary per signing
396
+ // key, so compute them once instead of inside the per-key loop below.
397
+ const expiration = now + (typeof cfg.signatureValidity === 'number' ? cfg.signatureValidity : 604800);
398
+ const inception = now - (typeof cfg.inceptionSkew === 'number' ? cfg.inceptionSkew : 3600);
399
+
400
+ const sortedRdata = rrs.map(rr => wire.canonicalRdata(typeNum, rr)).sort(wire.compareCanonicalRdata);
401
+ // Remove duplicate RRs from the RRset before signing (RFC 4034 6.3). A
402
+ // validator de-duplicates before verifying, so signing over duplicates - e.g.
403
+ // the same A value stored twice, or one SOA per question in a multi-question
404
+ // NODATA packet - would produce an RRSIG that fails validation.
405
+ const canonical = sortedRdata
406
+ .filter((rdata, i) => i === 0 || !rdata.equals(sortedRdata[i - 1]))
407
+ .map(rdata => wire.canonicalRR(signingOwner, typeNum, 1, ttl, rdata));
408
+ // The canonical RRset bytes are identical for every signing key; concatenate
409
+ // them once and only prepend the per-key RRSIG preimage inside the loop.
410
+ const canonicalRRs = Buffer.concat(canonical);
411
+
412
+ return signer.keys.map(key => {
413
+ const fields = {
414
+ typeCovered: typeNum,
415
+ algorithm: key.algorithm,
416
+ labels,
417
+ originalTtl: ttl,
418
+ expiration,
419
+ inception,
420
+ keyTag: key.keyTag,
421
+ signerName: signer.zone
422
+ };
423
+ const signature = wire.ALGS[key.algorithm].sign(Buffer.concat([wire.encodeRRSIGSigningPreimage(fields), canonicalRRs]), key.privateKeyObj);
424
+ return {
425
+ name: wireOwner,
426
+ type: wire.TYPE.RRSIG,
427
+ class: 1,
428
+ ttl,
429
+ data: wire.encodeRRSIGRdata(fields, signature)
430
+ };
431
+ });
432
+ };
433
+
434
+ // DNSKEY RRset for the apex (structured for dns2's native encoder).
435
+ const buildDnskeyRecords = signer => {
436
+ const dnskeyTtl = dnssecConfig().dnskeyTtl;
437
+ const ttl = typeof dnskeyTtl === 'number' ? dnskeyTtl : 3600;
438
+ return signer.keys.map(key => ({
439
+ name: signer.zone,
440
+ type: 'DNSKEY',
441
+ class: 1,
442
+ ttl,
443
+ flags: key.flags,
444
+ protocol: key.protocol,
445
+ algorithm: key.algorithm,
446
+ key: key.publicKeyDnssec
447
+ }));
448
+ };
449
+
450
+ module.exports = {
451
+ enableZone,
452
+ disableZone,
453
+ removeKey,
454
+ getZoneStatus,
455
+ isZoneSigned,
456
+ getSigner,
457
+ signRRset,
458
+ buildDnskeyRecords,
459
+ // testing seam
460
+ testables: { zoneName }
461
+ };
package/lib/lock.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // Shared ioredfour distributed lock (namespace 'd:lock:' on the Redis write
4
+ // client). Used by both ACME certificate issuance (lib/certs.js) and DNSSEC key
5
+ // management (lib/dnssec.js); callers namespace their own lock keys so the two
6
+ // never collide.
7
+
8
+ const Lock = require('ioredfour');
9
+ const util = require('util');
10
+ const db = require('./db');
11
+ const logger = require('./logger').child({ component: 'lock' });
12
+
13
+ const lock = new Lock({
14
+ redis: db.redisWrite,
15
+ namespace: 'd:lock:'
16
+ });
17
+
18
+ const waitAcquireLock = util.promisify(lock.waitAcquireLock.bind(lock));
19
+
20
+ // Release an acquired lock. Safe to call with a missing or unsuccessful lock
21
+ // (no-op), so callers can release unconditionally in a finally block. `context`
22
+ // is merged into the release-failure log so the caller can identify which lock
23
+ // (e.g. which domains) is stuck.
24
+ const releaseLock = (acquired, context) =>
25
+ new Promise(resolve => {
26
+ if (!acquired || !acquired.success) {
27
+ return resolve();
28
+ }
29
+ lock.releaseLock(acquired, err => {
30
+ if (err) {
31
+ logger.error(Object.assign({ msg: 'Failed releasing lock' }, context || {}, { err }));
32
+ }
33
+ resolve();
34
+ });
35
+ });
36
+
37
+ module.exports = { waitAcquireLock, releaseLock };
package/lib/zone-store.js CHANGED
@@ -6,10 +6,30 @@ const db = require('./db');
6
6
  const logger = require('./logger').child({ component: 'zone-store' });
7
7
  const { normalizeDomain } = require('./tools');
8
8
 
9
- const orderList = ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'URL', 'NS'];
9
+ const orderList = ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'TLSA', 'URL', 'NS'];
10
10
  const allowedTypes = new Set(orderList);
11
11
  const allowedTags = new Set(['issue', 'issuewild', 'iodef']);
12
12
 
13
+ // TLSA records are stored as a positional value array
14
+ // [usage, selector, matchingType, certificate]. These keep that ordering in one
15
+ // place so the API, the store, and the DNS handler cannot drift apart.
16
+ const tlsaToValue = data => [data.usage, data.selector, data.matchingType, data.certificate];
17
+ // TLSA certificate association data must be even-length hex. The API enforces
18
+ // this in Joi, but guard the store boundary too so a direct caller cannot store
19
+ // a value that Buffer.from(..,'hex') would silently truncate into a corrupt DANE
20
+ // association.
21
+ const isEvenHex = value => typeof value === 'string' && value.length > 0 && value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value);
22
+ const tlsaFromValue = value => ({
23
+ usage: Number(value[0]) || 0,
24
+ selector: Number(value[1]) || 0,
25
+ matchingType: Number(value[2]) || 0,
26
+ certificate: value[3]
27
+ });
28
+
29
+ // Single-label wildcard key for a label-reversed name: replace the most-specific
30
+ // (trailing) label with '*'. resolve() and existingTypes() both probe this key.
31
+ const toWildcardName = name => name.replace(/\.[^.]+$/, '.*');
32
+
13
33
  class ZoneStore {
14
34
  constructor(db, options) {
15
35
  this.db = db;
@@ -168,6 +188,10 @@ class ZoneStore {
168
188
  result.ns = entry.value[0];
169
189
  break;
170
190
 
191
+ case 'TLSA':
192
+ Object.assign(result, tlsaFromValue(entry.value));
193
+ break;
194
+
171
195
  case 'TXT':
172
196
  result.data = entry.value[0];
173
197
  break;
@@ -322,6 +346,49 @@ class ZoneStore {
322
346
  return list;
323
347
  }
324
348
 
349
+ /**
350
+ * List record types that exist at an exact name, optionally also folding in
351
+ * the types a single-level wildcard could answer at that name (probed in the
352
+ * same pipeline). Used to build DNSSEC NSEC type bitmaps: listing the
353
+ * wildcard-answerable types keeps an RFC 8198 aggressive-NSEC resolver from
354
+ * synthesizing a NODATA that suppresses the wildcard answer.
355
+ * @param {String} domain Exact domain name
356
+ * @param {Boolean} includeWildcard Also probe the single-level wildcard key
357
+ * @returns {Array} Array of record type strings present at the name
358
+ */
359
+ async existingTypes(domain, includeWildcard) {
360
+ let name = this.domainToName(domain);
361
+ if (!name) {
362
+ return [];
363
+ }
364
+
365
+ // Same single-level wildcard key construction resolve() uses: replace the
366
+ // most-specific label with '*'.
367
+ let wildcardName = includeWildcard && name.includes('.') ? toWildcardName(name) : null;
368
+ let names = wildcardName && wildcardName !== name ? [name, wildcardName] : [name];
369
+
370
+ let req = this.db.redisRead.multi();
371
+ for (let probe of names) {
372
+ for (let type of orderList) {
373
+ req = req.exists(`d:${probe}:r:${type}`);
374
+ }
375
+ }
376
+ let res = await req.exec();
377
+
378
+ let types = new Set();
379
+ names.forEach((probe, n) => {
380
+ // Each probe contributed one EXISTS per orderList type, in order; take
381
+ // that probe's contiguous slice of the flat pipeline result.
382
+ let probeRes = res.slice(n * orderList.length, (n + 1) * orderList.length);
383
+ orderList.forEach((type, i) => {
384
+ if (probeRes[i] && probeRes[i][1]) {
385
+ types.add(type);
386
+ }
387
+ });
388
+ });
389
+ return [...types];
390
+ }
391
+
325
392
  /**
326
393
  * Add new resource record to Zone
327
394
  * @param {String} zone Zone domain
@@ -355,6 +422,12 @@ class ZoneStore {
355
422
  return false;
356
423
  }
357
424
 
425
+ // Reject TLSA records whose certificate data is not even-length hex; the
426
+ // wire encoder would otherwise silently truncate it (RFC 6698 RDATA).
427
+ if (type === 'TLSA' && !isEvenHex(value[3])) {
428
+ return false;
429
+ }
430
+
358
431
  let recordKey = `d:${name}:r:${type}`;
359
432
  let hid = nanoid();
360
433
 
@@ -421,6 +494,13 @@ class ZoneStore {
421
494
  return await this.add(this.nameToDomain(zone), updatedSubdomain, updatedType, value);
422
495
  }
423
496
 
497
+ // Same TLSA even-length-hex guard add() applies: the changed-name/type path
498
+ // above routes through add(), but this unchanged path writes directly and
499
+ // would otherwise let a corrupt cert through that the wire encoder truncates.
500
+ if (type === 'TLSA' && !isEvenHex(Array.isArray(value) ? value[3] : null)) {
501
+ return false;
502
+ }
503
+
424
504
  let updatedId = this.getFullId(name, type, hid);
425
505
 
426
506
  let recordKey = `d:${name}:r:${type}`;
@@ -495,9 +575,9 @@ class ZoneStore {
495
575
  return await checkHealthStatus(exactRes);
496
576
  }
497
577
 
498
- let wildcardName = name.replace(/\.[^.]+$/, '.*');
578
+ let wildcardName = toWildcardName(name);
499
579
  let wildcardDomain = this.nameToDomain(wildcardName);
500
- let wildcardRecordKey = `d:${name.replace(/\.[^.]+$/, '.*')}:r:${type}`;
580
+ let wildcardRecordKey = `d:${wildcardName}:r:${type}`;
501
581
 
502
582
  let wildcardRecord = await this.db.redisRead.hgetall(wildcardRecordKey);
503
583
  let wildRes = this.parseHashRecord(zone, wildcardName, domain, type, short, wildcardRecord);
@@ -578,3 +658,6 @@ module.exports.ZoneStore = ZoneStore;
578
658
  module.exports.zoneStore = new ZoneStore(db);
579
659
  module.exports.allowedTypes = [...allowedTypes];
580
660
  module.exports.allowedTags = [...allowedTags];
661
+ module.exports.tlsaToValue = tlsaToValue;
662
+ module.exports.tlsaFromValue = tlsaFromValue;
663
+ module.exports.isEvenHex = isEvenHex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pending-dns",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Lightweight API driven DNS server",
5
5
  "productTitle": "PendingDNS",
6
6
  "main": "index.js",
@@ -11,6 +11,8 @@
11
11
  "scripts": {
12
12
  "start": "node server.js",
13
13
  "lint": "eslint .",
14
+ "format": "prettier --write 'lib/**/*.js' 'workers/**/*.js' 'test/**/*.js' '*.js'",
15
+ "format:check": "prettier --check 'lib/**/*.js' 'workers/**/*.js' 'test/**/*.js' '*.js'",
14
16
  "test": "NODE_ENV=test node --test --test-force-exit --test-concurrency=1 test/*.test.js",
15
17
  "build-source": "rm -rf node_modules && npm install && npm run licenses && rm -rf node_modules && npm ci --omit=dev",
16
18
  "build-dist-fast": "pkg --debug package.json && npm install",
@@ -40,7 +42,8 @@
40
42
  "eslint": "9.39.4",
41
43
  "eslint-config-nodemailer": "1.2.0",
42
44
  "eslint-config-prettier": "10.1.8",
43
- "license-report": "6.8.5"
45
+ "license-report": "6.8.5",
46
+ "prettier": "3.8.4"
44
47
  },
45
48
  "dependencies": {
46
49
  "@fidm/x509": "1.2.1",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "include-component-in-tag": false,
3
4
  "packages": {
4
5
  ".": {
5
6
  "release-type": "node",