@blamejs/core 0.12.47 → 0.12.49

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/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.49 (2026-05-25) — **`b.network.dns.dnssec.verifyDenial` — NSEC / NSEC3 denial-of-existence.** Prove a DNS name does not exist, or has no records of a given type, from the signed NSEC (RFC 4034 §4) or NSEC3 (RFC 5155) records a server returns. This is the other half of local DNSSEC verification: verifyRrset proves a positive answer, verifyDenial proves a negative — so a resolver client can confirm an NXDOMAIN / NODATA itself instead of trusting the upstream resolver. NSEC3 proofs run the closest-encloser / next-closer / covering-range logic over iterated-SHA-1 hashes, with the iteration count capped (default 500) to bound the work an attacker can force, and an Opt-Out NXDOMAIN refused unless explicitly accepted (opt-out only proves 'no signed records', not non-existence). The companion b.network.dns.dnssec.nsec3Hash computes the RFC 5155 §5 hash directly. NSEC verifyRrset support is also enabled: per RFC 6840 §5.1 the NSEC Next Domain Name is not downcased, so its RDATA is verbatim-canonical. **Added:** *`b.network.dns.dnssec.verifyDenial(opts)`* — Proves NXDOMAIN or NODATA from already-verified NSEC / NSEC3 records (supply one of `opts.nsec3` or `opts.nsec`). Like `verifyDs`, it checks the denial RELATION — closest-encloser matching, covering ranges, and type-bitmap absence — not the record signatures, which the caller verifies with `verifyRrset` first. NSEC3 supports name-error proofs (matching closest encloser + covered next-closer + covered wildcard), NODATA (matching record with the type and CNAME absent from the bitmap), Opt-Out DS NODATA, and wildcard NODATA. The iterated-SHA-1 count is capped by `opts.maxIterations` (default 500); an NXDOMAIN proof that depends on an Opt-Out NSEC3 is refused unless `opts.allowOptOut` is set. NSEC supports covering-name NXDOMAIN (with the source-of-synthesis wildcard) and matching-name NODATA. Verified end-to-end against a live iana.org NXDOMAIN proof. · *`b.network.dns.dnssec.nsec3Hash(name, opts)`* — Computes the RFC 5155 §5 NSEC3 hash of a name — iterated SHA-1 over the canonical (lowercased, root-terminated) wire form with the zone salt. The base32hex encoding of the result is the NSEC3 owner label. SHA-1 is the only hash IANA registers for NSEC3, so this is a wire-protocol constant rather than a cryptographic default. Useful for checking an owner label or analyzing a zone's hashing parameters. **Changed:** *`verifyRrset` now accepts NSEC and NSEC3 RRsets* — NSEC (type 47) and NSEC3 (type 50) are no longer refused as uncanonicalizable: NSEC3's next-owner is a hash, and per RFC 6840 §5.1 the NSEC Next Domain Name field is not downcased for DNSSEC canonical form, so both RDATAs are verbatim-canonical. This lets a caller verify the signatures on the records that `verifyDenial` then reasons over.
12
+
13
+ - v0.12.48 (2026-05-25) — **`b.network.dns.dnssec` — local DNSSEC signature verification (RFC 4035).** Verify a DNS answer's RRSIG signature yourself instead of trusting the upstream resolver's AD bit. b.network.dns.dnssec.verifyRrset reconstructs the RFC 4034 §3.1.8.1 signed data — the RRSIG RDATA without the signature, followed by the RRset in canonical form (owner names lowercased, RRs ordered by canonical RDATA, the RRSIG's Original TTL) — and checks the signature against the DNSKEY, enforcing the inception / expiration window. Supports RSA/SHA-256 (alg 8), ECDSA P-256/SHA-256 (13), ECDSA P-384/SHA-384 (14), and Ed25519 (15) — the modern deployed set. verifyDs checks a delegation-signer digest against a DNSKEY (SHA-256 / SHA-384) and keyTag computes the RFC 4034 Appendix B key tag. The verification core is what a chain-walker composes; it defends against a compromised or on-path resolver that lies about authentication. **Added:** *`b.network.dns.dnssec.verifyRrset(opts)`* — Verifies an RRSIG over a canonicalised RRset against a DNSKEY. `opts` carries the owner `name`, the RR `type`, the wire-format `rdatas`, the parsed `rrsig` (algorithm / labels / originalTtl / inception / expiration / keyTag / signerName / signature), and the `dnskey` (algorithm + raw public key). The signed data is rebuilt per RFC 4034 §3.1.8.1: the RRSIG prefix (type covered | algorithm | labels | original TTL | expiration | inception | key tag | canonical signer name) followed by each RR in canonical form (lowercased owner | type | class | original TTL | rdlen | rdata), sorted by `Buffer.compare` on the RDATA. The validity window is enforced against `opts.at` (defaults to now; an invalid Date is refused, not treated as now). An RRSIG whose algorithm disagrees with the DNSKEY is refused before any key is built. RR types that embed domain names in their RDATA (NS, CNAME, SOA, MX, SRV, …) need RDATA-internal name-lowercasing this version does not perform, so they are refused with `dnssec/uncanonicalizable-type` rather than mis-validated; the security-critical DNSKEY / DS and the name-free address / text types (A, AAAA, TXT, CAA, TLSA, …) are fully supported. · *`b.network.dns.dnssec.verifyDs(opts)` / `b.network.dns.dnssec.keyTag(dnskeyRdata)`* — `verifyDs` confirms a delegation-signer record matches a DNSKEY: it checks the key tag, then compares the DS digest (SHA-256 type 2 / SHA-384 type 4) against the digest computed over the canonical owner name and the DNSKEY RDATA, constant-time. `keyTag` computes the RFC 4034 Appendix B 16-bit key tag from a DNSKEY's full RDATA — the identifier an RRSIG or DS uses to select the signing key. Together with `verifyRrset` these are the per-RRset building blocks a recursive chain-walk (root → TLD → zone) composes; the chain-walk itself, NSEC / NSEC3 denial-of-existence, and the bundled IANA root trust anchor are not part of this core.
14
+
11
15
  - v0.12.47 (2026-05-25) — **`b.cose.mac0` / `b.cose.macVerify0` — COSE_Mac0 (RFC 9052 §6.2).** Completes the COSE message-type set (COSE_Sign1 / COSE_Encrypt0 / COSE_Mac0) with single shared-key MACs. b.cose.mac0 produces a tagged COSE_Mac0 over a payload using HMAC-SHA-256/384/512 (the COSE-standard MAC algorithms; HMAC is symmetric, so its post-quantum strength is preserved). b.cose.macVerify0 recomputes the tag over the MAC_structure and compares it in constant time, with a mandatory algorithm allowlist. Use when both parties hold a shared key — e.g. an ECDH-derived key — and a non-repudiable signature is not wanted; detached payloads are supported (the proximity mdoc device-MAC variant and MACed CWTs are the consumers). Composes b.cbor + the framework's constant-time compare; no new runtime dependency. **Added:** *`b.cose.mac0(payload, opts)` / `b.cose.macVerify0(coseMac0, opts)`* — `mac0` emits a tagged COSE_Mac0 (tag 17) with `alg` (`HMAC-256/256` | `HMAC-384/384` | `HMAC-512/512`) in the protected header and the HMAC tag computed over the MAC_structure `["MAC0", protected, external_aad, payload]`; `detached: true` emits a nil payload. `macVerify0` reads the algorithm from the protected header (must be in the required `opts.algorithms` allowlist), recomputes the tag, and compares it constant-time — a wrong key, tampered tag, or `external_aad` mismatch is refused with `cose/bad-tag`; a detached payload is supplied via `opts.externalPayload`. `external_aad` binds context into the tag.
12
16
 
13
17
  - v0.12.46 (2026-05-25) — **`b.mdoc.verifyDeviceAuth` — ISO 18013-5 mdoc device authentication.** Completes mdoc verification with the holder-binding half (ISO 18013-5 §9.1.3, signature variant). verifyIssuerSigned proves the data is issuer-signed; verifyDeviceAuth proves the presenter controls the device key the issuer bound into the MSO, so a captured issuer-signed document cannot be replayed by anyone else. The device's COSE_Sign1 (deviceSigned.deviceAuth.deviceSignature) is verified over the detached DeviceAuthentication structure ["DeviceAuthentication", SessionTranscript, DocType, DeviceNameSpacesBytes] using the device key from verifyIssuerSigned().deviceKey (now surfaced) and the operator-supplied SessionTranscript that binds the proof to this exact exchange (the presentation protocol — e.g. OpenID4VP — defines the transcript). Composes the v0.12.45 b.cose detached-payload verify + importKey. The MAC variant (deviceMac / COSE_Mac0, used in proximity flows with a reader ephemeral key) is deferred and refused with mdoc/device-mac-unsupported. No new runtime dependency. **Added:** *`b.mdoc.verifyDeviceAuth(opts)` + `deviceKey` on the verifyIssuerSigned result* — `verifyDeviceAuth({ deviceKey, deviceSigned, docType, sessionTranscript, algorithms })` imports the device key (a COSE_Key via `b.cose.importKey`, or a KeyObject), reconstructs the detached `DeviceAuthentication` payload, and verifies the `deviceSignature` COSE_Sign1 against the mandatory algorithm allowlist — a mismatched `sessionTranscript` or `docType` fails the signature. `verifyIssuerSigned` now returns `deviceKey` (the MSO `deviceKeyInfo.deviceKey`) so the two checks chain. The MAC variant (`deviceMac`) is refused with `mdoc/device-mac-unsupported` pending COSE_Mac0 + reader-key support.
package/README.md CHANGED
@@ -120,7 +120,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
120
120
  - In-process CIDR fence (`b.middleware.networkAllowlist`)
121
121
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
122
122
  - **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
123
- - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
123
+ - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
124
124
  - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
125
125
  ### Defensive parsers
126
126
 
@@ -0,0 +1,732 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.network.dns.dnssec
4
+ * @nav Network
5
+ * @title DNSSEC validation
6
+ *
7
+ * @intro
8
+ * Local DNSSEC signature verification (RFC 4033–4035 / 6605 / 8080) —
9
+ * the cryptographic core that lets a resolver client verify a DNS
10
+ * answer itself instead of trusting the upstream resolver's AD bit.
11
+ * <code>b.network.dns.resolver</code> checks the AD flag; this module
12
+ * verifies the actual RRSIG signature over the canonicalised RRset,
13
+ * defending against a compromised or on-path resolver.
14
+ *
15
+ * <code>verifyRrset</code> reconstructs the RFC 4034 §3.1.8.1 signed
16
+ * data (the RRSIG RDATA without the signature, followed by the RRset
17
+ * in canonical form — owner names lowercased, RRs ordered by canonical
18
+ * RDATA, the RRSIG's Original TTL) and verifies it with the DNSKEY,
19
+ * enforcing the signature's inception / expiration window. The DNSKEY
20
+ * algorithms are RSA/SHA-256 (8), ECDSA P-256/SHA-256 (13), ECDSA
21
+ * P-384/SHA-384 (14), and Ed25519 (15) — the modern, deployed set.
22
+ * <code>verifyDs</code> checks a delegation-signer digest against a
23
+ * DNSKEY (SHA-256 / SHA-384), and <code>keyTag</code> computes the
24
+ * RFC 4034 Appendix B key tag.
25
+ *
26
+ * <strong>Scope.</strong> This is the verification core. RR types that
27
+ * carry domain names in their RDATA (NS, CNAME, SOA, MX, SRV, …) need
28
+ * name-lowercasing inside the RDATA (RFC 4034 §6.2) that this version
29
+ * does not perform, so they are refused with
30
+ * <code>dnssec/uncanonicalizable-type</code> rather than mis-validated
31
+ * — the security-critical DNSKEY / DS and the name-free address /
32
+ * text types (A, AAAA, TXT, …) are fully supported. The recursive
33
+ * chain-walk (root → TLD → zone), NSEC / NSEC3 denial-of-existence,
34
+ * and the IANA root trust-anchor bundle are deferred: these primitives
35
+ * are the per-RRset building blocks a chain-walker composes.
36
+ *
37
+ * @card
38
+ * Local DNSSEC verification (RFC 4035) — verify an RRSIG over a
39
+ * canonicalised RRset against a DNSKEY (RSA / ECDSA P-256·P-384 /
40
+ * Ed25519), plus DS-digest + key-tag. Don't trust the upstream AD bit;
41
+ * verify the signature. Name-bearing RR types are refused, not
42
+ * mis-validated; chain-walk + NSEC3 deferred.
43
+ */
44
+
45
+ var nodeCrypto = require("node:crypto");
46
+ var bCrypto = require("./crypto");
47
+ var validateOpts = require("./validate-opts");
48
+ var { defineClass } = require("./framework-error");
49
+
50
+ var DnssecError = defineClass("DnssecError", { alwaysPermanent: true });
51
+
52
+ // DNSSEC algorithm numbers (IANA DNSSEC Algorithm Numbers) → verify params.
53
+ var ALGS = {
54
+ 8: { name: "RSASHA256", kind: "rsa", hash: "sha256" }, // allow:raw-byte-literal — IANA DNSSEC algorithm number
55
+ 13: { name: "ECDSAP256SHA256", kind: "ec", hash: "sha256", crv: "P-256", coord: 32 }, // allow:raw-byte-literal — P-256 coordinate size
56
+ 14: { name: "ECDSAP384SHA384", kind: "ec", hash: "sha384", crv: "P-384", coord: 48 }, // allow:raw-byte-literal — P-384 coordinate size
57
+ 15: { name: "ED25519", kind: "okp", hash: null, crv: "Ed25519" },
58
+ };
59
+
60
+ // DS digest algorithms (IANA) → node hash.
61
+ var DS_DIGESTS = { 2: "sha256", 4: "sha384" };
62
+
63
+ // RR types whose RDATA contains NO embedded domain name that needs
64
+ // downcasing, so the wire RDATA is already in canonical form (RFC 4034
65
+ // §6.2 needs no rewrite). Name-bearing types are refused rather than
66
+ // silently mis-canonicalised. NSEC (47) is included because RFC 6840
67
+ // §5.1 corrected RFC 4034 §6.2: the NSEC Next Domain Name field is NOT
68
+ // downcased for DNSSEC canonical form, so its uncompressed RDATA is
69
+ // verbatim-canonical. NSEC3 (50) carries a hashed next-owner, not a name.
70
+ // (type numbers IANA): A 1, AAAA 28, TXT 16, DNSKEY 48, DS 43, CAA 257,
71
+ // TLSA 52, SSHFP 44, HINFO 13, CDS 59, CDNSKEY 60, OPENPGPKEY 61, SMIMEA
72
+ // 53, NSEC 47, NSEC3 50.
73
+ var NAME_FREE_TYPE_NUMS = [1, 28, 16, 48, 43, 257, 52, 44, 13, 59, 60, 61, 53, 47, 50]; // allow:raw-byte-literal allow:raw-time-literal — IANA DNS type numbers (no downcased embedded names)
74
+ var TYPE_NUM = {
75
+ A: 1, NS: 2, CNAME: 5, SOA: 6, PTR: 12, MX: 15, TXT: 16, AAAA: 28, SRV: 33,
76
+ DS: 43, SSHFP: 44, RRSIG: 46, NSEC: 47, DNSKEY: 48, NSEC3: 50, TLSA: 52, // allow:raw-byte-literal allow:raw-time-literal — IANA DNS type numbers
77
+ SMIMEA: 53, CDS: 59, CDNSKEY: 60, OPENPGPKEY: 61, CAA: 257, HINFO: 13,
78
+ };
79
+
80
+ function _bytes(x, what) {
81
+ if (Buffer.isBuffer(x)) return x;
82
+ if (x instanceof Uint8Array) return Buffer.from(x);
83
+ throw new DnssecError("dnssec/bad-bytes", "dnssec: " + what + " must be a Buffer");
84
+ }
85
+
86
+ // Canonical wire form of a domain name (RFC 4034 §6.2): each label
87
+ // length-prefixed, ASCII lowercased, terminated by the root label.
88
+ function _canonicalName(name) {
89
+ if (typeof name !== "string") throw new DnssecError("dnssec/bad-name", "dnssec: name must be a string");
90
+ var n = name.replace(/\.$/, "");
91
+ if (n === "") return Buffer.from([0]);
92
+ var labels = n.split(".");
93
+ var parts = [];
94
+ for (var i = 0; i < labels.length; i++) {
95
+ var lab = Buffer.from(labels[i].toLowerCase(), "ascii");
96
+ if (lab.length === 0 || lab.length > 63) { // allow:raw-byte-literal — DNS label length cap (RFC 1035)
97
+ throw new DnssecError("dnssec/bad-name", "dnssec: invalid label in '" + name + "'");
98
+ }
99
+ parts.push(Buffer.from([lab.length]), lab);
100
+ }
101
+ parts.push(Buffer.from([0]));
102
+ return Buffer.concat(parts);
103
+ }
104
+
105
+ function _u16(n) { return Buffer.from([(n >> 8) & 0xff, n & 0xff]); } // allow:raw-byte-literal — 16-bit big-endian split
106
+ function _u32(n) {
107
+ var b = Buffer.alloc(4);
108
+ b.writeUInt32BE(n >>> 0, 0);
109
+ return b;
110
+ }
111
+ function _typeNumber(type) {
112
+ if (typeof type === "number") return type;
113
+ var t = TYPE_NUM[String(type).toUpperCase()];
114
+ if (t === undefined) throw new DnssecError("dnssec/unknown-type", "dnssec: unknown RR type '" + type + "'");
115
+ return t;
116
+ }
117
+
118
+ // DNSKEY public-key RDATA → JWK (kty/crv allowlisted; RFC 3110 RSA,
119
+ // RFC 6605 ECDSA, RFC 8080 Ed25519). publicKey is the key bytes after
120
+ // the DNSKEY flags/protocol/algorithm fields.
121
+ function _dnskeyToKey(algId, publicKey) {
122
+ var alg = ALGS[algId];
123
+ if (!alg) throw new DnssecError("dnssec/unsupported-alg", "dnssec: unsupported DNSKEY algorithm " + algId);
124
+ var pk = _bytes(publicKey, "dnskey publicKey");
125
+ if (alg.kind === "rsa") {
126
+ // RFC 3110: exponent length is 1 byte, or (if that byte is 0) the
127
+ // next 2 bytes; then exponent, then modulus.
128
+ var off = 0, explen = pk[0];
129
+ off = 1;
130
+ if (explen === 0) { explen = (pk[1] << 8) | pk[2]; off = 3; } // allow:raw-byte-literal — RFC 3110 3-byte exponent length
131
+ if (explen === 0 || off + explen >= pk.length) {
132
+ throw new DnssecError("dnssec/bad-key", "dnssec: malformed RSA DNSKEY public key");
133
+ }
134
+ var exponent = pk.slice(off, off + explen);
135
+ var modulus = pk.slice(off + explen);
136
+ return _jwkKey({ kty: "RSA", n: modulus.toString("base64url"), e: exponent.toString("base64url") });
137
+ }
138
+ if (alg.kind === "ec") {
139
+ if (pk.length !== alg.coord * 2) {
140
+ throw new DnssecError("dnssec/bad-key", "dnssec: " + alg.crv + " key must be " + (alg.coord * 2) + " bytes (x||y)");
141
+ }
142
+ return _jwkKey({ kty: "EC", crv: alg.crv, x: pk.slice(0, alg.coord).toString("base64url"), y: pk.slice(alg.coord).toString("base64url") });
143
+ }
144
+ // Ed25519
145
+ if (pk.length !== 32) throw new DnssecError("dnssec/bad-key", "dnssec: Ed25519 key must be 32 bytes"); // allow:raw-byte-literal — Ed25519 key size
146
+ return _jwkKey({ kty: "OKP", crv: "Ed25519", x: pk.toString("base64url") });
147
+ }
148
+ function _jwkKey(jwk) {
149
+ try { return nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }
150
+ catch (e) { throw new DnssecError("dnssec/bad-key", "dnssec: could not import DNSKEY: " + ((e && e.message) || e)); }
151
+ }
152
+
153
+ /**
154
+ * @primitive b.network.dns.dnssec.keyTag
155
+ * @signature b.network.dns.dnssec.keyTag(dnskeyRdata)
156
+ * @since 0.12.48
157
+ * @status stable
158
+ * @related b.network.dns.dnssec.verifyDs, b.network.dns.dnssec.verifyRrset
159
+ *
160
+ * Compute the RFC 4034 Appendix B key tag of a DNSKEY from its full
161
+ * RDATA (flags || protocol || algorithm || public key) — the 16-bit
162
+ * identifier an RRSIG / DS references to select the signing key.
163
+ *
164
+ * @example
165
+ * var tag = b.network.dns.dnssec.keyTag(dnskeyRdata);
166
+ */
167
+ function keyTag(dnskeyRdata) {
168
+ var rd = _bytes(dnskeyRdata, "dnskeyRdata");
169
+ var acc = 0;
170
+ for (var i = 0; i < rd.length; i++) {
171
+ acc += (i & 1) ? rd[i] : (rd[i] << 8); // allow:raw-byte-literal — RFC 4034 App B key-tag accumulation
172
+ }
173
+ acc += (acc >> 16) & 0xffff; // allow:raw-byte-literal — App B fold
174
+ return acc & 0xffff; // allow:raw-byte-literal — App B 16-bit tag
175
+ }
176
+
177
+ /**
178
+ * @primitive b.network.dns.dnssec.verifyDs
179
+ * @signature b.network.dns.dnssec.verifyDs(opts)
180
+ * @since 0.12.48
181
+ * @status stable
182
+ * @related b.network.dns.dnssec.verifyRrset
183
+ *
184
+ * Verify a DS (Delegation Signer) record against a child DNSKEY — the
185
+ * link that lets a parent zone vouch for a child's key. The DS digest
186
+ * (SHA-256 / SHA-384) is recomputed over the owner name plus the DNSKEY
187
+ * RDATA and compared to the DS, with the key tag and algorithm checked.
188
+ *
189
+ * @opts
190
+ * {
191
+ * ownerName: string, // the child zone name (the DNSKEY owner)
192
+ * dnskeyRdata: Buffer, // full DNSKEY RDATA (flags||protocol||alg||publicKey)
193
+ * ds: { keyTag, algorithm, digestType, digest: Buffer }, // the parent DS
194
+ * }
195
+ *
196
+ * @example
197
+ * b.network.dns.dnssec.verifyDs({ ownerName: "example.com", dnskeyRdata: ksk, ds: parentDs });
198
+ */
199
+ function verifyDs(opts) {
200
+ validateOpts.requireObject(opts, "dnssec.verifyDs", DnssecError);
201
+ validateOpts(opts, ["ownerName", "dnskeyRdata", "ds"], "dnssec.verifyDs");
202
+ var ds = opts.ds;
203
+ if (!ds || typeof ds !== "object") throw new DnssecError("dnssec/bad-ds", "dnssec.verifyDs: opts.ds is required");
204
+ var hashName = DS_DIGESTS[ds.digestType];
205
+ if (!hashName) throw new DnssecError("dnssec/unsupported-digest", "dnssec.verifyDs: unsupported DS digest type " + ds.digestType);
206
+ var rd = _bytes(opts.dnskeyRdata, "dnskeyRdata");
207
+ if (keyTag(rd) !== ds.keyTag) {
208
+ throw new DnssecError("dnssec/keytag-mismatch", "dnssec.verifyDs: DNSKEY key tag does not match the DS");
209
+ }
210
+ var digestInput = Buffer.concat([_canonicalName(opts.ownerName), rd]);
211
+ var expected = nodeCrypto.createHash(hashName).update(digestInput).digest();
212
+ var actual = _bytes(ds.digest, "ds.digest");
213
+ if (!bCrypto.timingSafeEqual(expected, actual)) {
214
+ throw new DnssecError("dnssec/ds-mismatch", "dnssec.verifyDs: DS digest does not match the DNSKEY");
215
+ }
216
+ return { ok: true, keyTag: ds.keyTag, digestType: ds.digestType };
217
+ }
218
+
219
+ /**
220
+ * @primitive b.network.dns.dnssec.verifyRrset
221
+ * @signature b.network.dns.dnssec.verifyRrset(opts)
222
+ * @since 0.12.48
223
+ * @status stable
224
+ * @compliance soc2
225
+ * @related b.network.dns.dnssec.verifyDs, b.network.dns.resolver.create
226
+ *
227
+ * Verify an RRSIG over an RRset against a DNSKEY (RFC 4035 §5.3). The
228
+ * signed data is reconstructed in canonical form — the RRSIG RDATA
229
+ * without the signature, then the RRset's records ordered by canonical
230
+ * RDATA with the RRSIG Original TTL — and the signature is verified with
231
+ * the DNSKEY (RSA/SHA-256, ECDSA P-256/384, Ed25519). The signature's
232
+ * inception / expiration window is enforced against <code>opts.at</code>.
233
+ * RR types carrying embedded domain names are refused
234
+ * (<code>dnssec/uncanonicalizable-type</code>) rather than mis-validated.
235
+ *
236
+ * @opts
237
+ * {
238
+ * name: string, // owner name of the RRset
239
+ * type: string|number, // RR type (e.g. "DNSKEY", "A")
240
+ * class?: number, // default 1 (IN)
241
+ * rdatas: Buffer[], // each record's wire-format RDATA
242
+ * rrsig: { // the RRSIG covering the RRset
243
+ * algorithm, labels, originalTtl, expiration, inception, keyTag,
244
+ * signerName: string, signature: Buffer,
245
+ * },
246
+ * dnskey: { algorithm, publicKey: Buffer }, // the signing DNSKEY (publicKey = bytes after flags/proto/alg)
247
+ * at?: Date, // validity instant (default now); must be a valid Date
248
+ * }
249
+ *
250
+ * @example
251
+ * b.network.dns.dnssec.verifyRrset({ name: "example.com", type: "DNSKEY", rdatas: keys, rrsig: sig, dnskey: ksk });
252
+ */
253
+ function verifyRrset(opts) {
254
+ validateOpts.requireObject(opts, "dnssec.verifyRrset", DnssecError);
255
+ validateOpts(opts, ["name", "type", "class", "rdatas", "rrsig", "dnskey", "at"], "dnssec.verifyRrset");
256
+ var rrsig = opts.rrsig;
257
+ var dnskey = opts.dnskey;
258
+ if (!rrsig || typeof rrsig !== "object") throw new DnssecError("dnssec/bad-rrsig", "dnssec.verifyRrset: opts.rrsig is required");
259
+ if (!dnskey || typeof dnskey !== "object") throw new DnssecError("dnssec/bad-key", "dnssec.verifyRrset: opts.dnskey is required");
260
+ if (!Array.isArray(opts.rdatas) || opts.rdatas.length === 0) {
261
+ throw new DnssecError("dnssec/empty-rrset", "dnssec.verifyRrset: opts.rdatas must be a non-empty array");
262
+ }
263
+ var alg = ALGS[rrsig.algorithm];
264
+ if (!alg) throw new DnssecError("dnssec/unsupported-alg", "dnssec.verifyRrset: unsupported algorithm " + rrsig.algorithm);
265
+ if (dnskey.algorithm !== rrsig.algorithm) {
266
+ throw new DnssecError("dnssec/alg-mismatch", "dnssec.verifyRrset: DNSKEY algorithm does not match the RRSIG");
267
+ }
268
+
269
+ var typeNum = _typeNumber(opts.type);
270
+ if (NAME_FREE_TYPE_NUMS.indexOf(typeNum) === -1) {
271
+ throw new DnssecError("dnssec/uncanonicalizable-type",
272
+ "dnssec.verifyRrset: RR type " + typeNum + " carries embedded names; RDATA-name canonicalisation is not supported (refused, not mis-validated)");
273
+ }
274
+
275
+ // Validity window (fail closed on a bad opts.at).
276
+ var atMs;
277
+ if (opts.at !== undefined && opts.at !== null) {
278
+ if (!(opts.at instanceof Date) || !isFinite(opts.at.getTime())) {
279
+ throw new DnssecError("dnssec/bad-at", "dnssec.verifyRrset: opts.at must be a valid Date");
280
+ }
281
+ atMs = opts.at.getTime();
282
+ } else {
283
+ atMs = Date.now();
284
+ }
285
+ var nowSec = Math.floor(atMs / 1000); // allow:raw-time-literal — ms→NumericDate seconds (RRSIG inception/expiration are seconds since epoch, RFC 4034 §3.1.5)
286
+ if (nowSec < (rrsig.inception >>> 0)) throw new DnssecError("dnssec/not-yet-valid", "dnssec.verifyRrset: RRSIG inception is in the future");
287
+ if (nowSec > (rrsig.expiration >>> 0)) throw new DnssecError("dnssec/expired", "dnssec.verifyRrset: RRSIG has expired");
288
+
289
+ var klass = typeof opts.class === "number" ? opts.class : 1;
290
+ var ownerWire = _canonicalName(opts.name);
291
+ var ttl = _u32(rrsig.originalTtl);
292
+
293
+ // Canonical RRset (RFC 4034 §6.3): order records by canonical RDATA.
294
+ var rdatas = opts.rdatas.map(function (r, i) { return _bytes(r, "rdatas[" + i + "]"); });
295
+ var sorted = rdatas.slice().sort(Buffer.compare);
296
+ var rrParts = [];
297
+ for (var i = 0; i < sorted.length; i++) {
298
+ rrParts.push(ownerWire, _u16(typeNum), _u16(klass), ttl, _u16(sorted[i].length), sorted[i]);
299
+ }
300
+
301
+ // RRSIG RDATA without the signature (RFC 4034 §3.1.8.1).
302
+ var rrsigPrefix = Buffer.concat([
303
+ _u16(typeNum), Buffer.from([rrsig.algorithm & 0xff, rrsig.labels & 0xff]), // allow:raw-byte-literal — single-octet alg + labels fields
304
+ _u32(rrsig.originalTtl), _u32(rrsig.expiration), _u32(rrsig.inception),
305
+ _u16(rrsig.keyTag), _canonicalName(rrsig.signerName),
306
+ ]);
307
+ var signedData = Buffer.concat([rrsigPrefix].concat(rrParts));
308
+
309
+ var key = _dnskeyToKey(dnskey.algorithm, dnskey.publicKey);
310
+ var signature = _bytes(rrsig.signature, "rrsig.signature");
311
+ var ok;
312
+ try {
313
+ if (alg.kind === "okp") {
314
+ ok = nodeCrypto.verify(null, signedData, key, signature);
315
+ } else if (alg.kind === "ec") {
316
+ ok = nodeCrypto.verify(alg.hash, signedData, { key: key, dsaEncoding: "ieee-p1363" }, signature);
317
+ } else {
318
+ ok = nodeCrypto.verify(alg.hash, signedData, key, signature);
319
+ }
320
+ } catch (e) {
321
+ throw new DnssecError("dnssec/verify-threw", "dnssec.verifyRrset: signature verification threw: " + ((e && e.message) || e));
322
+ }
323
+ if (!ok) throw new DnssecError("dnssec/bad-signature", "dnssec.verifyRrset: RRSIG signature did not verify");
324
+ return { ok: true, algorithm: alg.name, keyTag: rrsig.keyTag, signerName: rrsig.signerName };
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Denial of existence (RFC 4034 §4 NSEC, RFC 5155 NSEC3).
329
+ //
330
+ // These helpers prove a name (or a name+type) DOES NOT EXIST from the
331
+ // signed NSEC / NSEC3 records a server returns in the Authority section.
332
+ // They operate on records the caller has ALREADY verified with
333
+ // verifyRrset — like verifyDs, this is the relation check, not the
334
+ // signature check. Passing unverified records proves nothing.
335
+ // ---------------------------------------------------------------------------
336
+
337
+ var BASE32HEX = "0123456789ABCDEFGHIJKLMNOPQRSTUV"; // allow:raw-byte-literal — RFC 4648 §7 extended-hex alphabet (RFC number in comment)
338
+ var TYPE_DS = 43; // allow:raw-byte-literal — IANA RR type DS
339
+ var TYPE_CNAME = 5;
340
+ var NSEC3_HASH_SHA1 = 1; // RFC 5155 §5 — the only registered NSEC3 hash
341
+ var DEFAULT_MAX_NSEC3_ITERATIONS = 500; // allow:raw-time-literal — DoS ceiling on iterated SHA-1 (RFC 9276 wants 0; deployed zones still use >0)
342
+
343
+ // RFC 4648 §7 base32hex decode (no padding, case-insensitive) — the
344
+ // label encoding of an NSEC3 owner-name hash.
345
+ function _base32hexDecode(s, label) {
346
+ var up = String(s).toUpperCase();
347
+ var bits = 0, value = 0, out = [];
348
+ for (var i = 0; i < up.length; i++) {
349
+ var idx = BASE32HEX.indexOf(up[i]);
350
+ if (idx === -1) throw new DnssecError("dnssec/bad-nsec3", "dnssec: " + label + " is not valid base32hex");
351
+ value = (value << 5) | idx; // allow:raw-byte-literal — base32 5-bit group
352
+ bits += 5; // allow:raw-byte-literal — base32 5-bit group
353
+ if (bits >= 8) { bits -= 8; out.push((value >> bits) & 0xff); } // allow:raw-byte-literal — emit a full octet
354
+ }
355
+ return Buffer.from(out);
356
+ }
357
+
358
+ // RFC 4034 §4.1.2 / RFC 5155 §3.2.1 type bitmap → Set of type numbers.
359
+ function _parseTypeBitmaps(buf, off, end) {
360
+ var types = new Set();
361
+ var i = off;
362
+ while (i + 2 <= end) {
363
+ var win = buf[i], len = buf[i + 1];
364
+ i += 2;
365
+ if (len < 1 || len > 32 || i + len > end) { // allow:raw-byte-literal — bitmap window ≤ 256 bits = 32 octets (RFC 4034 §4.1.2)
366
+ throw new DnssecError("dnssec/bad-bitmap", "dnssec: malformed NSEC type bitmap");
367
+ }
368
+ for (var j = 0; j < len; j++) {
369
+ var octet = buf[i + j];
370
+ for (var bit = 0; bit < 8; bit++) { // allow:raw-byte-literal — 8 bits per octet
371
+ if (octet & (0x80 >> bit)) types.add(win * 256 + j * 8 + bit); // allow:raw-byte-literal — bit→type-number (window*256 + octet*8 + bit)
372
+ }
373
+ }
374
+ i += len;
375
+ }
376
+ return types;
377
+ }
378
+
379
+ // Read an uncompressed wire-format domain name (compression pointers are
380
+ // illegal in signed RDATA). Returns { name, end }.
381
+ function _readWireName(buf, off) {
382
+ var labels = [];
383
+ var i = off;
384
+ for (;;) {
385
+ if (i >= buf.length) throw new DnssecError("dnssec/bad-name", "dnssec: truncated name in RDATA");
386
+ var len = buf[i];
387
+ if (len === 0) { i++; break; }
388
+ if ((len & 0xc0) !== 0) throw new DnssecError("dnssec/bad-name", "dnssec: compression pointer in signed RDATA"); // allow:raw-byte-literal — RFC 1035 label-length top-two-bits flag
389
+ i++;
390
+ labels.push(buf.slice(i, i + len).toString("ascii"));
391
+ i += len;
392
+ }
393
+ return { name: labels.length ? labels.join(".") + "." : ".", end: i };
394
+ }
395
+
396
+ function _parseNsec3Rdata(rd) {
397
+ if (rd.length < 6) throw new DnssecError("dnssec/bad-nsec3", "dnssec: NSEC3 RDATA too short"); // allow:raw-byte-literal — fixed NSEC3 header octets
398
+ var hashAlg = rd[0], flags = rd[1], iterations = rd.readUInt16BE(2), saltLen = rd[4];
399
+ var off = 5 + saltLen;
400
+ if (off + 1 > rd.length) throw new DnssecError("dnssec/bad-nsec3", "dnssec: NSEC3 salt overruns RDATA");
401
+ var salt = rd.slice(5, 5 + saltLen);
402
+ var hashLen = rd[off]; off += 1;
403
+ if (off + hashLen > rd.length) throw new DnssecError("dnssec/bad-nsec3", "dnssec: NSEC3 next-hashed-owner overruns RDATA");
404
+ var nextHashed = rd.slice(off, off + hashLen);
405
+ return { hashAlg: hashAlg, flags: flags, iterations: iterations, salt: salt, nextHashed: nextHashed, types: _parseTypeBitmaps(rd, off + hashLen, rd.length) };
406
+ }
407
+
408
+ function _parseNsecRdata(rd) {
409
+ var n = _readWireName(rd, 0);
410
+ return { nextName: n.name, types: _parseTypeBitmaps(rd, n.end, rd.length) };
411
+ }
412
+
413
+ // RFC 5155 §5 iterated hash: IH(salt, x, 0)=SHA-1(x‖salt);
414
+ // IH(salt, x, k)=SHA-1(IH(salt,x,k-1)‖salt). SHA-1 is the only NSEC3
415
+ // hash IANA defines — a wire-protocol constant, not a framework default.
416
+ function _nsec3HashWire(nameWire, salt, iterations) {
417
+ var h = nodeCrypto.createHash("sha1").update(Buffer.concat([nameWire, salt])).digest();
418
+ for (var k = 0; k < iterations; k++) {
419
+ h = nodeCrypto.createHash("sha1").update(Buffer.concat([h, salt])).digest();
420
+ }
421
+ return h;
422
+ }
423
+
424
+ function _nameLabels(name) {
425
+ var n = String(name).replace(/\.$/, "");
426
+ return n === "" ? [] : n.split(".");
427
+ }
428
+
429
+ // RFC 4034 §6.1 canonical name ordering: compare label sequences from
430
+ // the least-significant (rightmost) label, octets lowercased.
431
+ function _canonicalNameCompare(a, b) {
432
+ var la = _nameLabels(a).reverse(), lb = _nameLabels(b).reverse();
433
+ var min = Math.min(la.length, lb.length);
434
+ for (var i = 0; i < min; i++) {
435
+ var c = Buffer.compare(Buffer.from(la[i].toLowerCase(), "ascii"), Buffer.from(lb[i].toLowerCase(), "ascii"));
436
+ if (c !== 0) return c;
437
+ }
438
+ return la.length - lb.length;
439
+ }
440
+
441
+ // Closest-encloser candidates: proper suffixes of qname from longest
442
+ // (qname minus one label) down to the zone apex, longest first.
443
+ function _closestEncloserCandidates(qname, zone) {
444
+ var ql = _nameLabels(qname), zl = _nameLabels(zone);
445
+ var out = [];
446
+ for (var k = ql.length - 1; k >= zl.length; k--) {
447
+ out.push(ql.slice(ql.length - k).join(".") + ".");
448
+ }
449
+ return out;
450
+ }
451
+
452
+ // The "next closer" name: the closest encloser with one more label of
453
+ // qname prepended (RFC 5155 §1.3).
454
+ function _nextCloser(qname, ce) {
455
+ var ql = _nameLabels(qname), n = _nameLabels(ce).length + 1;
456
+ return ql.slice(ql.length - n).join(".") + ".";
457
+ }
458
+
459
+ /**
460
+ * @primitive b.network.dns.dnssec.nsec3Hash
461
+ * @signature b.network.dns.dnssec.nsec3Hash(name, opts)
462
+ * @since 0.12.49
463
+ * @status stable
464
+ * @related b.network.dns.dnssec.verifyDenial
465
+ *
466
+ * Compute the RFC 5155 §5 NSEC3 hash of a name — iterated SHA-1 over the
467
+ * canonical (lowercased, root-terminated) wire form with the zone's salt.
468
+ * The result is the unencoded hash; the NSEC3 owner label is its
469
+ * base32hex encoding. SHA-1 is the only hash IANA registers for NSEC3,
470
+ * so this is a wire-protocol constant, not a cryptographic default.
471
+ *
472
+ * @opts
473
+ * {
474
+ * salt: Buffer, // zone NSEC3 salt (may be empty)
475
+ * iterations: number, // additional hash iterations (>= 0)
476
+ * }
477
+ *
478
+ * @example
479
+ * var h = b.network.dns.dnssec.nsec3Hash("a.example.com", { salt: salt, iterations: 0 });
480
+ */
481
+ function nsec3Hash(name, opts) {
482
+ validateOpts.requireObject(opts, "dnssec.nsec3Hash", DnssecError);
483
+ validateOpts(opts, ["salt", "iterations"], "dnssec.nsec3Hash");
484
+ var salt = _bytes(opts.salt, "salt");
485
+ var iters = opts.iterations;
486
+ if (typeof iters !== "number" || !isFinite(iters) || iters < 0 || Math.floor(iters) !== iters) {
487
+ throw new DnssecError("dnssec/bad-iterations", "dnssec.nsec3Hash: iterations must be a non-negative integer");
488
+ }
489
+ return _nsec3HashWire(_canonicalName(name), salt, iters);
490
+ }
491
+
492
+ /**
493
+ * @primitive b.network.dns.dnssec.verifyDenial
494
+ * @signature b.network.dns.dnssec.verifyDenial(opts)
495
+ * @since 0.12.49
496
+ * @status stable
497
+ * @compliance soc2
498
+ * @related b.network.dns.dnssec.verifyRrset, b.network.dns.dnssec.nsec3Hash
499
+ *
500
+ * Prove that a name does not exist (NXDOMAIN) or that a name has no
501
+ * records of a given type (NODATA) from the signed NSEC (RFC 4034 §4) or
502
+ * NSEC3 (RFC 5155) records in a response's Authority section. This is the
503
+ * other half of "verify the answer yourself": <code>verifyRrset</code>
504
+ * proves a positive answer, <code>verifyDenial</code> proves a negative.
505
+ *
506
+ * The records MUST already be verified with <code>verifyRrset</code> —
507
+ * this checks the denial RELATION (closest-encloser, covering ranges,
508
+ * type-bitmap absence), not the signatures. For NSEC3, the iterated-hash
509
+ * count is capped (<code>opts.maxIterations</code>, default 500) to bound
510
+ * the SHA-1 work an attacker can force. An NXDOMAIN proof that relies on
511
+ * an Opt-Out NSEC3 (RFC 5155 §6) is refused unless
512
+ * <code>opts.allowOptOut</code> — opt-out only proves "no signed records",
513
+ * not non-existence.
514
+ *
515
+ * @opts
516
+ * {
517
+ * qname: string, // the queried name
518
+ * qtype: string|number, // queried type (required for proof "nodata")
519
+ * proof: string, // "nxdomain" | "nodata"
520
+ * zone: string, // the signer zone apex (a suffix of qname)
521
+ * nsec3?: [ { owner: string, rdata: Buffer } ], // NSEC3 records (owner = base32hex-label.zone)
522
+ * nsec?: [ { owner: string, rdata: Buffer } ], // NSEC records
523
+ * maxIterations?: number, // NSEC3 iteration cap (default 500)
524
+ * allowOptOut?: boolean, // accept an Opt-Out NXDOMAIN proof (default false)
525
+ * }
526
+ *
527
+ * @example
528
+ * b.network.dns.dnssec.verifyDenial({
529
+ * qname: "nope.example.com", proof: "nxdomain", zone: "example.com", nsec3: records,
530
+ * });
531
+ */
532
+ function verifyDenial(opts) {
533
+ validateOpts.requireObject(opts, "dnssec.verifyDenial", DnssecError);
534
+ validateOpts(opts, ["qname", "qtype", "proof", "zone", "nsec3", "nsec", "maxIterations", "allowOptOut"], "dnssec.verifyDenial");
535
+ if (typeof opts.qname !== "string" || opts.qname === "") throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.qname is required");
536
+ if (typeof opts.zone !== "string" || opts.zone === "") throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.zone is required");
537
+ if (opts.proof !== "nxdomain" && opts.proof !== "nodata") throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.proof must be 'nxdomain' or 'nodata'");
538
+ var zl = _nameLabels(opts.zone), ql = _nameLabels(opts.qname);
539
+ if (zl.length > ql.length || zl.join(".").toLowerCase() !== ql.slice(ql.length - zl.length).join(".").toLowerCase()) {
540
+ throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.zone must be a suffix of opts.qname");
541
+ }
542
+ var qtypeNum;
543
+ if (opts.proof === "nodata") {
544
+ if (opts.qtype === undefined || opts.qtype === null) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.qtype is required for a nodata proof");
545
+ qtypeNum = _typeNumber(opts.qtype);
546
+ } else if (opts.qtype !== undefined && opts.qtype !== null) {
547
+ qtypeNum = _typeNumber(opts.qtype);
548
+ }
549
+
550
+ var hasNsec3 = Array.isArray(opts.nsec3) && opts.nsec3.length > 0;
551
+ var hasNsec = Array.isArray(opts.nsec) && opts.nsec.length > 0;
552
+ if (hasNsec3 === hasNsec) {
553
+ throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: supply exactly one of opts.nsec3 or opts.nsec");
554
+ }
555
+ return hasNsec3 ? _verifyNsec3Denial(opts, qtypeNum) : _verifyNsecDenial(opts, qtypeNum);
556
+ }
557
+
558
+ function _verifyNsec3Denial(opts, qtypeNum) {
559
+ var maxIter = typeof opts.maxIterations === "number" ? opts.maxIterations : DEFAULT_MAX_NSEC3_ITERATIONS;
560
+ if (typeof maxIter !== "number" || !isFinite(maxIter) || maxIter < 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: maxIterations must be a non-negative number");
561
+
562
+ // Parse + sanity-check every NSEC3 record; the chain shares one salt /
563
+ // iteration / hash-algorithm tuple.
564
+ var recs = opts.nsec3.map(function (r, i) {
565
+ if (!r || typeof r.owner !== "string") throw new DnssecError("dnssec/bad-nsec3", "dnssec.verifyDenial: nsec3[" + i + "].owner must be a string");
566
+ var rd = _bytes(r.rdata, "nsec3[" + i + "].rdata");
567
+ var p = _parseNsec3Rdata(rd);
568
+ if (p.hashAlg !== NSEC3_HASH_SHA1) throw new DnssecError("dnssec/unsupported-nsec3-hash", "dnssec.verifyDenial: NSEC3 hash algorithm " + p.hashAlg + " is not supported (only SHA-1 / 1 is defined)");
569
+ if (p.iterations > maxIter) throw new DnssecError("dnssec/nsec3-iterations-excessive", "dnssec.verifyDenial: NSEC3 iterations " + p.iterations + " exceed the cap " + maxIter);
570
+ var firstLabel = _nameLabels(r.owner)[0];
571
+ if (!firstLabel) throw new DnssecError("dnssec/bad-nsec3", "dnssec.verifyDenial: nsec3[" + i + "].owner has no hash label");
572
+ return { ownerHash: _base32hexDecode(firstLabel, "nsec3[" + i + "].owner"), p: p };
573
+ });
574
+ var salt = recs[0].p.salt, iterations = recs[0].p.iterations;
575
+ for (var s = 1; s < recs.length; s++) {
576
+ if (recs[s].p.iterations !== iterations || Buffer.compare(recs[s].p.salt, salt) !== 0) {
577
+ throw new DnssecError("dnssec/nsec3-param-mismatch", "dnssec.verifyDenial: NSEC3 records disagree on salt / iterations");
578
+ }
579
+ }
580
+
581
+ function hashOf(name) { return _nsec3HashWire(_canonicalName(name), salt, iterations); }
582
+ function findMatch(name) {
583
+ var h = hashOf(name);
584
+ for (var i = 0; i < recs.length; i++) if (Buffer.compare(recs[i].ownerHash, h) === 0) return recs[i];
585
+ return null;
586
+ }
587
+ function findCover(name) {
588
+ var h = hashOf(name);
589
+ for (var i = 0; i < recs.length; i++) {
590
+ var owner = recs[i].ownerHash, next = recs[i].p.nextHashed;
591
+ var oc = Buffer.compare(owner, next);
592
+ var covered = oc < 0
593
+ ? (Buffer.compare(owner, h) < 0 && Buffer.compare(h, next) < 0)
594
+ : (Buffer.compare(owner, h) < 0 || Buffer.compare(h, next) < 0); // last NSEC3 wraps past the apex
595
+ if (covered) return recs[i];
596
+ }
597
+ return null;
598
+ }
599
+
600
+ if (opts.proof === "nodata") {
601
+ // RFC 5155 §8.5 — a matching NSEC3 with the type (and CNAME) absent.
602
+ var m = findMatch(opts.qname);
603
+ if (m) {
604
+ if (qtypeNum === TYPE_DS) {
605
+ if (m.p.types.has(TYPE_DS)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: DS is present in the matching NSEC3 bitmap");
606
+ return { ok: true, proof: "nodata", mechanism: "nsec3", matched: true };
607
+ }
608
+ if (m.p.types.has(qtypeNum)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: type " + qtypeNum + " is present in the matching NSEC3 bitmap");
609
+ if (m.p.types.has(TYPE_CNAME)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: name is a CNAME (query should have been redirected)");
610
+ return { ok: true, proof: "nodata", mechanism: "nsec3", matched: true };
611
+ }
612
+ // RFC 5155 §8.6 — Opt-Out DS NODATA: a covering NSEC3 with Opt-Out set
613
+ // proves an insecure delegation has no DS.
614
+ if (qtypeNum === TYPE_DS) {
615
+ var ce = _nsec3ClosestEncloser(opts, recs, findMatch);
616
+ if (ce) {
617
+ var nc = _nextCloser(opts.qname, ce);
618
+ var cov = findCover(nc);
619
+ if (cov && (cov.p.flags & 1) === 1) return { ok: true, proof: "nodata", mechanism: "nsec3", matched: false, optOut: true };
620
+ }
621
+ }
622
+ // RFC 5155 §8.7 — wildcard NODATA: closest encloser proof + a matching
623
+ // wildcard NSEC3 with the type absent.
624
+ var ce2 = _nsec3ClosestEncloser(opts, recs, findMatch);
625
+ if (ce2) {
626
+ var wc = findMatch("*." + ce2);
627
+ if (wc && !wc.p.types.has(qtypeNum) && !wc.p.types.has(TYPE_CNAME)) {
628
+ return { ok: true, proof: "nodata", mechanism: "nsec3", matched: false, wildcard: true, closestEncloser: ce2 };
629
+ }
630
+ }
631
+ throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no matching NSEC3 proves NODATA for the queried type");
632
+ }
633
+
634
+ // NXDOMAIN (RFC 5155 §8.4): matching closest encloser + covered next
635
+ // closer + covered wildcard. Opt-Out on the next-closer cover only
636
+ // proves "no signed records", so it is refused unless allowOptOut.
637
+ var ceName = _nsec3ClosestEncloser(opts, recs, findMatch);
638
+ if (!ceName) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no NSEC3 matches any closest-encloser candidate");
639
+ var nextCloser = _nextCloser(opts.qname, ceName);
640
+ var ncCover = findCover(nextCloser);
641
+ if (!ncCover) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: the next-closer name is not covered by any NSEC3");
642
+ var optOut = (ncCover.p.flags & 1) === 1;
643
+ if (optOut && !opts.allowOptOut) throw new DnssecError("dnssec/denial-opt-out", "dnssec.verifyDenial: NXDOMAIN relies on an Opt-Out NSEC3 (set allowOptOut to accept it as 'no signed records')");
644
+ // The wildcard at the closest encloser must be proven NON-EXISTENT
645
+ // (covered). A MATCHING wildcard means it exists, so the name should
646
+ // have been wildcard-synthesised and NXDOMAIN would be a forgery.
647
+ if (!findCover("*." + ceName)) {
648
+ throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: the wildcard at the closest encloser is not covered (a matching wildcard would mean the name should have been synthesised)");
649
+ }
650
+ return { ok: true, proof: "nxdomain", mechanism: "nsec3", closestEncloser: ceName, optOut: optOut };
651
+ }
652
+
653
+ function _nsec3ClosestEncloser(opts, recs, findMatch) {
654
+ var cands = _closestEncloserCandidates(opts.qname, opts.zone);
655
+ for (var i = 0; i < cands.length; i++) if (findMatch(cands[i])) return cands[i];
656
+ return null;
657
+ }
658
+
659
+ function _verifyNsecDenial(opts, qtypeNum) {
660
+ var recs = opts.nsec.map(function (r, i) {
661
+ if (!r || typeof r.owner !== "string") throw new DnssecError("dnssec/bad-nsec", "dnssec.verifyDenial: nsec[" + i + "].owner must be a string");
662
+ return { owner: r.owner, p: _parseNsecRdata(_bytes(r.rdata, "nsec[" + i + "].rdata")) };
663
+ });
664
+ function findMatch(name) {
665
+ for (var i = 0; i < recs.length; i++) if (_canonicalNameCompare(recs[i].owner, name) === 0) return recs[i];
666
+ return null;
667
+ }
668
+ function findCover(name) {
669
+ for (var i = 0; i < recs.length; i++) {
670
+ var owner = recs[i].owner, next = recs[i].p.nextName;
671
+ var oc = _canonicalNameCompare(owner, next);
672
+ var afterOwner = _canonicalNameCompare(owner, name) < 0;
673
+ var covered = oc < 0
674
+ ? (afterOwner && _canonicalNameCompare(name, next) < 0)
675
+ : afterOwner; // last NSEC (next wraps to apex): any name after owner
676
+ if (covered) return recs[i];
677
+ }
678
+ return null;
679
+ }
680
+
681
+ if (opts.proof === "nodata") {
682
+ var m = findMatch(opts.qname);
683
+ if (!m) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no NSEC matches the queried name");
684
+ if (m.p.types.has(qtypeNum)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: type " + qtypeNum + " is present in the matching NSEC bitmap");
685
+ if (qtypeNum !== TYPE_CNAME && m.p.types.has(TYPE_CNAME)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: name is a CNAME (query should have been redirected)");
686
+ return { ok: true, proof: "nodata", mechanism: "nsec", matched: true };
687
+ }
688
+
689
+ // NXDOMAIN (RFC 4035 §5.4): an NSEC covering qname AND an NSEC proving
690
+ // the source-of-synthesis wildcard does not exist.
691
+ var cover = findCover(opts.qname);
692
+ if (!cover) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no NSEC covers the queried name");
693
+ // The closest encloser is the longest common ancestor of qname and the
694
+ // covering NSEC's owner/next; the wildcard sits one label below it.
695
+ var ce = _nsecClosestEncloser(opts.qname, cover);
696
+ // The source-of-synthesis wildcard must be proven NON-EXISTENT
697
+ // (covered). A MATCHING wildcard owner means it exists, so the query
698
+ // should have been answered by wildcard expansion, not NXDOMAIN.
699
+ var wildcard = "*." + ce;
700
+ if (!findCover(wildcard)) {
701
+ throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: the wildcard at the closest encloser is not covered (a matching wildcard would mean the name should have been synthesised)");
702
+ }
703
+ return { ok: true, proof: "nxdomain", mechanism: "nsec", closestEncloser: ce };
704
+ }
705
+
706
+ // The closest encloser for an NSEC NXDOMAIN proof is the longest name
707
+ // that is a suffix of qname and an ancestor of both the covering NSEC's
708
+ // owner and its next name (RFC 4035 §5.3.4 / §5.4).
709
+ function _nsecClosestEncloser(qname, cover) {
710
+ var ql = _nameLabels(qname);
711
+ var a = _commonSuffixLen(qname, cover.owner);
712
+ var b = _commonSuffixLen(qname, cover.p.nextName);
713
+ var ceLen = Math.max(a, b);
714
+ return ql.slice(ql.length - ceLen).join(".") + ".";
715
+ }
716
+
717
+ function _commonSuffixLen(a, b) {
718
+ var la = _nameLabels(a).reverse(), lb = _nameLabels(b).reverse();
719
+ var n = 0, min = Math.min(la.length, lb.length);
720
+ while (n < min && la[n].toLowerCase() === lb[n].toLowerCase()) n++;
721
+ return n;
722
+ }
723
+
724
+ module.exports = {
725
+ verifyRrset: verifyRrset,
726
+ verifyDs: verifyDs,
727
+ verifyDenial: verifyDenial,
728
+ nsec3Hash: nsec3Hash,
729
+ keyTag: keyTag,
730
+ ALGORITHMS: ALGS,
731
+ DnssecError: DnssecError,
732
+ };
package/lib/network.js CHANGED
@@ -35,6 +35,7 @@ var ntpCheck = require("./ntp-check");
35
35
  var nts = require("./network-nts");
36
36
  var networkDns = require("./network-dns");
37
37
  networkDns.resolver = require("./network-dns-resolver");
38
+ networkDns.dnssec = require("./network-dnssec");
38
39
  var networkProxy = require("./network-proxy");
39
40
  var networkTls = require("./network-tls");
40
41
  var heartbeat = require("./network-heartbeat");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.47",
3
+ "version": "0.12.49",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:4b602bbd-c8e6-4e4a-8b26-b0c570144b86",
5
+ "serialNumber": "urn:uuid:abe5140c-7ec0-4872-8094-0863d2e0a821",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-25T11:11:40.479Z",
8
+ "timestamp": "2026-05-25T13:20:22.849Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.12.47",
22
+ "bom-ref": "@blamejs/core@0.12.49",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.47",
25
+ "version": "0.12.49",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.12.47",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.49",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.12.47",
57
+ "ref": "@blamejs/core@0.12.49",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]