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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +15 -3
- package/README.md +83 -4
- package/config/default.toml +38 -0
- package/config/test.toml +10 -0
- package/lib/api-server.js +185 -17
- package/lib/certs.js +2 -17
- package/lib/dns-handler.js +350 -25
- package/lib/dns-server.js +90 -25
- package/lib/dnssec-wire.js +321 -0
- package/lib/dnssec.js +461 -0
- package/lib/lock.js +37 -0
- package/lib/zone-store.js +86 -3
- package/package.json +5 -2
- package/release-please-config.json +1 -0
- package/test/api.test.js +93 -1
- package/test/dns-handler.test.js +32 -1
- package/test/dns-server.test.js +93 -0
- package/test/dnssec-handler.test.js +550 -0
- package/test/dnssec-wire.test.js +163 -0
- package/test/dnssec.test.js +213 -0
- package/test/helpers.js +3 -1
- package/test/zone-store.test.js +37 -1
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
|
|
578
|
+
let wildcardName = toWildcardName(name);
|
|
499
579
|
let wildcardDomain = this.nameToDomain(wildcardName);
|
|
500
|
-
let wildcardRecordKey = `d:${
|
|
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
|
+
"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",
|