@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +67 -10
- package/bin/whoisleuth.mjs +22 -9
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +5 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +96 -42
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
// Isolated, bounded DNSSEC chain validation for explicitly authorised CLI
|
|
2
|
+
// actions. The validator asks one analyst-selected public recursive resolver
|
|
3
|
+
// for DNSSEC wire records over pinned DNS-over-TCP connections, then verifies
|
|
4
|
+
// the returned DS, DNSKEY, RRSIG, NSEC, and exact bounded NSEC3 material locally
|
|
5
|
+
// from an analyst-supplied trust anchor. It is not used by Lookup, Bulk,
|
|
6
|
+
// monitoring, or automatic investigation recipes.
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
import * as crypto from 'node:crypto';
|
|
9
|
+
import { randomInt } from 'node:crypto';
|
|
10
|
+
import { isIP } from 'node:net';
|
|
11
|
+
import { domainToASCII } from 'node:url';
|
|
12
|
+
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
13
|
+
import { MAX_DNS_MESSAGE_BYTES, defaultTcpExchange, formatIpv6, } from "./service-binding-dns.mjs";
|
|
14
|
+
const DNSSEC_CHAIN_SCHEMA = 'whoisleuth.dnssec-chain-validation';
|
|
15
|
+
const DNSSEC_CHAIN_VERSION = 1;
|
|
16
|
+
const DNSSEC_TRUST_ANCHOR_SCHEMA = 'whoisleuth.dnssec-trust-anchor';
|
|
17
|
+
const DNSSEC_TRUST_ANCHOR_VERSION = 1;
|
|
18
|
+
const MAX_DNSSEC_TRUST_ANCHOR_BYTES = 64 * 1024;
|
|
19
|
+
const DNS_CLASS_IN = 1;
|
|
20
|
+
const DNS_TYPE_A = 1;
|
|
21
|
+
const DNS_TYPE_NS = 2;
|
|
22
|
+
const DNS_TYPE_CNAME = 5;
|
|
23
|
+
const DNS_TYPE_SOA = 6;
|
|
24
|
+
const DNS_TYPE_AAAA = 28;
|
|
25
|
+
const DNS_TYPE_OPT = 41;
|
|
26
|
+
const DNS_TYPE_DS = 43;
|
|
27
|
+
const DNS_TYPE_RRSIG = 46;
|
|
28
|
+
const DNS_TYPE_NSEC = 47;
|
|
29
|
+
const DNS_TYPE_DNSKEY = 48;
|
|
30
|
+
const DNS_TYPE_NSEC3 = 50;
|
|
31
|
+
const DNS_TYPE_TLSA = 52;
|
|
32
|
+
const MAX_DNS_RECORDS_PER_RESPONSE = 128;
|
|
33
|
+
const MAX_DNSSEC_QUERIES = 32;
|
|
34
|
+
const MAX_DNSSEC_RESPONSE_BYTES = 512 * 1024;
|
|
35
|
+
const MAX_DNSSEC_DELEGATIONS = 8;
|
|
36
|
+
const MAX_DNSSEC_NSEC3_ITERATIONS = 500;
|
|
37
|
+
const MAX_DNSSEC_RRSET_RECORDS = 32;
|
|
38
|
+
const MAX_DNSSEC_RRSET_SIGNATURES = 16;
|
|
39
|
+
const MAX_DNSSEC_DENIAL_RECORDS = 16;
|
|
40
|
+
const DNSSEC_QUERY_TIMEOUT_MS = 2_500;
|
|
41
|
+
const DNSSEC_TOTAL_TIMEOUT_MS = 15_000;
|
|
42
|
+
const MAX_ERROR_LENGTH = 240;
|
|
43
|
+
const MAX_TRUST_ANCHOR_RECORDS = 8;
|
|
44
|
+
class DnssecWireError extends Error {
|
|
45
|
+
code;
|
|
46
|
+
constructor(message, code = 'EBADRESP') {
|
|
47
|
+
super(boundedError(message));
|
|
48
|
+
this.name = 'DnssecWireError';
|
|
49
|
+
this.code = code;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function boundedError(value) {
|
|
53
|
+
const message = value instanceof Error ? value.message : String(value || 'DNSSEC operation failed');
|
|
54
|
+
return message.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, MAX_ERROR_LENGTH) || 'DNSSEC operation failed';
|
|
55
|
+
}
|
|
56
|
+
function exactObject(value, keys, label) {
|
|
57
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
58
|
+
throw new TypeError(`${label} must be one object.`);
|
|
59
|
+
const record = value;
|
|
60
|
+
const unexpected = Object.keys(record).filter((key) => !keys.has(key));
|
|
61
|
+
if (unexpected.length)
|
|
62
|
+
throw new TypeError(`${label} contains unsupported field "${unexpected[0]}".`);
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
function normalizeDnsName(value, options = {}) {
|
|
66
|
+
if (typeof value !== 'string' || value.length > 1024 || /[\u0000-\u0020\u007f]/u.test(value))
|
|
67
|
+
return null;
|
|
68
|
+
const trimmed = value.trim().replace(/\.+$/u, '');
|
|
69
|
+
if (!trimmed && options.allowRoot)
|
|
70
|
+
return '.';
|
|
71
|
+
const ascii = (/^[\x21-\x7e]+$/u.test(trimmed) ? trimmed : domainToASCII(trimmed)).toLowerCase();
|
|
72
|
+
if (!ascii || ascii.length > 253)
|
|
73
|
+
return null;
|
|
74
|
+
const labelPattern = options.allowServiceLabels
|
|
75
|
+
? /^_?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u
|
|
76
|
+
: /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
77
|
+
return ascii.split('.').every((label) => label.length <= 63 && labelPattern.test(label)) ? ascii : null;
|
|
78
|
+
}
|
|
79
|
+
function normalizeResolverEndpoint(value) {
|
|
80
|
+
if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u0020\u007f%]/u.test(value))
|
|
81
|
+
return null;
|
|
82
|
+
const family = isIP(value);
|
|
83
|
+
if ((family !== 4 && family !== 6) || isPrivateAddress(value))
|
|
84
|
+
return null;
|
|
85
|
+
return { address: value.toLowerCase(), port: 53, family };
|
|
86
|
+
}
|
|
87
|
+
function integer(value, minimum, maximum) {
|
|
88
|
+
return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum ? Number(value) : null;
|
|
89
|
+
}
|
|
90
|
+
function normalizedText(value, maximum) {
|
|
91
|
+
if (typeof value !== 'string' || value.length > maximum * 4)
|
|
92
|
+
return null;
|
|
93
|
+
const text = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
94
|
+
return text && text.length <= maximum ? text : null;
|
|
95
|
+
}
|
|
96
|
+
function normalizedTimestamp(value) {
|
|
97
|
+
const text = normalizedText(value, 64);
|
|
98
|
+
if (!text || !Number.isFinite(Date.parse(text)))
|
|
99
|
+
return null;
|
|
100
|
+
return new Date(text).toISOString();
|
|
101
|
+
}
|
|
102
|
+
function normalizeTrustAnchorDs(value) {
|
|
103
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
104
|
+
if (!source || Object.keys(source).some((key) => !['keyTag', 'algorithm', 'digestType', 'digest'].includes(key)))
|
|
105
|
+
return null;
|
|
106
|
+
const keyTag = integer(source.keyTag, 0, 65_535);
|
|
107
|
+
const algorithm = integer(source.algorithm, 0, 255);
|
|
108
|
+
const digestType = integer(source.digestType, 1, 255);
|
|
109
|
+
const digest = typeof source.digest === 'string' ? source.digest.replace(/\s+/gu, '').toUpperCase() : '';
|
|
110
|
+
const expectedLength = digestType === 1 ? 40 : digestType === 2 ? 64 : digestType === 4 ? 96 : null;
|
|
111
|
+
if (keyTag === null || algorithm === null || digestType === null || !expectedLength || digest.length !== expectedLength || !/^[0-9A-F]+$/u.test(digest))
|
|
112
|
+
return null;
|
|
113
|
+
return Object.freeze({ keyTag, algorithm, digestType, digest });
|
|
114
|
+
}
|
|
115
|
+
function parseDnssecTrustAnchor(value) {
|
|
116
|
+
const input = typeof value === 'string' ? (() => {
|
|
117
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_DNSSEC_TRUST_ANCHOR_BYTES) {
|
|
118
|
+
throw new TypeError(`DNSSEC trust anchor is limited to ${MAX_DNSSEC_TRUST_ANCHOR_BYTES} bytes.`);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(value);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new TypeError('DNSSEC trust anchor is not valid JSON.');
|
|
125
|
+
}
|
|
126
|
+
})() : value;
|
|
127
|
+
const source = exactObject(input, new Set(['schema', 'version', 'zone', 'source', 'reviewedAt', 'dsRecords']), 'DNSSEC trust anchor');
|
|
128
|
+
if (source.schema !== DNSSEC_TRUST_ANCHOR_SCHEMA || source.version !== DNSSEC_TRUST_ANCHOR_VERSION) {
|
|
129
|
+
throw new TypeError(`DNSSEC trust anchor must use ${DNSSEC_TRUST_ANCHOR_SCHEMA} version ${DNSSEC_TRUST_ANCHOR_VERSION}.`);
|
|
130
|
+
}
|
|
131
|
+
const zone = normalizeDnsName(source.zone, { allowRoot: true });
|
|
132
|
+
const sourceLabel = normalizedText(source.source, 160);
|
|
133
|
+
const reviewedAt = normalizedTimestamp(source.reviewedAt);
|
|
134
|
+
if (!zone || !sourceLabel || !reviewedAt || !Array.isArray(source.dsRecords)
|
|
135
|
+
|| source.dsRecords.length < 1 || source.dsRecords.length > MAX_TRUST_ANCHOR_RECORDS) {
|
|
136
|
+
throw new TypeError(`DNSSEC trust anchor requires one bounded zone, source, review time, and 1 to ${MAX_TRUST_ANCHOR_RECORDS} DS records.`);
|
|
137
|
+
}
|
|
138
|
+
const dsRecords = source.dsRecords.map(normalizeTrustAnchorDs);
|
|
139
|
+
if (dsRecords.some((record) => record === null))
|
|
140
|
+
throw new TypeError('DNSSEC trust anchor contains a malformed or unsupported DS record.');
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
schema: DNSSEC_TRUST_ANCHOR_SCHEMA,
|
|
143
|
+
version: DNSSEC_TRUST_ANCHOR_VERSION,
|
|
144
|
+
zone,
|
|
145
|
+
source: sourceLabel,
|
|
146
|
+
reviewedAt,
|
|
147
|
+
dsRecords: Object.freeze(dsRecords),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function assertRange(buffer, offset, length, boundary = buffer.length) {
|
|
151
|
+
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0 || offset + length > boundary) {
|
|
152
|
+
throw new DnssecWireError('DNS response ended inside a field.');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function encodeDnsName(value) {
|
|
156
|
+
const name = value === '.' ? '.' : normalizeDnsName(value, { allowServiceLabels: true, allowRoot: true });
|
|
157
|
+
if (!name)
|
|
158
|
+
throw new DnssecWireError('DNS query name is invalid.', 'EINVAL');
|
|
159
|
+
if (name === '.')
|
|
160
|
+
return Buffer.from([0]);
|
|
161
|
+
return Buffer.concat([...name.split('.').flatMap((label) => {
|
|
162
|
+
const bytes = Buffer.from(label, 'ascii');
|
|
163
|
+
return [Buffer.from([bytes.length]), bytes];
|
|
164
|
+
}), Buffer.from([0])]);
|
|
165
|
+
}
|
|
166
|
+
function readDnsName(message, startOffset, boundary = message.length) {
|
|
167
|
+
const labels = [];
|
|
168
|
+
const wireLabels = [];
|
|
169
|
+
const pointers = new Set();
|
|
170
|
+
let offset = startOffset;
|
|
171
|
+
let nextOffset = null;
|
|
172
|
+
let wireLength = 0;
|
|
173
|
+
while (true) {
|
|
174
|
+
assertRange(message, offset, 1, nextOffset === null ? boundary : message.length);
|
|
175
|
+
const length = message[offset];
|
|
176
|
+
if ((length & 0xc0) === 0xc0) {
|
|
177
|
+
assertRange(message, offset, 2, nextOffset === null ? boundary : message.length);
|
|
178
|
+
const pointer = ((length & 0x3f) << 8) | message[offset + 1];
|
|
179
|
+
if (pointer >= message.length || pointers.has(pointer) || pointers.size >= 32)
|
|
180
|
+
throw new DnssecWireError('DNS name compression pointer is invalid.');
|
|
181
|
+
pointers.add(pointer);
|
|
182
|
+
if (nextOffset === null)
|
|
183
|
+
nextOffset = offset + 2;
|
|
184
|
+
offset = pointer;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if ((length & 0xc0) !== 0 || length > 63)
|
|
188
|
+
throw new DnssecWireError('DNS name label encoding is invalid.');
|
|
189
|
+
offset += 1;
|
|
190
|
+
if (length === 0) {
|
|
191
|
+
if (nextOffset === null)
|
|
192
|
+
nextOffset = offset;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
assertRange(message, offset, length, nextOffset === null ? boundary : message.length);
|
|
196
|
+
wireLength += length + 1;
|
|
197
|
+
if (wireLength > 254)
|
|
198
|
+
throw new DnssecWireError('DNS name exceeds the wire-length limit.');
|
|
199
|
+
const bytes = message.subarray(offset, offset + length);
|
|
200
|
+
if ([...bytes].some((byte) => byte < 0x21 || byte > 0x7e))
|
|
201
|
+
throw new DnssecWireError('DNS name contains an unsafe label byte.');
|
|
202
|
+
labels.push(bytes.toString('ascii').toLowerCase());
|
|
203
|
+
wireLabels.push(Buffer.concat([Buffer.from([length]), Buffer.from(bytes)]));
|
|
204
|
+
offset += length;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
name: labels.length ? labels.join('.') : '.',
|
|
208
|
+
nextOffset: nextOffset,
|
|
209
|
+
wireName: Buffer.concat([...wireLabels, Buffer.from([0])]),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function buildDnssecQuery(nameValue, type, transactionId = randomInt(0x1_0000)) {
|
|
213
|
+
const name = normalizeDnsName(nameValue, { allowServiceLabels: true, allowRoot: true });
|
|
214
|
+
if (!name || !Number.isInteger(type) || type < 1 || type > 65_535
|
|
215
|
+
|| !Number.isInteger(transactionId) || transactionId < 0 || transactionId > 65_535) {
|
|
216
|
+
throw new DnssecWireError('DNSSEC query parameters are invalid.', 'EINVAL');
|
|
217
|
+
}
|
|
218
|
+
const qname = encodeDnsName(name);
|
|
219
|
+
const message = Buffer.alloc(12 + qname.length + 4 + 11);
|
|
220
|
+
message.writeUInt16BE(transactionId, 0);
|
|
221
|
+
message.writeUInt16BE(0x0110, 2);
|
|
222
|
+
message.writeUInt16BE(1, 4);
|
|
223
|
+
message.writeUInt16BE(0, 6);
|
|
224
|
+
message.writeUInt16BE(0, 8);
|
|
225
|
+
message.writeUInt16BE(1, 10);
|
|
226
|
+
qname.copy(message, 12);
|
|
227
|
+
let offset = 12 + qname.length;
|
|
228
|
+
message.writeUInt16BE(type, offset);
|
|
229
|
+
message.writeUInt16BE(DNS_CLASS_IN, offset + 2);
|
|
230
|
+
offset += 4;
|
|
231
|
+
message[offset] = 0;
|
|
232
|
+
message.writeUInt16BE(DNS_TYPE_OPT, offset + 1);
|
|
233
|
+
message.writeUInt16BE(1232, offset + 3);
|
|
234
|
+
message.writeUInt32BE(0x00008000, offset + 5);
|
|
235
|
+
message.writeUInt16BE(0, offset + 9);
|
|
236
|
+
return message;
|
|
237
|
+
}
|
|
238
|
+
function parseTypeBitmap(bytes) {
|
|
239
|
+
const output = new Set();
|
|
240
|
+
let offset = 0;
|
|
241
|
+
let priorWindow = -1;
|
|
242
|
+
while (offset < bytes.length) {
|
|
243
|
+
assertRange(bytes, offset, 2);
|
|
244
|
+
const window = bytes[offset];
|
|
245
|
+
const length = bytes[offset + 1];
|
|
246
|
+
offset += 2;
|
|
247
|
+
if (window <= priorWindow || length < 1 || length > 32)
|
|
248
|
+
throw new DnssecWireError('DNSSEC type bitmap is malformed.');
|
|
249
|
+
priorWindow = window;
|
|
250
|
+
assertRange(bytes, offset, length);
|
|
251
|
+
for (let octet = 0; octet < length; octet += 1) {
|
|
252
|
+
const value = bytes[offset + octet];
|
|
253
|
+
for (let bit = 0; bit < 8; bit += 1)
|
|
254
|
+
if (value & (0x80 >> bit))
|
|
255
|
+
output.add(window * 256 + octet * 8 + bit);
|
|
256
|
+
}
|
|
257
|
+
offset += length;
|
|
258
|
+
}
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
function base32hex(bytes) {
|
|
262
|
+
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUV';
|
|
263
|
+
let accumulator = 0;
|
|
264
|
+
let bits = 0;
|
|
265
|
+
let output = '';
|
|
266
|
+
for (const byte of bytes) {
|
|
267
|
+
accumulator = (accumulator << 8) | byte;
|
|
268
|
+
bits += 8;
|
|
269
|
+
while (bits >= 5) {
|
|
270
|
+
bits -= 5;
|
|
271
|
+
output += alphabet[(accumulator >>> bits) & 31];
|
|
272
|
+
accumulator &= (1 << bits) - 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (bits)
|
|
276
|
+
output += alphabet[(accumulator << (5 - bits)) & 31];
|
|
277
|
+
return output;
|
|
278
|
+
}
|
|
279
|
+
function parseRecordData(message, type, start, end) {
|
|
280
|
+
const raw = message.subarray(start, end);
|
|
281
|
+
if (type === DNS_TYPE_A) {
|
|
282
|
+
if (raw.length !== 4)
|
|
283
|
+
throw new DnssecWireError('DNS A record length is invalid.');
|
|
284
|
+
return { data: { kind: 'A', address: [...raw].join('.') }, canonicalRdata: Buffer.from(raw) };
|
|
285
|
+
}
|
|
286
|
+
if (type === DNS_TYPE_AAAA) {
|
|
287
|
+
if (raw.length !== 16)
|
|
288
|
+
throw new DnssecWireError('DNS AAAA record length is invalid.');
|
|
289
|
+
return { data: { kind: 'AAAA', address: formatIpv6(raw) }, canonicalRdata: Buffer.from(raw) };
|
|
290
|
+
}
|
|
291
|
+
if (type === DNS_TYPE_NS || type === DNS_TYPE_CNAME) {
|
|
292
|
+
const parsed = readDnsName(message, start, end);
|
|
293
|
+
if (parsed.nextOffset !== end)
|
|
294
|
+
throw new DnssecWireError('DNS name record contains trailing bytes.');
|
|
295
|
+
return {
|
|
296
|
+
data: { kind: type === DNS_TYPE_NS ? 'NS' : 'CNAME', name: parsed.name },
|
|
297
|
+
canonicalRdata: encodeDnsName(parsed.name),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (type === DNS_TYPE_DS) {
|
|
301
|
+
if (raw.length < 5 || raw.length > 68)
|
|
302
|
+
throw new DnssecWireError('DNS DS record length is invalid.');
|
|
303
|
+
return {
|
|
304
|
+
data: { kind: 'DS', keyTag: raw.readUInt16BE(0), algorithm: raw[2], digestType: raw[3], digest: Buffer.from(raw.subarray(4)) },
|
|
305
|
+
canonicalRdata: Buffer.from(raw),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (type === DNS_TYPE_DNSKEY) {
|
|
309
|
+
if (raw.length < 5 || raw.length > 8196)
|
|
310
|
+
throw new DnssecWireError('DNS DNSKEY record length is invalid.');
|
|
311
|
+
return {
|
|
312
|
+
data: { kind: 'DNSKEY', flags: raw.readUInt16BE(0), protocol: raw[2], algorithm: raw[3], publicKey: Buffer.from(raw.subarray(4)) },
|
|
313
|
+
canonicalRdata: Buffer.from(raw),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (type === DNS_TYPE_RRSIG) {
|
|
317
|
+
if (raw.length < 20)
|
|
318
|
+
throw new DnssecWireError('DNS RRSIG record length is invalid.');
|
|
319
|
+
const signer = readDnsName(message, start + 18, end);
|
|
320
|
+
if (signer.nextOffset >= end)
|
|
321
|
+
throw new DnssecWireError('DNS RRSIG record has no signature bytes.');
|
|
322
|
+
const signedPrefix = Buffer.concat([raw.subarray(0, 18), encodeDnsName(signer.name)]);
|
|
323
|
+
return {
|
|
324
|
+
data: {
|
|
325
|
+
kind: 'RRSIG',
|
|
326
|
+
typeCovered: raw.readUInt16BE(0),
|
|
327
|
+
algorithm: raw[2],
|
|
328
|
+
labels: raw[3],
|
|
329
|
+
originalTtl: raw.readUInt32BE(4),
|
|
330
|
+
expiration: raw.readUInt32BE(8),
|
|
331
|
+
inception: raw.readUInt32BE(12),
|
|
332
|
+
keyTag: raw.readUInt16BE(16),
|
|
333
|
+
signerName: signer.name,
|
|
334
|
+
signature: Buffer.from(message.subarray(signer.nextOffset, end)),
|
|
335
|
+
signedPrefix,
|
|
336
|
+
},
|
|
337
|
+
canonicalRdata: Buffer.concat([signedPrefix, message.subarray(signer.nextOffset, end)]),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (type === DNS_TYPE_NSEC) {
|
|
341
|
+
const next = readDnsName(message, start, end);
|
|
342
|
+
if (next.nextOffset >= end)
|
|
343
|
+
throw new DnssecWireError('DNS NSEC record has no type bitmap.');
|
|
344
|
+
const bitmap = Buffer.from(message.subarray(next.nextOffset, end));
|
|
345
|
+
return {
|
|
346
|
+
data: { kind: 'NSEC', nextName: next.name, types: parseTypeBitmap(bitmap) },
|
|
347
|
+
canonicalRdata: Buffer.concat([next.wireName, bitmap]),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (type === DNS_TYPE_NSEC3) {
|
|
351
|
+
if (raw.length < 6)
|
|
352
|
+
throw new DnssecWireError('DNS NSEC3 record length is invalid.');
|
|
353
|
+
const saltLength = raw[4];
|
|
354
|
+
assertRange(raw, 5, saltLength + 1);
|
|
355
|
+
const hashLengthOffset = 5 + saltLength;
|
|
356
|
+
const hashLength = raw[hashLengthOffset];
|
|
357
|
+
if (!hashLength)
|
|
358
|
+
throw new DnssecWireError('DNS NSEC3 next hash is empty.');
|
|
359
|
+
assertRange(raw, hashLengthOffset + 1, hashLength + 1);
|
|
360
|
+
const bitmapOffset = hashLengthOffset + 1 + hashLength;
|
|
361
|
+
return {
|
|
362
|
+
data: {
|
|
363
|
+
kind: 'NSEC3',
|
|
364
|
+
hashAlgorithm: raw[0],
|
|
365
|
+
flags: raw[1],
|
|
366
|
+
iterations: raw.readUInt16BE(2),
|
|
367
|
+
salt: Buffer.from(raw.subarray(5, 5 + saltLength)),
|
|
368
|
+
nextHash: base32hex(Buffer.from(raw.subarray(hashLengthOffset + 1, bitmapOffset))),
|
|
369
|
+
types: parseTypeBitmap(Buffer.from(raw.subarray(bitmapOffset))),
|
|
370
|
+
},
|
|
371
|
+
canonicalRdata: Buffer.from(raw),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (type === DNS_TYPE_TLSA) {
|
|
375
|
+
if (raw.length < 4 || raw.length > 256 * 1024)
|
|
376
|
+
throw new DnssecWireError('DNS TLSA record length is invalid.');
|
|
377
|
+
return {
|
|
378
|
+
data: { kind: 'TLSA', usage: raw[0], selector: raw[1], matchingType: raw[2], associationData: Buffer.from(raw.subarray(3)) },
|
|
379
|
+
canonicalRdata: Buffer.from(raw),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return { data: { kind: 'opaque' }, canonicalRdata: Buffer.from(raw) };
|
|
383
|
+
}
|
|
384
|
+
function parseDnssecResponse(messageValue, expected) {
|
|
385
|
+
const message = Buffer.from(messageValue);
|
|
386
|
+
if (message.length < 12 || message.length > MAX_DNS_MESSAGE_BYTES)
|
|
387
|
+
throw new DnssecWireError('DNSSEC response size is invalid.');
|
|
388
|
+
const flags = message.readUInt16BE(2);
|
|
389
|
+
const counts = [message.readUInt16BE(6), message.readUInt16BE(8), message.readUInt16BE(10)];
|
|
390
|
+
if (message.readUInt16BE(0) !== expected.transactionId || (flags & 0x8000) === 0 || (flags & 0x7800) !== 0
|
|
391
|
+
|| message.readUInt16BE(4) !== 1 || counts.reduce((total, value) => total + value, 0) > MAX_DNS_RECORDS_PER_RESPONSE) {
|
|
392
|
+
throw new DnssecWireError('DNSSEC response header does not match the query.');
|
|
393
|
+
}
|
|
394
|
+
if ((flags & 0x0200) !== 0)
|
|
395
|
+
throw new DnssecWireError('DNSSEC response remained truncated over TCP.');
|
|
396
|
+
let offset = 12;
|
|
397
|
+
const question = readDnsName(message, offset);
|
|
398
|
+
offset = question.nextOffset;
|
|
399
|
+
assertRange(message, offset, 4);
|
|
400
|
+
const questionType = message.readUInt16BE(offset);
|
|
401
|
+
const questionClass = message.readUInt16BE(offset + 2);
|
|
402
|
+
offset += 4;
|
|
403
|
+
if (question.name !== expected.name || questionType !== expected.type || questionClass !== DNS_CLASS_IN) {
|
|
404
|
+
throw new DnssecWireError('DNSSEC response question does not match the query.');
|
|
405
|
+
}
|
|
406
|
+
const records = [];
|
|
407
|
+
for (let sectionIndex = 0; sectionIndex < counts.length; sectionIndex += 1) {
|
|
408
|
+
const section = ['answer', 'authority', 'additional'][sectionIndex];
|
|
409
|
+
for (let index = 0; index < counts[sectionIndex]; index += 1) {
|
|
410
|
+
const owner = readDnsName(message, offset);
|
|
411
|
+
offset = owner.nextOffset;
|
|
412
|
+
assertRange(message, offset, 10);
|
|
413
|
+
const type = message.readUInt16BE(offset);
|
|
414
|
+
const recordClass = message.readUInt16BE(offset + 2);
|
|
415
|
+
const ttl = message.readUInt32BE(offset + 4);
|
|
416
|
+
const length = message.readUInt16BE(offset + 8);
|
|
417
|
+
offset += 10;
|
|
418
|
+
const end = offset + length;
|
|
419
|
+
assertRange(message, offset, length);
|
|
420
|
+
if (type !== DNS_TYPE_OPT) {
|
|
421
|
+
const parsed = parseRecordData(message, type, offset, end);
|
|
422
|
+
records.push(Object.freeze({ section, owner: owner.name, type, class: recordClass, ttl, ...parsed }));
|
|
423
|
+
}
|
|
424
|
+
offset = end;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (offset !== message.length)
|
|
428
|
+
throw new DnssecWireError('DNSSEC response contains trailing bytes.');
|
|
429
|
+
return Object.freeze({
|
|
430
|
+
name: expected.name,
|
|
431
|
+
type: expected.type,
|
|
432
|
+
rcode: flags & 0x000f,
|
|
433
|
+
authenticatedData: (flags & 0x0020) !== 0,
|
|
434
|
+
records: Object.freeze(records),
|
|
435
|
+
byteLength: message.length,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
class DnssecQuerySession {
|
|
439
|
+
resolver;
|
|
440
|
+
maximumQueries;
|
|
441
|
+
maximumResponseBytes;
|
|
442
|
+
totalTimeoutMs;
|
|
443
|
+
queryTimeoutMs;
|
|
444
|
+
#exchange;
|
|
445
|
+
#transactionId;
|
|
446
|
+
#now;
|
|
447
|
+
#startedAt;
|
|
448
|
+
#cache = new Map();
|
|
449
|
+
queryCount = 0;
|
|
450
|
+
responseBytes = 0;
|
|
451
|
+
constructor(options) {
|
|
452
|
+
this.resolver = options.resolver;
|
|
453
|
+
this.maximumQueries = Math.max(1, Math.min(MAX_DNSSEC_QUERIES, options.maximumQueries ?? MAX_DNSSEC_QUERIES));
|
|
454
|
+
this.maximumResponseBytes = Math.max(1, Math.min(MAX_DNSSEC_RESPONSE_BYTES, options.maximumResponseBytes ?? MAX_DNSSEC_RESPONSE_BYTES));
|
|
455
|
+
this.totalTimeoutMs = Math.max(250, Math.min(30_000, options.totalTimeoutMs ?? DNSSEC_TOTAL_TIMEOUT_MS));
|
|
456
|
+
this.queryTimeoutMs = Math.max(250, Math.min(10_000, options.queryTimeoutMs ?? DNSSEC_QUERY_TIMEOUT_MS));
|
|
457
|
+
this.#exchange = options.exchange ?? defaultTcpExchange;
|
|
458
|
+
this.#transactionId = options.transactionId ?? (() => randomInt(0x1_0000));
|
|
459
|
+
this.#now = options.now ?? Date.now;
|
|
460
|
+
this.#startedAt = this.#now();
|
|
461
|
+
}
|
|
462
|
+
elapsedMs() { return Math.max(0, this.#now() - this.#startedAt); }
|
|
463
|
+
async query(nameValue, type, options = {}) {
|
|
464
|
+
const name = normalizeDnsName(nameValue, { allowServiceLabels: true, allowRoot: true });
|
|
465
|
+
if (!name)
|
|
466
|
+
throw new DnssecWireError('DNSSEC query name is invalid.', 'EINVAL');
|
|
467
|
+
const key = `${name}|${type}`;
|
|
468
|
+
if (!options.fresh) {
|
|
469
|
+
const cached = this.#cache.get(key);
|
|
470
|
+
if (cached)
|
|
471
|
+
return cached;
|
|
472
|
+
}
|
|
473
|
+
const promise = this.#queryUncached(name, type);
|
|
474
|
+
if (!options.fresh)
|
|
475
|
+
this.#cache.set(key, promise);
|
|
476
|
+
try {
|
|
477
|
+
return await promise;
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
if (!options.fresh)
|
|
481
|
+
this.#cache.delete(key);
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async #queryUncached(name, type) {
|
|
486
|
+
if (this.queryCount >= this.maximumQueries)
|
|
487
|
+
throw new DnssecWireError('DNSSEC query limit was reached.', 'ELIMIT');
|
|
488
|
+
const remaining = this.totalTimeoutMs - this.elapsedMs();
|
|
489
|
+
if (remaining <= 0)
|
|
490
|
+
throw new DnssecWireError('DNSSEC total run timed out.', 'ETIMEOUT');
|
|
491
|
+
this.queryCount += 1;
|
|
492
|
+
const transactionId = this.#transactionId();
|
|
493
|
+
const query = buildDnssecQuery(name, type, transactionId);
|
|
494
|
+
let bytes;
|
|
495
|
+
try {
|
|
496
|
+
bytes = await this.#exchange(query, this.resolver, { timeoutMs: Math.min(this.queryTimeoutMs, remaining) });
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code || '') : '';
|
|
500
|
+
throw new DnssecWireError(boundedError(error), /TIMEOUT|TIMEDOUT/u.test(code) || /timed out/iu.test(boundedError(error)) ? 'ETIMEOUT' : 'ETRANSPORT');
|
|
501
|
+
}
|
|
502
|
+
this.responseBytes += bytes.length;
|
|
503
|
+
if (this.responseBytes > this.maximumResponseBytes)
|
|
504
|
+
throw new DnssecWireError('DNSSEC response-byte limit was reached.', 'ELIMIT');
|
|
505
|
+
const response = parseDnssecResponse(bytes, { transactionId, name, type });
|
|
506
|
+
if (![0, 3].includes(response.rcode))
|
|
507
|
+
throw new DnssecWireError(`DNS resolver returned response code ${response.rcode}.`, 'EDNSRCODE');
|
|
508
|
+
return response;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function recordsFor(response, owner, type) {
|
|
512
|
+
return response.records.filter((record) => record.owner === owner && record.type === type && record.class === DNS_CLASS_IN);
|
|
513
|
+
}
|
|
514
|
+
function dnskeyTag(rdata) {
|
|
515
|
+
let accumulator = 0;
|
|
516
|
+
for (let index = 0; index < rdata.length; index += 1)
|
|
517
|
+
accumulator += index & 1 ? rdata[index] : rdata[index] << 8;
|
|
518
|
+
accumulator += (accumulator >> 16) & 0xffff;
|
|
519
|
+
return accumulator & 0xffff;
|
|
520
|
+
}
|
|
521
|
+
function digestForDs(owner, key, digestType) {
|
|
522
|
+
const hash = digestType === 1 ? 'sha1' : digestType === 2 ? 'sha256' : digestType === 4 ? 'sha384' : null;
|
|
523
|
+
if (!hash || key.data.kind !== 'DNSKEY')
|
|
524
|
+
return null;
|
|
525
|
+
return crypto.createHash(hash).update(encodeDnsName(owner)).update(key.canonicalRdata).digest();
|
|
526
|
+
}
|
|
527
|
+
function dsMatchesKey(owner, ds, key) {
|
|
528
|
+
if (key.data.kind !== 'DNSKEY' || key.data.protocol !== 3 || (key.data.flags & 0x0100) === 0 || key.data.algorithm !== ds.algorithm)
|
|
529
|
+
return false;
|
|
530
|
+
if (dnskeyTag(key.canonicalRdata) !== ds.keyTag)
|
|
531
|
+
return false;
|
|
532
|
+
const digest = digestForDs(owner, key, ds.digestType);
|
|
533
|
+
const expected = Buffer.isBuffer(ds.digest) ? ds.digest : Buffer.from(ds.digest, 'hex');
|
|
534
|
+
return Boolean(digest && digest.length === expected.length && crypto.timingSafeEqual(digest, expected));
|
|
535
|
+
}
|
|
536
|
+
function preferredSupportedDs(records) {
|
|
537
|
+
const supported = records.filter((record) => [1, 2, 4].includes(record.data.digestType));
|
|
538
|
+
return supported.some((record) => record.data.digestType === 2)
|
|
539
|
+
? supported.filter((record) => record.data.digestType !== 1)
|
|
540
|
+
: supported;
|
|
541
|
+
}
|
|
542
|
+
function base64url(bytes) { return bytes.toString('base64url'); }
|
|
543
|
+
function dnskeyPublicKey(key) {
|
|
544
|
+
try {
|
|
545
|
+
if ([5, 7, 8, 10].includes(key.algorithm)) {
|
|
546
|
+
const bytes = key.publicKey;
|
|
547
|
+
if (bytes.length < 3)
|
|
548
|
+
return null;
|
|
549
|
+
const exponentLength = bytes[0] === 0 ? bytes.readUInt16BE(1) : bytes[0];
|
|
550
|
+
const offset = bytes[0] === 0 ? 3 : 1;
|
|
551
|
+
if (!exponentLength || offset + exponentLength >= bytes.length)
|
|
552
|
+
return null;
|
|
553
|
+
return crypto.createPublicKey({
|
|
554
|
+
key: { kty: 'RSA', n: base64url(bytes.subarray(offset + exponentLength)), e: base64url(bytes.subarray(offset, offset + exponentLength)), ext: true },
|
|
555
|
+
format: 'jwk',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (key.algorithm === 13 && key.publicKey.length === 64) {
|
|
559
|
+
return crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: base64url(key.publicKey.subarray(0, 32)), y: base64url(key.publicKey.subarray(32)), ext: true }, format: 'jwk' });
|
|
560
|
+
}
|
|
561
|
+
if (key.algorithm === 14 && key.publicKey.length === 96) {
|
|
562
|
+
return crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-384', x: base64url(key.publicKey.subarray(0, 48)), y: base64url(key.publicKey.subarray(48)), ext: true }, format: 'jwk' });
|
|
563
|
+
}
|
|
564
|
+
if (key.algorithm === 15 && key.publicKey.length === 32) {
|
|
565
|
+
return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: base64url(key.publicKey), ext: true }, format: 'jwk' });
|
|
566
|
+
}
|
|
567
|
+
if (key.algorithm === 16 && key.publicKey.length === 57) {
|
|
568
|
+
return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed448', x: base64url(key.publicKey), ext: true }, format: 'jwk' });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
function signatureOwner(owner, labels) {
|
|
577
|
+
const ownerLabels = owner === '.' ? [] : owner.split('.');
|
|
578
|
+
if (labels > ownerLabels.length)
|
|
579
|
+
return null;
|
|
580
|
+
if (labels === ownerLabels.length)
|
|
581
|
+
return owner;
|
|
582
|
+
const suffix = labels ? ownerLabels.slice(ownerLabels.length - labels).join('.') : '';
|
|
583
|
+
return suffix ? `*.${suffix}` : '*';
|
|
584
|
+
}
|
|
585
|
+
function encodeSignatureOwner(owner) {
|
|
586
|
+
if (owner === '*')
|
|
587
|
+
return Buffer.from([1, 0x2a, 0]);
|
|
588
|
+
if (owner.startsWith('*.'))
|
|
589
|
+
return Buffer.concat([Buffer.from([1, 0x2a]), encodeDnsName(owner.slice(2))]);
|
|
590
|
+
return encodeDnsName(owner);
|
|
591
|
+
}
|
|
592
|
+
function signedRrsetData(owner, type, rrset, signature) {
|
|
593
|
+
const canonicalOwner = signatureOwner(owner, signature.labels);
|
|
594
|
+
if (!canonicalOwner || signature.typeCovered !== type || !rrset.length)
|
|
595
|
+
return null;
|
|
596
|
+
const ownerWire = encodeSignatureOwner(canonicalOwner);
|
|
597
|
+
const uniqueRdata = [...new Map(rrset.map((record) => [record.canonicalRdata.toString('hex'), record.canonicalRdata])).values()]
|
|
598
|
+
.sort(Buffer.compare);
|
|
599
|
+
const records = uniqueRdata.map((canonicalRdata) => {
|
|
600
|
+
const header = Buffer.alloc(ownerWire.length + 10);
|
|
601
|
+
ownerWire.copy(header, 0);
|
|
602
|
+
header.writeUInt16BE(type, ownerWire.length);
|
|
603
|
+
header.writeUInt16BE(DNS_CLASS_IN, ownerWire.length + 2);
|
|
604
|
+
header.writeUInt32BE(signature.originalTtl, ownerWire.length + 4);
|
|
605
|
+
header.writeUInt16BE(canonicalRdata.length, ownerWire.length + 8);
|
|
606
|
+
return Buffer.concat([header, canonicalRdata]);
|
|
607
|
+
});
|
|
608
|
+
return Buffer.concat([signature.signedPrefix, ...records]);
|
|
609
|
+
}
|
|
610
|
+
function signatureWithinWindow(signature, nowSeconds) {
|
|
611
|
+
const now = Math.floor(nowSeconds) >>> 0;
|
|
612
|
+
const sinceInception = (now - signature.inception) >>> 0;
|
|
613
|
+
const untilExpiration = (signature.expiration - now) >>> 0;
|
|
614
|
+
return sinceInception < 0x8000_0000 && untilExpiration < 0x8000_0000;
|
|
615
|
+
}
|
|
616
|
+
function verifyDnssecSignature(key, signature, data) {
|
|
617
|
+
if (![5, 7, 8, 10, 13, 14, 15, 16].includes(key.algorithm))
|
|
618
|
+
return null;
|
|
619
|
+
const publicKey = dnskeyPublicKey(key);
|
|
620
|
+
if (!publicKey)
|
|
621
|
+
return false;
|
|
622
|
+
try {
|
|
623
|
+
if (key.algorithm === 5 || key.algorithm === 7)
|
|
624
|
+
return crypto.verify('sha1', data, publicKey, signature.signature);
|
|
625
|
+
if (key.algorithm === 8)
|
|
626
|
+
return crypto.verify('sha256', data, publicKey, signature.signature);
|
|
627
|
+
if (key.algorithm === 10)
|
|
628
|
+
return crypto.verify('sha512', data, publicKey, signature.signature);
|
|
629
|
+
if (key.algorithm === 13)
|
|
630
|
+
return crypto.verify('sha256', data, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature.signature);
|
|
631
|
+
if (key.algorithm === 14)
|
|
632
|
+
return crypto.verify('sha384', data, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature.signature);
|
|
633
|
+
if (key.algorithm === 15 || key.algorithm === 16)
|
|
634
|
+
return crypto.verify(null, data, publicKey, signature.signature);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
function verifyRrset(response, owner, type, keys, signerName, now) {
|
|
642
|
+
const rrset = recordsFor(response, owner, type);
|
|
643
|
+
const signatures = response.records.filter((record) => (record.owner === owner && record.type === DNS_TYPE_RRSIG && record.class === DNS_CLASS_IN
|
|
644
|
+
&& record.data.kind === 'RRSIG' && record.data.typeCovered === type && record.data.signerName === signerName));
|
|
645
|
+
if (!rrset.length || !signatures.length)
|
|
646
|
+
return { state: 'indeterminate', algorithm: null, keyTag: null, detail: 'The response did not contain a complete signed RRset.' };
|
|
647
|
+
if (rrset.length > MAX_DNSSEC_RRSET_RECORDS || signatures.length > MAX_DNSSEC_RRSET_SIGNATURES || keys.length > MAX_DNSSEC_RRSET_RECORDS) {
|
|
648
|
+
return { state: 'unsupported', algorithm: null, keyTag: null, detail: 'The signed RRset exceeded this validator\'s record, signature, or authenticated-key bound.' };
|
|
649
|
+
}
|
|
650
|
+
let attempted = false;
|
|
651
|
+
let unsupported = false;
|
|
652
|
+
let outsideWindow = false;
|
|
653
|
+
let invalidLabelCount = false;
|
|
654
|
+
const nowSeconds = Math.floor(now.getTime() / 1000);
|
|
655
|
+
const ownerLabelCount = owner === '.' ? 0 : owner.split('.').length;
|
|
656
|
+
for (const signatureRecord of signatures) {
|
|
657
|
+
const signature = signatureRecord.data;
|
|
658
|
+
if (!signatureWithinWindow(signature, nowSeconds)) {
|
|
659
|
+
outsideWindow = true;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (signature.labels > ownerLabelCount) {
|
|
663
|
+
invalidLabelCount = true;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (signature.labels < ownerLabelCount) {
|
|
667
|
+
unsupported = true;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const candidates = keys.filter((record) => (record.data.kind === 'DNSKEY'
|
|
671
|
+
&& record.owner === signerName
|
|
672
|
+
&& record.data.protocol === 3
|
|
673
|
+
&& (record.data.flags & 0x0100) !== 0
|
|
674
|
+
&& record.data.algorithm === signature.algorithm
|
|
675
|
+
&& dnskeyTag(record.canonicalRdata) === signature.keyTag));
|
|
676
|
+
if (!candidates.length)
|
|
677
|
+
continue;
|
|
678
|
+
const data = signedRrsetData(owner, type, rrset, signature);
|
|
679
|
+
if (!data)
|
|
680
|
+
continue;
|
|
681
|
+
for (const key of candidates) {
|
|
682
|
+
const valid = verifyDnssecSignature(key.data, signature, data);
|
|
683
|
+
if (valid === null) {
|
|
684
|
+
unsupported = true;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
attempted = true;
|
|
688
|
+
if (valid)
|
|
689
|
+
return { state: 'valid', algorithm: signature.algorithm, keyTag: signature.keyTag, detail: 'The RRset signature validated against an authenticated zone key.' };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (invalidLabelCount)
|
|
693
|
+
return { state: 'bogus', algorithm: null, keyTag: null, detail: 'The RRset signature label count exceeded the owner name.' };
|
|
694
|
+
if (attempted || outsideWindow)
|
|
695
|
+
return { state: 'bogus', algorithm: null, keyTag: null, detail: outsideWindow ? 'No usable RRset signature was within its validity window.' : 'The RRset signature did not validate.' };
|
|
696
|
+
if (unsupported)
|
|
697
|
+
return { state: 'unsupported', algorithm: null, keyTag: null, detail: 'The RRset used an unsupported DNSSEC algorithm or a wildcard-expanded signature that requires authenticated denial proof.' };
|
|
698
|
+
return { state: 'indeterminate', algorithm: null, keyTag: null, detail: 'No authenticated key matched the supplied RRset signature.' };
|
|
699
|
+
}
|
|
700
|
+
function nsec3Hash(name, salt, iterations) {
|
|
701
|
+
let value = crypto.createHash('sha1').update(encodeDnsName(name)).update(salt).digest();
|
|
702
|
+
for (let index = 0; index < iterations; index += 1)
|
|
703
|
+
value = crypto.createHash('sha1').update(value).update(salt).digest();
|
|
704
|
+
return base32hex(value);
|
|
705
|
+
}
|
|
706
|
+
function circularHashCovers(ownerHash, nextHash, candidate) {
|
|
707
|
+
if (ownerHash < nextHash)
|
|
708
|
+
return candidate > ownerHash && candidate < nextHash;
|
|
709
|
+
if (ownerHash > nextHash)
|
|
710
|
+
return candidate > ownerHash || candidate < nextHash;
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
function validateNegativeProof(response, name, missingType, keys, zone, now, requireDelegation) {
|
|
714
|
+
const exactNsecs = response.records.filter((record) => record.owner === name && record.data.kind === 'NSEC');
|
|
715
|
+
if (exactNsecs.length > 1)
|
|
716
|
+
return { state: 'bogus', detail: 'The denial response contained multiple NSEC records at one owner name.' };
|
|
717
|
+
const exactNsec = exactNsecs[0];
|
|
718
|
+
if (exactNsec) {
|
|
719
|
+
const verification = verifyRrset(response, exactNsec.owner, DNS_TYPE_NSEC, keys, zone, now);
|
|
720
|
+
if (verification.state !== 'valid')
|
|
721
|
+
return verification;
|
|
722
|
+
const delegation = exactNsec.data.types.has(DNS_TYPE_NS) && !exactNsec.data.types.has(DNS_TYPE_SOA);
|
|
723
|
+
if (!exactNsec.data.types.has(missingType) && (!requireDelegation || delegation)) {
|
|
724
|
+
return { state: 'valid', detail: 'A validated NSEC record proved the requested RRset absent.' };
|
|
725
|
+
}
|
|
726
|
+
return { state: 'bogus', detail: 'The validated NSEC bitmap did not prove the required absence and delegation state.' };
|
|
727
|
+
}
|
|
728
|
+
const nsec3Records = response.records.filter((record) => record.data.kind === 'NSEC3');
|
|
729
|
+
if (nsec3Records.length > MAX_DNSSEC_DENIAL_RECORDS)
|
|
730
|
+
return { state: 'unsupported', detail: 'The DNSSEC denial response exceeded the NSEC3 record bound.' };
|
|
731
|
+
let sawUnsupported = false;
|
|
732
|
+
let sawBogus = false;
|
|
733
|
+
for (const record of nsec3Records) {
|
|
734
|
+
const ownerParts = record.owner.split('.');
|
|
735
|
+
const ownerHash = ownerParts.shift()?.toUpperCase() || '';
|
|
736
|
+
if (record.data.hashAlgorithm !== 1 || record.data.iterations > MAX_DNSSEC_NSEC3_ITERATIONS || (record.data.flags & ~1) !== 0
|
|
737
|
+
|| ownerHash.length !== 32 || record.data.nextHash.length !== 32) {
|
|
738
|
+
sawUnsupported = true;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const ownerZone = ownerParts.length ? ownerParts.join('.') : '.';
|
|
742
|
+
if (ownerZone !== zone || !/^[0-9A-V]+$/u.test(ownerHash))
|
|
743
|
+
continue;
|
|
744
|
+
const candidate = nsec3Hash(name, record.data.salt, record.data.iterations);
|
|
745
|
+
const exact = candidate === ownerHash;
|
|
746
|
+
const optOutCover = missingType === DNS_TYPE_DS && (record.data.flags & 1) === 1
|
|
747
|
+
&& circularHashCovers(ownerHash, record.data.nextHash, candidate);
|
|
748
|
+
if (!exact && !optOutCover)
|
|
749
|
+
continue;
|
|
750
|
+
if (recordsFor(response, record.owner, DNS_TYPE_NSEC3).length !== 1) {
|
|
751
|
+
sawBogus = true;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const verification = verifyRrset(response, record.owner, DNS_TYPE_NSEC3, keys, zone, now);
|
|
755
|
+
if (verification.state !== 'valid') {
|
|
756
|
+
if (verification.state === 'unsupported')
|
|
757
|
+
sawUnsupported = true;
|
|
758
|
+
else
|
|
759
|
+
sawBogus = true;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (exact && record.data.types.has(missingType))
|
|
763
|
+
return { state: 'bogus', detail: 'The validated NSEC3 bitmap contains the RR type reported as absent.' };
|
|
764
|
+
if (requireDelegation && exact && (!record.data.types.has(DNS_TYPE_NS) || record.data.types.has(DNS_TYPE_SOA)))
|
|
765
|
+
return { state: 'bogus', detail: 'The validated NSEC3 record did not identify a parent-side delegation.' };
|
|
766
|
+
if (optOutCover)
|
|
767
|
+
return { state: 'unsupported', detail: 'NSEC3 opt-out span validation is outside this bounded validator.' };
|
|
768
|
+
return { state: 'valid', detail: 'A validated exact NSEC3 record proved the requested RRset absent.' };
|
|
769
|
+
}
|
|
770
|
+
if (sawBogus)
|
|
771
|
+
return { state: 'bogus', detail: 'The DNSSEC denial-of-existence signature did not validate.' };
|
|
772
|
+
if (sawUnsupported)
|
|
773
|
+
return { state: 'unsupported', detail: 'The DNSSEC denial of existence used unsupported NSEC3 parameters or algorithms.' };
|
|
774
|
+
return { state: 'indeterminate', detail: 'The response did not contain a supported authenticated denial of existence.' };
|
|
775
|
+
}
|
|
776
|
+
function targetWithinAnchor(target, anchor) {
|
|
777
|
+
return anchor === '.' || target === anchor || target.endsWith(`.${anchor}`);
|
|
778
|
+
}
|
|
779
|
+
function zonesBelowAnchor(target, anchor) {
|
|
780
|
+
if (target === anchor)
|
|
781
|
+
return [];
|
|
782
|
+
const targetLabels = target.split('.');
|
|
783
|
+
const anchorLabels = anchor === '.' ? [] : anchor.split('.');
|
|
784
|
+
const count = targetLabels.length - anchorLabels.length;
|
|
785
|
+
return Array.from({ length: count }, (_, index) => targetLabels.slice(targetLabels.length - anchorLabels.length - index - 1).join('.')).filter(Boolean);
|
|
786
|
+
}
|
|
787
|
+
function chainLimitations() {
|
|
788
|
+
return Object.freeze([
|
|
789
|
+
'This isolated action validates retained DNSSEC wire evidence from one analyst-selected recursive resolver against one supplied trust anchor; another resolver or observation time may differ.',
|
|
790
|
+
'Secure means that the supported chain and signatures validated at observation time. It is standards-posture evidence, not a complete security, ownership, availability, safety, or maliciousness guarantee.',
|
|
791
|
+
'Insecure, bogus, indeterminate, timed-out, and unsupported are distinct states. Missing or incomplete evidence is never converted into an unsigned or secure conclusion.',
|
|
792
|
+
'Wildcard-expanded positive RRsets remain unsupported unless authenticated denial evidence proves the wildcard expansion; this validator does not infer that proof from the signature alone.',
|
|
793
|
+
'No raw DNS response, DNSKEY public key, signature, or resolver transaction identifier is retained in the report.',
|
|
794
|
+
]);
|
|
795
|
+
}
|
|
796
|
+
function transportSnapshot(session, state) {
|
|
797
|
+
return Object.freeze({
|
|
798
|
+
state,
|
|
799
|
+
queryCount: session?.queryCount ?? 0,
|
|
800
|
+
responseBytes: session?.responseBytes ?? 0,
|
|
801
|
+
queryLimit: session?.maximumQueries ?? MAX_DNSSEC_QUERIES,
|
|
802
|
+
responseByteLimit: session?.maximumResponseBytes ?? MAX_DNSSEC_RESPONSE_BYTES,
|
|
803
|
+
totalTimeoutMs: session?.totalTimeoutMs ?? DNSSEC_TOTAL_TIMEOUT_MS,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
function failureReport(options) {
|
|
807
|
+
const transportState = options.state === 'timed_out' ? 'timed_out'
|
|
808
|
+
: options.kind === 'transport' ? 'partial'
|
|
809
|
+
: options.session ? 'complete' : 'unavailable';
|
|
810
|
+
return Object.freeze({
|
|
811
|
+
schema: DNSSEC_CHAIN_SCHEMA,
|
|
812
|
+
version: DNSSEC_CHAIN_VERSION,
|
|
813
|
+
state: options.state,
|
|
814
|
+
target: options.target,
|
|
815
|
+
observedAt: options.observedAt,
|
|
816
|
+
resolver: Object.freeze({ address: options.resolver?.address ?? null, port: options.resolver?.port ?? 53 }),
|
|
817
|
+
trustAnchor: Object.freeze({
|
|
818
|
+
zone: options.anchor?.zone ?? null,
|
|
819
|
+
source: options.anchor?.source ?? null,
|
|
820
|
+
reviewedAt: options.anchor?.reviewedAt ?? null,
|
|
821
|
+
dsRecordCount: options.anchor?.dsRecords.length ?? 0,
|
|
822
|
+
}),
|
|
823
|
+
validatedZone: options.validatedZone ?? null,
|
|
824
|
+
delegations: Object.freeze([...(options.delegations ?? [])]),
|
|
825
|
+
completeness: options.state === 'invalid' ? 'unavailable' : 'partial',
|
|
826
|
+
transport: transportSnapshot(options.session, transportState),
|
|
827
|
+
failure: Object.freeze({ kind: options.kind, stage: options.stage, detail: boundedError(options.detail) }),
|
|
828
|
+
limitations: chainLimitations(),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function stateFromVerification(state) {
|
|
832
|
+
return state === 'bogus' ? 'bogus' : state === 'unsupported' ? 'unsupported' : 'indeterminate';
|
|
833
|
+
}
|
|
834
|
+
function kindFromVerification(state) {
|
|
835
|
+
return state === 'unsupported' ? 'unsupported' : 'validation';
|
|
836
|
+
}
|
|
837
|
+
async function validateDnssecChainWithContext(input) {
|
|
838
|
+
const suppliedObservedAt = input.observedAt === undefined ? null : normalizedTimestamp(input.observedAt);
|
|
839
|
+
const observedAt = suppliedObservedAt ?? new Date().toISOString();
|
|
840
|
+
const now = new Date(observedAt);
|
|
841
|
+
const target = normalizeDnsName(input.target);
|
|
842
|
+
const resolver = normalizeResolverEndpoint(input.resolver);
|
|
843
|
+
if (input.ownedOrAuthorized !== true) {
|
|
844
|
+
return {
|
|
845
|
+
report: failureReport({
|
|
846
|
+
state: 'invalid', target, observedAt, resolver, anchor: null, session: null, kind: 'input', stage: 'authorization',
|
|
847
|
+
detail: 'DNSSEC chain validation requires explicit owned-or-authorised scope for every run.',
|
|
848
|
+
}),
|
|
849
|
+
zone: null,
|
|
850
|
+
keys: [],
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
if (input.observedAt !== undefined && !suppliedObservedAt) {
|
|
854
|
+
return {
|
|
855
|
+
report: failureReport({
|
|
856
|
+
state: 'invalid', target, observedAt, resolver, anchor: null, session: null, kind: 'input', stage: 'observed_at',
|
|
857
|
+
detail: 'DNSSEC validation observation time is invalid.',
|
|
858
|
+
}),
|
|
859
|
+
zone: null,
|
|
860
|
+
keys: [],
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
let anchor = null;
|
|
864
|
+
try {
|
|
865
|
+
anchor = parseDnssecTrustAnchor(input.trustAnchor);
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
return { report: failureReport({ state: 'invalid', target, observedAt, resolver, anchor: null, session: null, kind: 'input', stage: 'trust_anchor', detail: boundedError(error) }), zone: null, keys: [] };
|
|
869
|
+
}
|
|
870
|
+
if (!target || !resolver || !targetWithinAnchor(target, anchor.zone)) {
|
|
871
|
+
return {
|
|
872
|
+
report: failureReport({
|
|
873
|
+
state: 'invalid', target, observedAt, resolver, anchor, session: null, kind: 'input', stage: 'target',
|
|
874
|
+
detail: !target ? 'DNSSEC validation target is invalid.' : !resolver ? 'DNSSEC resolver must be one public IP address.' : 'DNSSEC target is outside the supplied trust-anchor zone.',
|
|
875
|
+
}),
|
|
876
|
+
zone: null,
|
|
877
|
+
keys: [],
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const zones = zonesBelowAnchor(target, anchor.zone);
|
|
881
|
+
if (zones.length > MAX_DNSSEC_DELEGATIONS) {
|
|
882
|
+
return { report: failureReport({ state: 'unsupported', target, observedAt, resolver, anchor, session: null, kind: 'unsupported', stage: 'delegation_bound', detail: `DNSSEC validation is limited to ${MAX_DNSSEC_DELEGATIONS} delegations below the trust anchor.` }), zone: null, keys: [] };
|
|
883
|
+
}
|
|
884
|
+
if (input.session && (input.session.resolver.address !== resolver.address || input.session.resolver.port !== resolver.port)) {
|
|
885
|
+
return {
|
|
886
|
+
report: failureReport({
|
|
887
|
+
state: 'invalid', target, observedAt, resolver, anchor, session: null, kind: 'input', stage: 'resolver',
|
|
888
|
+
detail: 'The supplied DNSSEC query session does not use the analyst-selected resolver.',
|
|
889
|
+
}),
|
|
890
|
+
zone: null,
|
|
891
|
+
keys: [],
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
const session = input.session ?? new DnssecQuerySession({ resolver, ...input.sessionOptions });
|
|
895
|
+
const delegations = [];
|
|
896
|
+
let currentZone = anchor.zone;
|
|
897
|
+
let currentKeys = [];
|
|
898
|
+
try {
|
|
899
|
+
const rootKeysResponse = await session.query(anchor.zone, DNS_TYPE_DNSKEY);
|
|
900
|
+
const rootKeys = recordsFor(rootKeysResponse, anchor.zone, DNS_TYPE_DNSKEY);
|
|
901
|
+
const matchedRootKeys = rootKeys.filter((key) => anchor.dsRecords.some((ds) => dsMatchesKey(anchor.zone, ds, key)));
|
|
902
|
+
const rootVerification = verifyRrset(rootKeysResponse, anchor.zone, DNS_TYPE_DNSKEY, matchedRootKeys, anchor.zone, now);
|
|
903
|
+
if (!matchedRootKeys.length || rootVerification.state !== 'valid') {
|
|
904
|
+
const verification = matchedRootKeys.length ? rootVerification : { state: 'bogus', detail: 'No supplied trust-anchor DS record matched the anchor-zone DNSKEY RRset.' };
|
|
905
|
+
return {
|
|
906
|
+
report: failureReport({ state: stateFromVerification(verification.state), target, observedAt, resolver, anchor, session, delegations, validatedZone: null, kind: kindFromVerification(verification.state), stage: 'trust_anchor', detail: verification.detail }),
|
|
907
|
+
zone: null,
|
|
908
|
+
keys: [],
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
currentKeys = rootKeys;
|
|
912
|
+
delegations.push(Object.freeze({
|
|
913
|
+
zone: anchor.zone,
|
|
914
|
+
parentZone: null,
|
|
915
|
+
state: 'secure',
|
|
916
|
+
dsRecordCount: anchor.dsRecords.length,
|
|
917
|
+
dnskeyRecordCount: rootKeys.length,
|
|
918
|
+
matchedDsCount: matchedRootKeys.length,
|
|
919
|
+
signatureAlgorithm: rootVerification.algorithm,
|
|
920
|
+
detail: 'The supplied trust anchor matched and authenticated the anchor-zone DNSKEY RRset.',
|
|
921
|
+
}));
|
|
922
|
+
for (const zone of zones) {
|
|
923
|
+
const dsResponse = await session.query(zone, DNS_TYPE_DS);
|
|
924
|
+
const dsRecords = recordsFor(dsResponse, zone, DNS_TYPE_DS).filter((record) => record.data.kind === 'DS');
|
|
925
|
+
if (!dsRecords.length) {
|
|
926
|
+
const delegationProof = validateNegativeProof(dsResponse, zone, DNS_TYPE_DS, currentKeys, currentZone, now, true);
|
|
927
|
+
if (delegationProof.state === 'valid') {
|
|
928
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: 'insecure', dsRecordCount: 0, dnskeyRecordCount: 0, matchedDsCount: 0, signatureAlgorithm: null, detail: delegationProof.detail }));
|
|
929
|
+
const report = Object.freeze({
|
|
930
|
+
schema: DNSSEC_CHAIN_SCHEMA,
|
|
931
|
+
version: DNSSEC_CHAIN_VERSION,
|
|
932
|
+
state: 'insecure',
|
|
933
|
+
target,
|
|
934
|
+
observedAt,
|
|
935
|
+
resolver: Object.freeze({ address: resolver.address, port: resolver.port }),
|
|
936
|
+
trustAnchor: Object.freeze({ zone: anchor.zone, source: anchor.source, reviewedAt: anchor.reviewedAt, dsRecordCount: anchor.dsRecords.length }),
|
|
937
|
+
validatedZone: currentZone,
|
|
938
|
+
delegations: Object.freeze(delegations),
|
|
939
|
+
completeness: 'complete',
|
|
940
|
+
transport: transportSnapshot(session, 'complete'),
|
|
941
|
+
failure: null,
|
|
942
|
+
limitations: chainLimitations(),
|
|
943
|
+
});
|
|
944
|
+
return { report, zone: currentZone, keys: currentKeys };
|
|
945
|
+
}
|
|
946
|
+
const absenceProof = validateNegativeProof(dsResponse, zone, DNS_TYPE_DS, currentKeys, currentZone, now, false);
|
|
947
|
+
if (absenceProof.state === 'valid')
|
|
948
|
+
continue;
|
|
949
|
+
const proof = [delegationProof, absenceProof].find((candidate) => candidate.state === 'unsupported')
|
|
950
|
+
?? [delegationProof, absenceProof].find((candidate) => candidate.state === 'bogus')
|
|
951
|
+
?? absenceProof;
|
|
952
|
+
if (proof.state === 'valid')
|
|
953
|
+
continue;
|
|
954
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: proof.state, dsRecordCount: 0, dnskeyRecordCount: 0, matchedDsCount: 0, signatureAlgorithm: null, detail: proof.detail }));
|
|
955
|
+
return { report: failureReport({ state: stateFromVerification(proof.state), target, observedAt, resolver, anchor, session, delegations, validatedZone: currentZone, kind: kindFromVerification(proof.state), stage: `delegation:${zone}`, detail: proof.detail }), zone: currentZone, keys: currentKeys };
|
|
956
|
+
}
|
|
957
|
+
const dsVerification = verifyRrset(dsResponse, zone, DNS_TYPE_DS, currentKeys, currentZone, now);
|
|
958
|
+
if (dsVerification.state !== 'valid') {
|
|
959
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: dsVerification.state, dsRecordCount: dsRecords.length, dnskeyRecordCount: 0, matchedDsCount: 0, signatureAlgorithm: dsVerification.algorithm, detail: dsVerification.detail }));
|
|
960
|
+
return { report: failureReport({ state: stateFromVerification(dsVerification.state), target, observedAt, resolver, anchor, session, delegations, validatedZone: currentZone, kind: kindFromVerification(dsVerification.state), stage: `ds:${zone}`, detail: dsVerification.detail }), zone: currentZone, keys: currentKeys };
|
|
961
|
+
}
|
|
962
|
+
const keyResponse = await session.query(zone, DNS_TYPE_DNSKEY);
|
|
963
|
+
const zoneKeys = recordsFor(keyResponse, zone, DNS_TYPE_DNSKEY);
|
|
964
|
+
const supportedDs = preferredSupportedDs(dsRecords);
|
|
965
|
+
const matchedKeys = zoneKeys.filter((key) => supportedDs.some((ds) => dsMatchesKey(zone, ds.data, key)));
|
|
966
|
+
if (!supportedDs.length) {
|
|
967
|
+
const detail = 'The authenticated DS RRset used only digest algorithms unsupported by this validator.';
|
|
968
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: 'unsupported', dsRecordCount: dsRecords.length, dnskeyRecordCount: zoneKeys.length, matchedDsCount: 0, signatureAlgorithm: dsVerification.algorithm, detail }));
|
|
969
|
+
return { report: failureReport({ state: 'unsupported', target, observedAt, resolver, anchor, session, delegations, validatedZone: currentZone, kind: 'unsupported', stage: `dnskey:${zone}`, detail }), zone: currentZone, keys: currentKeys };
|
|
970
|
+
}
|
|
971
|
+
if (!matchedKeys.length) {
|
|
972
|
+
const detail = 'No authenticated child DNSKEY matched the parent DS RRset.';
|
|
973
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: 'bogus', dsRecordCount: dsRecords.length, dnskeyRecordCount: zoneKeys.length, matchedDsCount: 0, signatureAlgorithm: dsVerification.algorithm, detail }));
|
|
974
|
+
return { report: failureReport({ state: 'bogus', target, observedAt, resolver, anchor, session, delegations, validatedZone: currentZone, kind: 'validation', stage: `dnskey:${zone}`, detail }), zone: currentZone, keys: currentKeys };
|
|
975
|
+
}
|
|
976
|
+
const keyVerification = verifyRrset(keyResponse, zone, DNS_TYPE_DNSKEY, matchedKeys, zone, now);
|
|
977
|
+
if (keyVerification.state !== 'valid') {
|
|
978
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: keyVerification.state, dsRecordCount: dsRecords.length, dnskeyRecordCount: zoneKeys.length, matchedDsCount: matchedKeys.length, signatureAlgorithm: keyVerification.algorithm, detail: keyVerification.detail }));
|
|
979
|
+
return { report: failureReport({ state: stateFromVerification(keyVerification.state), target, observedAt, resolver, anchor, session, delegations, validatedZone: currentZone, kind: kindFromVerification(keyVerification.state), stage: `dnskey:${zone}`, detail: keyVerification.detail }), zone: currentZone, keys: currentKeys };
|
|
980
|
+
}
|
|
981
|
+
delegations.push(Object.freeze({ zone, parentZone: currentZone, state: 'secure', dsRecordCount: dsRecords.length, dnskeyRecordCount: zoneKeys.length, matchedDsCount: matchedKeys.length, signatureAlgorithm: keyVerification.algorithm, detail: 'The parent-signed DS RRset matched and authenticated the child DNSKEY RRset.' }));
|
|
982
|
+
currentZone = zone;
|
|
983
|
+
currentKeys = zoneKeys;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code || '') : '';
|
|
988
|
+
const timedOut = code === 'ETIMEOUT';
|
|
989
|
+
return {
|
|
990
|
+
report: failureReport({ state: timedOut ? 'timed_out' : 'indeterminate', target, observedAt, resolver, anchor, session, delegations, validatedZone: currentZone, kind: 'transport', stage: 'dns_query', detail: boundedError(error) }),
|
|
991
|
+
zone: currentZone,
|
|
992
|
+
keys: currentKeys,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
const report = Object.freeze({
|
|
996
|
+
schema: DNSSEC_CHAIN_SCHEMA,
|
|
997
|
+
version: DNSSEC_CHAIN_VERSION,
|
|
998
|
+
state: 'secure',
|
|
999
|
+
target,
|
|
1000
|
+
observedAt,
|
|
1001
|
+
resolver: Object.freeze({ address: resolver.address, port: resolver.port }),
|
|
1002
|
+
trustAnchor: Object.freeze({ zone: anchor.zone, source: anchor.source, reviewedAt: anchor.reviewedAt, dsRecordCount: anchor.dsRecords.length }),
|
|
1003
|
+
validatedZone: currentZone,
|
|
1004
|
+
delegations: Object.freeze(delegations),
|
|
1005
|
+
completeness: 'complete',
|
|
1006
|
+
transport: transportSnapshot(session, 'complete'),
|
|
1007
|
+
failure: null,
|
|
1008
|
+
limitations: chainLimitations(),
|
|
1009
|
+
});
|
|
1010
|
+
return { report, zone: currentZone, keys: currentKeys };
|
|
1011
|
+
}
|
|
1012
|
+
async function validateDnssecChain(input) {
|
|
1013
|
+
return (await validateDnssecChainWithContext(input)).report;
|
|
1014
|
+
}
|
|
1015
|
+
function formatDnssecChainReport(report) {
|
|
1016
|
+
const lines = [
|
|
1017
|
+
'DNSSEC chain validation',
|
|
1018
|
+
`Target ${report.target ?? 'invalid'}`,
|
|
1019
|
+
`State ${report.state.replaceAll('_', ' ')}`,
|
|
1020
|
+
`Completeness ${report.completeness}`,
|
|
1021
|
+
`Resolver ${report.resolver.address ?? 'unavailable'}:${report.resolver.port}`,
|
|
1022
|
+
`Trust anchor ${report.trustAnchor.zone ?? 'unavailable'} (${report.trustAnchor.source ?? 'source unavailable'})`,
|
|
1023
|
+
`Validated ${report.validatedZone ?? 'none'}`,
|
|
1024
|
+
`Queries ${report.transport.queryCount}/${report.transport.queryLimit}`,
|
|
1025
|
+
`DNS bytes ${report.transport.responseBytes}/${report.transport.responseByteLimit}`,
|
|
1026
|
+
];
|
|
1027
|
+
if (report.delegations.length) {
|
|
1028
|
+
lines.push('', 'Delegations:');
|
|
1029
|
+
for (const delegation of report.delegations) {
|
|
1030
|
+
lines.push(` - ${delegation.zone}: ${delegation.state} (${delegation.matchedDsCount} matched DS)`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (report.failure)
|
|
1034
|
+
lines.push('', `Failure ${report.failure.kind}/${report.failure.stage}: ${report.failure.detail}`);
|
|
1035
|
+
lines.push('', 'Limitations:');
|
|
1036
|
+
for (const limitation of report.limitations)
|
|
1037
|
+
lines.push(` - ${limitation}`);
|
|
1038
|
+
return `${lines.join('\n')}\n`;
|
|
1039
|
+
}
|
|
1040
|
+
async function collectValidatedRrset(session, context, ownerValue, type, observedAt) {
|
|
1041
|
+
const owner = normalizeDnsName(ownerValue, { allowServiceLabels: true });
|
|
1042
|
+
if (!owner || context.report.state !== 'secure' || !context.zone || !context.keys.length) {
|
|
1043
|
+
throw new DnssecWireError('A secure DNSSEC context and valid RRset owner are required.', 'EINVAL');
|
|
1044
|
+
}
|
|
1045
|
+
const response = await session.query(owner, type);
|
|
1046
|
+
const records = recordsFor(response, owner, type);
|
|
1047
|
+
const now = new Date(normalizedTimestamp(observedAt) ?? context.report.observedAt);
|
|
1048
|
+
const verification = records.length
|
|
1049
|
+
? verifyRrset(response, owner, type, context.keys, context.zone, now)
|
|
1050
|
+
: validateNegativeProof(response, owner, type, context.keys, context.zone, now, false);
|
|
1051
|
+
return Object.freeze({ response, records: Object.freeze(records), verification });
|
|
1052
|
+
}
|
|
1053
|
+
export { DNSSEC_CHAIN_SCHEMA, DNSSEC_CHAIN_VERSION, DNSSEC_QUERY_TIMEOUT_MS, DNSSEC_TOTAL_TIMEOUT_MS, DNSSEC_TRUST_ANCHOR_SCHEMA, DNSSEC_TRUST_ANCHOR_VERSION, MAX_DNSSEC_TRUST_ANCHOR_BYTES, DNS_TYPE_A, DNS_TYPE_AAAA, DNS_TYPE_CNAME, DNS_TYPE_DNSKEY, DNS_TYPE_DS, DNS_TYPE_NS, DNS_TYPE_NSEC, DNS_TYPE_NSEC3, DNS_TYPE_RRSIG, DNS_TYPE_TLSA, MAX_DNSSEC_DELEGATIONS, MAX_DNSSEC_QUERIES, MAX_DNSSEC_RESPONSE_BYTES, DnssecQuerySession, DnssecWireError, buildDnssecQuery, collectValidatedRrset, dnskeyTag, formatDnssecChainReport, normalizeDnsName, normalizeResolverEndpoint, parseDnssecResponse, parseDnssecTrustAnchor, signedRrsetData, validateDnssecChain, validateDnssecChainWithContext, verifyRrset, };
|