@blamejs/core 0.12.49 → 0.12.50

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.50 (2026-05-25) — **`b.network.dns.dnssec.verifyChain` — validate a DNSSEC delegation chain to a pinned root anchor.** Completes local DNSSEC verification: validate a full delegation chain from the root down to a zone against a pinned trust anchor (RFC 4035 §5), instead of trusting any single resolver. For each link, the zone's DNSKEY RRset must be self-signed by one of its keys, and that key must be vouched for either by a pinned anchor (at the root) or by a DS record served + signed by the already-trusted parent — so trust flows root → TLD → zone with no gap. The IANA root KSKs (KSK-2017 tag 20326, KSK-2024 tag 38696) ship as the default anchors; override with opts.trustAnchors for a private root. verifyChain returns the leaf zone's trusted DNSKEY set, which you then hand to verifyRrset / verifyDenial for the actual answer. Composes verifyRrset + verifyDs + the key tag; verified end-to-end against a live root→org chain. **Added:** *`b.network.dns.dnssec.verifyChain(opts)`* — Walks an ordered, root-first list of `links` ({ zone, dnskeys, dnskeyRrsig, dsRdatas?, dsRrsig? }). At each link it verifies the DNSKEY RRset's self-signature (composing `verifyRrset`), then establishes trust in the signing key: at the root by matching a pinned anchor's DS digest (`verifyDs`), at every delegation by verifying the parent-served DS RRset's signature with the already-trusted parent key and confirming the signing KSK matches one of those DS records. Returns `{ ok, zone, keys, path }` with the leaf zone's trusted DNSKEY set. Refuses a root key that matches no anchor (`dnssec/chain-anchor-mismatch`), a KSK that matches no parent DS (`dnssec/chain-ds-mismatch`), and a missing parent key (`dnssec/chain-no-parent-key`). The default `DEFAULT_ROOT_ANCHORS` are the published IANA root KSK DS records; `opts.trustAnchors` overrides them for a private or test root.
12
+
11
13
  - 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
14
 
13
15
  - 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.
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; 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
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, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) 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
 
@@ -721,12 +721,163 @@ function _commonSuffixLen(a, b) {
721
721
  return n;
722
722
  }
723
723
 
724
+ // ---------------------------------------------------------------------------
725
+ // Chain validation (RFC 4035 §5) — walk a delegation chain root → … → zone,
726
+ // anchoring at a pinned trust anchor.
727
+ // ---------------------------------------------------------------------------
728
+
729
+ // IANA root zone trust anchors (DS / SHA-256). KSK-2017 (tag 20326) and
730
+ // KSK-2024 (tag 38696), published at data.iana.org/root-anchors. An
731
+ // operator pins their own via opts.trustAnchors.
732
+ var DEFAULT_ROOT_ANCHORS = [
733
+ { keyTag: 20326, algorithm: 8, digestType: 2, digest: Buffer.from("E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D", "hex") }, // allow:raw-byte-literal — IANA root KSK-2017 DS
734
+ { keyTag: 38696, algorithm: 8, digestType: 2, digest: Buffer.from("683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16", "hex") }, // allow:raw-byte-literal — IANA root KSK-2024 DS
735
+ ];
736
+
737
+ function _dnskeyParts(rdata, what) {
738
+ var rd = _bytes(rdata, what || "dnskey rdata");
739
+ if (rd.length < 4) throw new DnssecError("dnssec/bad-key", "dnssec: DNSKEY RDATA too short"); // allow:raw-byte-literal — DNSKEY fixed header octets
740
+ return { flags: rd.readUInt16BE(0), algorithm: rd[3], publicKey: rd.slice(4) };
741
+ }
742
+ function _parseDsRdata(rd) {
743
+ if (rd.length < 5) throw new DnssecError("dnssec/bad-ds", "dnssec: DS RDATA too short"); // allow:raw-byte-literal — DS fixed header octets
744
+ return { keyTag: rd.readUInt16BE(0), algorithm: rd[2], digestType: rd[3], digest: rd.slice(4) };
745
+ }
746
+ // ALL DNSKEYs whose key tag matches — 16-bit key tags collide (RFC 4034
747
+ // App B), so a verifier must try every candidate, not just the first.
748
+ function _keysByTag(dnskeys, tag) {
749
+ var out = [];
750
+ for (var i = 0; i < dnskeys.length; i++) if (keyTag(dnskeys[i]) === tag) out.push(dnskeys[i]);
751
+ return out;
752
+ }
753
+
754
+ // Verify an RRset against EVERY DNSKEY whose tag matches the RRSIG,
755
+ // returning the key that validated. A wrong colliding key yields
756
+ // `dnssec/bad-signature` — that is not terminal, the next candidate is
757
+ // tried; any other error (expired, alg) is terminal. RFC 4035 §5.3.1.
758
+ function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMsg) {
759
+ if (candidates.length === 0) throw new DnssecError(noKeyCode, noKeyMsg);
760
+ var lastErr = null;
761
+ for (var i = 0; i < candidates.length; i++) {
762
+ var kp = _dnskeyParts(candidates[i]);
763
+ if (kp.algorithm !== rrsig.algorithm) { lastErr = new DnssecError("dnssec/alg-mismatch", "dnssec.verifyChain: candidate key algorithm does not match the RRSIG"); continue; }
764
+ try {
765
+ verifyRrset(Object.assign({}, rrsetBase, { rrsig: rrsig, dnskey: { algorithm: kp.algorithm, publicKey: kp.publicKey } }));
766
+ return candidates[i];
767
+ } catch (e) {
768
+ if (e && e.code === "dnssec/bad-signature") { lastErr = e; continue; } // colliding non-signing key — try the next
769
+ throw e;
770
+ }
771
+ }
772
+ throw lastErr;
773
+ }
774
+
775
+ /**
776
+ * @primitive b.network.dns.dnssec.verifyChain
777
+ * @signature b.network.dns.dnssec.verifyChain(opts)
778
+ * @since 0.12.50
779
+ * @status stable
780
+ * @compliance soc2
781
+ * @related b.network.dns.dnssec.verifyRrset, b.network.dns.dnssec.verifyDs
782
+ *
783
+ * Validate a DNSSEC delegation chain from the root down to a zone, against
784
+ * a pinned trust anchor (RFC 4035 §5). For each link, the zone's DNSKEY
785
+ * RRset must be self-signed by one of its keys; that signing key must be
786
+ * vouched for either by a pinned anchor (root) or by a DS record served by
787
+ * the already-trusted parent. The DS RRset itself is verified against the
788
+ * parent's keys, so trust flows root → TLD → zone with no gap. The default
789
+ * anchors are the IANA root KSKs; override with <code>opts.trustAnchors</code>.
790
+ *
791
+ * This composes <code>verifyRrset</code> + <code>verifyDs</code> + the key
792
+ * tag; it returns the leaf zone's trusted DNSKEY set, which the caller then
793
+ * passes to <code>verifyRrset</code> / <code>verifyDenial</code> for the
794
+ * actual answer.
795
+ *
796
+ * @opts
797
+ * {
798
+ * links: [ { // ordered root-first
799
+ * zone: string,
800
+ * dnskeys: Buffer[], // the zone's DNSKEY RRset RDATAs
801
+ * dnskeyRrsig: { algorithm, labels, originalTtl, expiration, inception, keyTag, signerName, signature },
802
+ * dsRdatas?: Buffer[], // DS RRset for this zone (served by parent; omit for root)
803
+ * dsRrsig?: { ... }, // RRSIG over the DS RRset (signed by parent; omit for root)
804
+ * } ],
805
+ * trustAnchors?: [ { keyTag, algorithm, digestType, digest: Buffer } ], // default IANA root
806
+ * at?: Date, // validity instant (default now)
807
+ * }
808
+ *
809
+ * @example
810
+ * var trusted = b.network.dns.dnssec.verifyChain({ links: [rootLink, orgLink] });
811
+ * // → { ok: true, zone: "org.", keys: [ ...trusted org DNSKEY rdatas ] }
812
+ */
813
+ function verifyChain(opts) {
814
+ validateOpts.requireObject(opts, "dnssec.verifyChain", DnssecError);
815
+ validateOpts(opts, ["links", "trustAnchors", "at"], "dnssec.verifyChain");
816
+ if (!Array.isArray(opts.links) || opts.links.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.links must be a non-empty array");
817
+ var anchors = opts.trustAnchors !== undefined ? opts.trustAnchors : DEFAULT_ROOT_ANCHORS;
818
+ if (!Array.isArray(anchors) || anchors.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.trustAnchors must be a non-empty array");
819
+
820
+ var trustedKeys = null, path = [];
821
+ for (var i = 0; i < opts.links.length; i++) {
822
+ var link = opts.links[i];
823
+ if (!link || typeof link.zone !== "string" || link.zone === "") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].zone is required");
824
+ if (!Array.isArray(link.dnskeys) || link.dnskeys.length === 0) throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeys must be a non-empty array");
825
+ if (!link.dnskeyRrsig || typeof link.dnskeyRrsig !== "object") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeyRrsig is required");
826
+
827
+ // 1. The DNSKEY RRset is self-signed by one of its own keys (trying
828
+ // every key whose tag matches, since tags collide).
829
+ var signer = _verifyRrsetWithAnyKey(
830
+ { name: link.zone, type: "DNSKEY", rdatas: link.dnskeys, at: opts.at },
831
+ link.dnskeyRrsig,
832
+ _keysByTag(link.dnskeys, link.dnskeyRrsig.keyTag),
833
+ "dnssec/chain-no-signing-key", "dnssec.verifyChain: no DNSKEY in '" + link.zone + "' verifies the DNSKEY RRSIG"
834
+ );
835
+
836
+ // 2. Establish trust in the signing key.
837
+ var signerTag = keyTag(signer);
838
+ if (i === 0) {
839
+ // Root: the signing key must match a pinned anchor's DS digest.
840
+ var matched = false;
841
+ for (var a = 0; a < anchors.length; a++) {
842
+ if (anchors[a].keyTag !== signerTag) continue;
843
+ try { verifyDs({ ownerName: link.zone, dnskeyRdata: signer, ds: anchors[a] }); matched = true; break; } catch (_e) { /* try the next anchor */ }
844
+ }
845
+ if (!matched) throw new DnssecError("dnssec/chain-anchor-mismatch", "dnssec.verifyChain: root DNSKEY does not match any pinned trust anchor");
846
+ } else {
847
+ // Delegation: the parent (already trusted) signed a DS RRset for this
848
+ // zone, and the signing KSK matches one of those DS records.
849
+ if (!Array.isArray(link.dsRdatas) || link.dsRdatas.length === 0 || !link.dsRrsig || typeof link.dsRrsig !== "object") {
850
+ throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "] needs dsRdatas + dsRrsig (DS served by the parent)");
851
+ }
852
+ _verifyRrsetWithAnyKey(
853
+ { name: link.zone, type: "DS", rdatas: link.dsRdatas, at: opts.at },
854
+ link.dsRrsig,
855
+ _keysByTag(trustedKeys, link.dsRrsig.keyTag),
856
+ "dnssec/chain-no-parent-key", "dnssec.verifyChain: no trusted parent key verifies the DS RRSIG for '" + link.zone + "'"
857
+ );
858
+ var dsMatched = false;
859
+ for (var d = 0; d < link.dsRdatas.length; d++) {
860
+ var dsObj = _parseDsRdata(_bytes(link.dsRdatas[d], "dsRdatas[" + d + "]"));
861
+ if (dsObj.keyTag !== signerTag) continue;
862
+ try { verifyDs({ ownerName: link.zone, dnskeyRdata: signer, ds: dsObj }); dsMatched = true; break; } catch (_e) { /* try the next DS */ }
863
+ }
864
+ if (!dsMatched) throw new DnssecError("dnssec/chain-ds-mismatch", "dnssec.verifyChain: the signing KSK of '" + link.zone + "' matches no parent-signed DS");
865
+ }
866
+
867
+ trustedKeys = link.dnskeys;
868
+ path.push(link.zone);
869
+ }
870
+ return { ok: true, zone: opts.links[opts.links.length - 1].zone, keys: trustedKeys, path: path };
871
+ }
872
+
724
873
  module.exports = {
725
- verifyRrset: verifyRrset,
726
- verifyDs: verifyDs,
727
- verifyDenial: verifyDenial,
728
- nsec3Hash: nsec3Hash,
729
- keyTag: keyTag,
730
- ALGORITHMS: ALGS,
731
- DnssecError: DnssecError,
874
+ verifyRrset: verifyRrset,
875
+ verifyDs: verifyDs,
876
+ verifyDenial: verifyDenial,
877
+ verifyChain: verifyChain,
878
+ nsec3Hash: nsec3Hash,
879
+ keyTag: keyTag,
880
+ ALGORITHMS: ALGS,
881
+ DEFAULT_ROOT_ANCHORS: DEFAULT_ROOT_ANCHORS,
882
+ DnssecError: DnssecError,
732
883
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.49",
3
+ "version": "0.12.50",
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:abe5140c-7ec0-4872-8094-0863d2e0a821",
5
+ "serialNumber": "urn:uuid:73355867-afbb-4417-a3e0-56f83b718707",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-25T13:20:22.849Z",
8
+ "timestamp": "2026-05-25T14:43:55.314Z",
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.49",
22
+ "bom-ref": "@blamejs/core@0.12.50",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.49",
25
+ "version": "0.12.50",
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.49",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.50",
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.49",
57
+ "ref": "@blamejs/core@0.12.50",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]