@blamejs/core 0.12.68 → 0.12.70

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.70 (2026-05-26) — **`b.network.dns.tsig` — RFC 8945 DNS transaction signatures.** Sign and verify DNS messages with RFC 8945 TSIG — the shared-key HMAC that authenticates a DNS transaction (zone transfers, dynamic updates, query/response pairs) and proves it was not altered in flight. b.network.dns.tsig.sign(message, opts) appends a TSIG resource record and returns the signed wire; b.network.dns.tsig.verify(message, opts) locates the TSIG record, recomputes the HMAC over the RFC 8945 §4.3.3 digest, compares it in constant time, and checks the time window (valid only within `fudge` seconds of `timeSigned`). HMAC-SHA-256 is the default; SHA-384 / SHA-512 are available and the broken HMAC-MD5 / HMAC-SHA-1 algorithms are refused unless allowLegacy is set. Signing a response chains the request MAC into the digest. Verified byte-for-byte against dnspython 2.8.0 reference signatures. TSIG completes the DNS-trust set alongside the existing DNSSEC (zone-data authentication) and DANE primitives — DNSSEC authenticates the data end-to-end, TSIG authenticates a single hop's transaction with a pre-shared key. **Added:** *`b.network.dns.tsig.sign` / `b.network.dns.tsig.verify`* — RFC 8945 TSIG transaction authentication. `sign(message, { keyName, secret, algorithm, fudge, time, requestMac })` appends a TSIG RR to a DNS wire message and returns `{ wire, mac }`; `verify(message, { keys, now, requestMac })` returns `{ valid, keyName, algorithm, timeSigned, error, macValid, timeValid, reason }`, with a constant-time MAC compare (via `b.crypto.timingSafeEqual`), a `fudge`-second time-window check, truncated-MAC handling per §5.2.2.1, and request-MAC chaining for responses (§5.4.1). HMAC-SHA-256 default; HMAC-SHA-384 / SHA-512 supported; HMAC-MD5 / HMAC-SHA-1 refused unless `allowLegacy: true`. The transaction-level companion to `b.network.dns.dnssec` and `b.network.dns.dane`.
12
+
13
+ - v0.12.69 (2026-05-26) — **`b.middleware.botGuard` no longer blocks browsers that omit Sec-Fetch-Mode.** b.middleware.botGuard treated a missing Sec-Fetch-Mode header as a bot signal and returned 403 Forbidden, which refused legitimate browsers on any origin where the browser does not emit Fetch Metadata: every plain-HTTP non-localhost origin (Umbrel apps, LAN and *.local reverse-proxy deployments) and Safari before 16.4 even over HTTPS. Browsers only send Sec-Fetch-* in a secure context, so its absence is normal there — not a bot. Sec-Fetch-Mode is now advisory only: it never blocks, and it sets req.suspectedBot in mode:"tag" only on a secure-context HTML GET where a modern browser would have sent it. Drive-by bots are still blocked by the missing-Accept-Language and User-Agent heuristics. No configuration change is needed; if you had widened skipPaths or disabled bot-guard to work around this, you can revert that. **Fixed:** *`b.middleware.botGuard` no longer 403s browsers over plain HTTP or older Safari* — A missing `Sec-Fetch-Mode` was a blocking heuristic, but browsers omit Fetch Metadata outside a secure context (every plain-HTTP non-localhost origin — Umbrel, LAN, `*.local` proxies) and Safari < 16.4 omits it even over HTTPS. Those legitimate browsers were refused with `403 Forbidden`. `Sec-Fetch-Mode` is now advisory: it never blocks, and only sets `req.suspectedBot` in `mode: "tag"` on a secure-context HTML GET. The `Accept-Language` and User-Agent heuristics (which catch the same bots) are unchanged. **Detectors:** *reserved-hostname trailing-dot detector recognizes regex strips* — The codebase-patterns gate that requires stripping the RFC 1034 trailing root-zone dot before a reserved-hostname comparison now also recognizes end-anchored regex strips (`.replace(/\.$/, …)`), not only the `charAt` / `while`-loop forms.
14
+
11
15
  - v0.12.68 (2026-05-26) — **`b.jwk` — RFC 7638 JWK thumbprint.** Compute the RFC 7638 thumbprint of a JSON Web Key — the canonical base64url(SHA-256(canonical-JSON)) identifier used to name a key (DPoP jkt bindings, ACME account-key thumbprints, DBSC session pins, kid derivation). b.jwk.thumbprint(jwk) returns the digest; b.jwk.canonicalize(jwk) returns the exact JSON that is hashed — only the key-type's required members, member names in lexicographic order, no whitespace, so the same key always yields the same thumbprint regardless of how its JWK was serialized. The standard key types are supported (EC, RSA, oct, OKP per RFC 8037) plus AKP, the IANA key type Node uses for ML-DSA / SLH-DSA post-quantum public keys; SHA-256 is the default, with hash: "sha384" | "sha512" for RFC 9278 thumbprint-with-hash. Verified against the RFC 7638 §3.1 worked example. b.auth.dpop, b.acme, and b.dbsc now compute their thumbprints through this primitive. **Added:** *`b.jwk.thumbprint` / `b.jwk.canonicalize`* — RFC 7638 JWK thumbprint. `thumbprint(jwk, opts)` returns `base64url(hash(canonical-JSON))` — only the key-type's required members feed the hash, so optional fields (`kid`, `use`, `alg`, …) never change the result. `canonicalize(jwk)` returns the canonical JSON string itself. Supports EC / RSA / oct / OKP and the AKP post-quantum key type; SHA-256 default, `hash` selects SHA-384 / SHA-512. Throws `JwkError` on an invalid key or unknown hash. **Changed:** *DPoP, ACME, and DBSC compose `b.jwk`* — `b.auth.dpop` (the `jkt` proof-key thumbprint), `b.acme` (the RFC 8555 account-key authorization), and `b.dbsc` (the session-pin thumbprint) now compute RFC 7638 thumbprints through `b.jwk` instead of carrying their own implementations. Behavior is unchanged — DPoP still refuses symmetric key types, and each surface keeps its own error codes.
12
16
 
13
17
  - v0.12.66 (2026-05-26) — **`b.uriTemplate` — RFC 6570 URI Template expansion.** Expand RFC 6570 URI Templates — the {var} syntax that OpenAPI links, HAL _links, and hypermedia API clients use to turn a template plus a set of variables into a concrete URI. The full Level 4 grammar is supported: every operator ({+var} reserved, {#var} fragment, {.var} label, {/var} path, {;var} path-style parameters, {?var} query, {&var} query continuation), the {var:3} prefix modifier, and the {var*} explode modifier for lists and associative arrays. b.uriTemplate.expand(template, vars) returns the expanded string; b.uriTemplate.compile(template) parses once for templates applied to many variable sets. A malformed template (unclosed expression, reserved operator, non-numeric prefix, unmatched brace) throws UriTemplateError. Verified against the official uritemplate-test conformance suite (all 135 spec, extended, and negative cases). **Added:** *`b.uriTemplate.expand` / `b.uriTemplate.compile`* — RFC 6570 URI Template expansion, full Level 4. `expand(template, vars)` substitutes variables into a template and returns the URI; `compile(template)` returns a reusable `{ expand }` for repeated use. Variable values may be strings, numbers, booleans, arrays (lists), or plain objects (associative arrays); undefined, null, and empty list/map variables are omitted. All eight operators, the `:N` prefix modifier, and the `*` explode modifier follow §3.2, including reserved-set encoding for `{+var}` / `{#var}`. Composes naturally with `b.hal`, `b.linkHeader`, and `b.openapi` link objects. A malformed template throws `UriTemplateError`.
package/README.md CHANGED
@@ -131,7 +131,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
131
131
  - In-process CIDR fence (`b.middleware.networkAllowlist`)
132
132
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
133
133
  - **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`)
134
- - **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; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
134
+ - **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; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
135
135
  - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
136
136
  ### Defensive parsers
137
137
 
@@ -6,8 +6,12 @@
6
6
  *
7
7
  * Heuristics (all combined):
8
8
  * - Missing Accept-Language header (real browsers always send one)
9
- * - Missing Sec-Fetch-Mode header (modern browsers send these on every
10
- * navigation; absence is suspicious for HTML routes but not API)
9
+ * - Missing Sec-Fetch-Mode header ADVISORY ONLY (never blocks). Tagged
10
+ * in mode:"tag" on secure-context HTML GETs where a modern browser
11
+ * would have sent it. It cannot block because the header is absent for
12
+ * entire browser families (Safari < 16.4) and for every plain-HTTP
13
+ * non-localhost origin (Umbrel, LAN / *.local proxies) — a 403 on it
14
+ * alone would refuse real users.
11
15
  * - User-Agent matches known automation libraries (curl, wget, python-
12
16
  * requests, axios, Go-http-client) — operators can add or remove
13
17
  * entries via config
@@ -86,9 +90,13 @@ function _xffIpFor(trustProxy) {
86
90
  * Cheap fingerprint-based detection of obviously-non-browser requests.
87
91
  * Constructed via `b.middleware.botGuard(opts)`; the resulting
88
92
  * middleware has the `(req, res, next)` shape shown above.
89
- * Combines three heuristics: missing `Accept-Language`, missing
90
- * `Sec-Fetch-Mode` (HTML routes), and User-Agent regex match against
91
- * a default list (curl / wget / python-requests / axios / etc.). Not
93
+ * Two blocking heuristics missing `Accept-Language` and a User-Agent
94
+ * regex match against a default list (curl / wget / python-requests /
95
+ * axios / etc.) plus one advisory signal: a missing `Sec-Fetch-Mode`
96
+ * on a secure-context HTML GET sets `req.suspectedBot` in `mode: "tag"`
97
+ * but NEVER blocks (the header is absent for Safari < 16.4 and every
98
+ * plain-HTTP non-localhost origin, so blocking on it refuses real
99
+ * users). Not
92
100
  * a substitute for proper authentication — catches drive-by scrapers
93
101
  * and low-effort bots. In `mode: "block"` (default) the request is
94
102
  * refused; in `mode: "tag"` `req.suspectedBot = true` is set and the
@@ -152,6 +160,28 @@ function create(opts) {
152
160
  return /^\/api\//.test(path);
153
161
  }
154
162
 
163
+ // Browsers only emit Fetch Metadata (Sec-Fetch-*) in a *secure context*
164
+ // (W3C Secure Contexts): an HTTPS origin, or a localhost-family origin
165
+ // even over plain HTTP. On a plain-HTTP non-localhost origin — an Umbrel
166
+ // app, a LAN / *.local reverse-proxy deployment — the browser omits
167
+ // Sec-Fetch-* entirely, so a missing Sec-Fetch-Mode is NORMAL there and
168
+ // must not be read as a bot signal. The effective scheme honours
169
+ // X-Forwarded-Proto only under trustProxy (otherwise it is forgeable).
170
+ function _isSecureContext(req) {
171
+ if (requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https") return true;
172
+ var host = (req.headers && req.headers.host) || "";
173
+ host = String(host).toLowerCase().replace(/:\d+$/, ""); // strip :port
174
+ if (host.charAt(0) === "[") { // [::1] IPv6 literal
175
+ var end = host.indexOf("]");
176
+ host = end === -1 ? host.slice(1) : host.slice(1, end);
177
+ }
178
+ host = host.replace(/\.$/, ""); // strip trailing root-zone dot (RFC 1034 §3.1) so "localhost." matches
179
+ if (host === "localhost" || /\.localhost$/.test(host)) return true;
180
+ if (host === "::1") return true;
181
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; // allow:regex-no-length-cap — bounded dotted-quad loopback
182
+ return false;
183
+ }
184
+
155
185
  function _checkHeuristics(req) {
156
186
  var headers = req.headers || {};
157
187
  var ua = headers["user-agent"] || "";
@@ -167,7 +197,14 @@ function create(opts) {
167
197
  return null;
168
198
  }
169
199
  if (!headers["accept-language"]) return "missing-accept-language";
170
- if (req.method === "GET" && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
200
+ // Missing Sec-Fetch-Mode NEVER blocks: the header is absent for entire
201
+ // browser families (Safari < 16.4 omits Fetch Metadata even over HTTPS)
202
+ // and for every plain-HTTP non-localhost origin (Umbrel, LAN / *.local
203
+ // reverse proxies), so a 403 on it alone refuses real users. It survives
204
+ // only as an advisory TAG in mode:"tag", and even then only in a secure
205
+ // context where a modern browser would have sent it. Drive-by bots are
206
+ // still blocked by missing Accept-Language + the User-Agent deny-list.
207
+ if (mode === "tag" && req.method === "GET" && _isSecureContext(req) && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
171
208
  return null;
172
209
  }
173
210
 
@@ -0,0 +1,404 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.network.dns.tsig
4
+ * @nav Network
5
+ * @title DNS TSIG
6
+ *
7
+ * @intro
8
+ * Sign and verify DNS messages with <a
9
+ * href="https://www.rfc-editor.org/rfc/rfc8945">RFC 8945</a> TSIG
10
+ * (Transaction SIGnature) — the shared-key HMAC that authenticates the
11
+ * transaction between a resolver and a server (zone transfers, dynamic
12
+ * updates, and any query/response pair) and proves it was not tampered
13
+ * with in flight. TSIG complements the existing DNSSEC and DANE
14
+ * primitives: DNSSEC authenticates zone <em>data</em> end-to-end, while
15
+ * TSIG authenticates a single hop's <em>transaction</em> with a
16
+ * pre-shared key.
17
+ *
18
+ * <code>sign(message, opts)</code> appends a TSIG resource record to a
19
+ * DNS message and returns the signed wire bytes;
20
+ * <code>verify(message, opts)</code> locates the TSIG record, recomputes
21
+ * the HMAC over the RFC 8945 §4.3.3 digest, compares it in constant time,
22
+ * and checks the time window (the signature is only valid within
23
+ * <code>fudge</code> seconds of <code>timeSigned</code>). The default MAC
24
+ * algorithm is HMAC-SHA-256; SHA-384 / SHA-512 are available, and the
25
+ * broken HMAC-MD5 / HMAC-SHA-1 algorithms are refused unless
26
+ * <code>allowLegacy</code> is set. Signing a response chains the
27
+ * request's MAC into the digest (<code>requestMac</code>) per §5.4.1.
28
+ *
29
+ * @card
30
+ * RFC 8945 DNS TSIG — shared-key HMAC transaction authentication for DNS
31
+ * messages (sign / verify, constant-time MAC compare, time-window check,
32
+ * HMAC-SHA-256 default). The transaction-level companion to DNSSEC + DANE.
33
+ */
34
+
35
+ var nodeCrypto = require("node:crypto");
36
+ var validateOpts = require("./validate-opts");
37
+ var { timingSafeEqual } = require("./crypto");
38
+ var { defineClass } = require("./framework-error");
39
+
40
+ var TsigError = defineClass("TsigError", { alwaysPermanent: true });
41
+
42
+ var TYPE_TSIG = 250; // allow:raw-byte-literal — IANA RR type TSIG
43
+ var CLASS_ANY = 255; // allow:raw-byte-literal — TSIG RRs use CLASS ANY
44
+ var DEFAULT_FUDGE = 300; // allow:raw-time-literal — RFC 8945 recommended fudge window (seconds)
45
+
46
+ // Algorithm name → Node hash. The strong HMAC-SHA-2 family is the safe set;
47
+ // HMAC-MD5 and HMAC-SHA-1 are refused unless allowLegacy (kept only for
48
+ // interop with legacy nameservers).
49
+ var ALGORITHMS = {
50
+ "hmac-sha256": "sha256",
51
+ "hmac-sha384": "sha384",
52
+ "hmac-sha512": "sha512",
53
+ "hmac-sha224": "sha224",
54
+ };
55
+ var LEGACY_ALGORITHMS = {
56
+ "hmac-sha1": "sha1",
57
+ "hmac-md5": "md5",
58
+ };
59
+ // RFC 8945 §5.2.2.1 — TSIG error RCODEs.
60
+ var ERROR = { NOERROR: 0, BADSIG: 16, BADKEY: 17, BADTIME: 18, BADTRUNC: 22 }; // allow:raw-byte-literal — RFC 8945 extended-RCODE values
61
+
62
+ function _normAlg(name, allowLegacy) {
63
+ var key = String(name || "hmac-sha256").toLowerCase().replace(/\.$/, "");
64
+ if (ALGORITHMS[key]) return { name: key, hash: ALGORITHMS[key] };
65
+ if (LEGACY_ALGORITHMS[key]) {
66
+ if (!allowLegacy) throw new TsigError("tsig/legacy-algorithm", "tsig: algorithm '" + key + "' is broken; pass allowLegacy:true to permit it for legacy interop");
67
+ return { name: key, hash: LEGACY_ALGORITHMS[key] };
68
+ }
69
+ throw new TsigError("tsig/bad-algorithm", "tsig: unknown algorithm '" + key + "'");
70
+ }
71
+
72
+ function _secretBuf(secret) {
73
+ if (Buffer.isBuffer(secret)) return secret;
74
+ if (typeof secret === "string") {
75
+ // TSIG keys are conventionally transported as base64.
76
+ var b = Buffer.from(secret, "base64");
77
+ if (b.length === 0 && secret.length > 0) throw new TsigError("tsig/bad-secret", "tsig: secret must be base64 or a Buffer");
78
+ return b;
79
+ }
80
+ throw new TsigError("tsig/bad-secret", "tsig: secret must be a base64 string or Buffer");
81
+ }
82
+
83
+ // Encode a domain name to uncompressed wire form (labels), lower-casing is
84
+ // NOT applied — TSIG uses the names as presented (key names are
85
+ // conventionally lower-case already; algorithm names are canonical).
86
+ function _encodeName(name) {
87
+ var n = String(name).replace(/\.$/, "");
88
+ if (n === "") return Buffer.from([0]);
89
+ var parts = n.split(".");
90
+ var out = [];
91
+ for (var i = 0; i < parts.length; i++) {
92
+ var lab = Buffer.from(parts[i], "ascii");
93
+ if (lab.length === 0 || lab.length > 63) throw new TsigError("tsig/bad-name", "tsig: invalid label in name '" + name + "'"); // allow:raw-byte-literal — RFC 1035 max label length
94
+ out.push(Buffer.from([lab.length]), lab);
95
+ }
96
+ out.push(Buffer.from([0]));
97
+ return Buffer.concat(out);
98
+ }
99
+
100
+ // Read a domain name starting at off, returning { name, end }. Handles a
101
+ // compression pointer as a terminal jump (TSIG only needs the END offset to
102
+ // keep walking; the pointed-at labels are resolved for the name string).
103
+ function _readName(buf, off) {
104
+ var labels = [];
105
+ var i = off;
106
+ var end = -1;
107
+ var jumps = 0;
108
+ for (;;) {
109
+ if (i >= buf.length) throw new TsigError("tsig/truncated", "tsig: truncated name in message");
110
+ var len = buf[i];
111
+ if (len === 0) { if (end === -1) end = i + 1; break; }
112
+ if ((len & 0xc0) === 0xc0) { // allow:raw-byte-literal — RFC 1035 §4.1.4 compression-pointer flag
113
+ if (i + 1 >= buf.length) throw new TsigError("tsig/truncated", "tsig: truncated compression pointer");
114
+ if (end === -1) end = i + 2;
115
+ var ptr = ((len & 0x3f) << 8) | buf[i + 1]; // allow:raw-byte-literal — 14-bit pointer offset
116
+ if (++jumps > 128) throw new TsigError("tsig/bad-name", "tsig: compression-pointer loop"); // allow:raw-byte-literal — pointer-chase cap
117
+ i = ptr;
118
+ continue;
119
+ }
120
+ if ((len & 0xc0) !== 0) throw new TsigError("tsig/bad-name", "tsig: reserved label-length bits set"); // allow:raw-byte-literal — RFC 1035 label top-bits
121
+ i++;
122
+ labels.push(buf.slice(i, i + len).toString("ascii"));
123
+ i += len;
124
+ }
125
+ return { name: labels.length ? labels.join(".") + "." : ".", end: end };
126
+ }
127
+
128
+ // Skip a name, returning the offset after it (compression-pointer aware).
129
+ function _skipName(buf, off) {
130
+ var i = off;
131
+ for (;;) {
132
+ if (i >= buf.length) throw new TsigError("tsig/truncated", "tsig: truncated name");
133
+ var len = buf[i];
134
+ if (len === 0) return i + 1;
135
+ if ((len & 0xc0) === 0xc0) return i + 2; // allow:raw-byte-literal — compression pointer is terminal
136
+ if ((len & 0xc0) !== 0) throw new TsigError("tsig/bad-name", "tsig: reserved label-length bits set"); // allow:raw-byte-literal — RFC 1035 label top-bits
137
+ i += 1 + len;
138
+ }
139
+ }
140
+
141
+ // Walk the message to the start of the LAST resource record, which a
142
+ // TSIG-bearing message requires to be the TSIG RR (RFC 8945 §5.1).
143
+ function _findTsigRr(buf) {
144
+ if (buf.length < 12) throw new TsigError("tsig/truncated", "tsig: message shorter than the 12-byte header"); // allow:raw-byte-literal — DNS header length
145
+ var qd = buf.readUInt16BE(4), an = buf.readUInt16BE(6), ns = buf.readUInt16BE(8), ar = buf.readUInt16BE(10);
146
+ if (ar < 1) throw new TsigError("tsig/no-tsig", "tsig: message has no additional records (no TSIG)");
147
+ var off = 12; // allow:raw-byte-literal — past the DNS header
148
+ var q;
149
+ for (q = 0; q < qd; q++) { off = _skipName(buf, off); off += 4; } // allow:raw-byte-literal — QTYPE + QCLASS
150
+ var total = an + ns + ar;
151
+ var rrStart = -1;
152
+ for (var r = 0; r < total; r++) {
153
+ rrStart = off;
154
+ off = _skipName(buf, off);
155
+ if (off + 10 > buf.length) throw new TsigError("tsig/truncated", "tsig: truncated RR header"); // allow:raw-byte-literal — type+class+ttl+rdlength
156
+ var rdlen = buf.readUInt16BE(off + 8); // allow:raw-byte-literal — rdlength offset within RR header
157
+ off += 10 + rdlen; // allow:raw-byte-literal — RR fixed header before RDATA
158
+ }
159
+ if (off !== buf.length) throw new TsigError("tsig/trailing-bytes", "tsig: trailing bytes after the final record");
160
+ return rrStart;
161
+ }
162
+
163
+ // Build the TSIG-variables byte block (RFC 8945 §4.3.3).
164
+ function _tsigVariables(keyName, algName, timeSigned, fudge, error, otherData) {
165
+ var time = Buffer.alloc(6); // allow:raw-byte-literal — 48-bit time-signed field
166
+ time.writeUIntBE(timeSigned, 0, 6); // allow:raw-byte-literal — 48-bit big-endian
167
+ var head = Buffer.alloc(6); // allow:raw-byte-literal — CLASS(2) + TTL(4)
168
+ head.writeUInt16BE(CLASS_ANY, 0);
169
+ head.writeUInt32BE(0, 2); // TTL is always 0 (4 bytes)
170
+ var tail = Buffer.alloc(6); // allow:raw-byte-literal — fudge(2)+error(2)+otherlen(2)
171
+ tail.writeUInt16BE(fudge, 0);
172
+ tail.writeUInt16BE(error, 2);
173
+ tail.writeUInt16BE(otherData.length, 4);
174
+ // DNS names are case-insensitive and the TSIG digest uses their canonical
175
+ // (lower-cased) form (RFC 8945 §4.3.3 / RFC 4034 §6.2) — the on-the-wire
176
+ // RR may carry any case, but both signer and verifier digest the
177
+ // lower-cased name, so the MAC is stable across case differences.
178
+ return Buffer.concat([_encodeName(String(keyName).toLowerCase()), head, _encodeName(String(algName).toLowerCase()), time, tail, otherData]);
179
+ }
180
+
181
+ function _requestMacPrefix(requestMac) {
182
+ if (!requestMac) return Buffer.alloc(0);
183
+ var len = Buffer.alloc(2);
184
+ len.writeUInt16BE(requestMac.length, 0);
185
+ return Buffer.concat([len, requestMac]);
186
+ }
187
+
188
+ /**
189
+ * @primitive b.network.dns.tsig.sign
190
+ * @signature b.network.dns.tsig.sign(message, opts)
191
+ * @since 0.12.70
192
+ * @status stable
193
+ * @related b.network.dns.tsig.verify
194
+ *
195
+ * Append a TSIG resource record to a DNS message (a Buffer of wire bytes)
196
+ * and return the signed wire Buffer. The MAC is the HMAC over the message
197
+ * plus the RFC 8945 §4.3.3 TSIG variables. Returns
198
+ * <code>{ wire, mac }</code> — <code>wire</code> is the message with the
199
+ * TSIG RR appended and ARCOUNT incremented, and <code>mac</code> is the raw
200
+ * HMAC (keep it to verify the matching response).
201
+ *
202
+ * @opts
203
+ * keyName: string, // REQUIRED — the shared-key name
204
+ * secret: string | Buffer, // REQUIRED — base64 string or raw bytes
205
+ * algorithm: string, // default: "hmac-sha256"
206
+ * fudge: number, // default: 300 (seconds)
207
+ * time: number, // default: now (Unix seconds)
208
+ * originalId: number, // default: the message's own ID
209
+ * requestMac: Buffer, // when signing a response (§5.4.1)
210
+ * error: number, // default: 0 (NOERROR)
211
+ * otherData: Buffer, // default: empty
212
+ * allowLegacy: boolean, // permit HMAC-MD5 / HMAC-SHA-1
213
+ *
214
+ * @example
215
+ * var signed = b.network.dns.tsig.sign(queryWire, {
216
+ * keyName: "update.key.", secret: "<base64-secret>",
217
+ * });
218
+ * socket.send(signed.wire);
219
+ */
220
+ function sign(message, opts) {
221
+ opts = opts || {};
222
+ if (!Buffer.isBuffer(message) || message.length < 12) throw new TsigError("tsig/bad-message", "tsig.sign: message must be a DNS wire Buffer");
223
+ validateOpts.requireNonEmptyString(opts.keyName, "tsig.sign: keyName", TsigError, "tsig/bad-opt");
224
+ var alg = _normAlg(opts.algorithm, opts.allowLegacy === true);
225
+ var secret = _secretBuf(opts.secret);
226
+ var fudge = opts.fudge == null ? DEFAULT_FUDGE : opts.fudge;
227
+ if (typeof fudge !== "number" || !isFinite(fudge) || fudge < 0 || fudge > 0xffff) throw new TsigError("tsig/bad-opt", "tsig.sign: fudge must be 0..65535 seconds"); // allow:raw-byte-literal — 16-bit fudge field
228
+ var time = opts.time == null ? Math.floor(Date.now() / 1000) : opts.time; // allow:raw-time-literal — ms→s
229
+ if (typeof time !== "number" || !isFinite(time) || time < 0) throw new TsigError("tsig/bad-opt", "tsig.sign: time must be a non-negative Unix-seconds number");
230
+ var error = opts.error == null ? 0 : opts.error;
231
+ var otherData = Buffer.isBuffer(opts.otherData) ? opts.otherData : Buffer.alloc(0);
232
+ var originalId = opts.originalId == null ? message.readUInt16BE(0) : opts.originalId;
233
+ var algName = alg.name + ".";
234
+
235
+ var digest = Buffer.concat([
236
+ _requestMacPrefix(opts.requestMac),
237
+ message,
238
+ _tsigVariables(opts.keyName, algName, time, fudge, error, otherData),
239
+ ]);
240
+ var mac = nodeCrypto.createHmac(alg.hash, secret).update(digest).digest();
241
+
242
+ // TSIG RDATA: algorithm name, time signed, fudge, MAC size + MAC,
243
+ // original ID, error, other len + other data.
244
+ var rtime = Buffer.alloc(6); rtime.writeUIntBE(time, 0, 6); // allow:raw-byte-literal — 48-bit time-signed
245
+ var fixed = Buffer.alloc(4); // allow:raw-byte-literal — fudge(2)+macsize(2)
246
+ fixed.writeUInt16BE(fudge, 0);
247
+ fixed.writeUInt16BE(mac.length, 2);
248
+ var trailer = Buffer.alloc(6); // allow:raw-byte-literal — origid(2)+error(2)+otherlen(2)
249
+ trailer.writeUInt16BE(originalId, 0);
250
+ trailer.writeUInt16BE(error, 2);
251
+ trailer.writeUInt16BE(otherData.length, 4);
252
+ var rdata = Buffer.concat([_encodeName(algName), rtime, fixed, mac, trailer, otherData]);
253
+
254
+ var rrHead = Buffer.alloc(10); // allow:raw-byte-literal — type+class+ttl+rdlength
255
+ rrHead.writeUInt16BE(TYPE_TSIG, 0);
256
+ rrHead.writeUInt16BE(CLASS_ANY, 2);
257
+ rrHead.writeUInt32BE(0, 4); // TTL 0
258
+ rrHead.writeUInt16BE(rdata.length, 8); // allow:raw-byte-literal — rdlength offset within the 10-byte RR header
259
+ var tsigRr = Buffer.concat([_encodeName(opts.keyName), rrHead, rdata]);
260
+
261
+ var out = Buffer.from(message); // copy so we can bump ARCOUNT
262
+ out.writeUInt16BE(out.readUInt16BE(10) + 1, 10); // allow:raw-byte-literal — ARCOUNT offset
263
+ return { wire: Buffer.concat([out, tsigRr]), mac: mac };
264
+ }
265
+
266
+ function _parseTsigRr(buf, rrStart) {
267
+ var n = _readName(buf, rrStart);
268
+ var off = n.end;
269
+ var type = buf.readUInt16BE(off);
270
+ if (type !== TYPE_TSIG) throw new TsigError("tsig/not-tsig", "tsig: the final record is not a TSIG RR (type " + type + ")");
271
+ // The MAC digest hard-codes CLASS ANY / TTL 0, so the on-wire CLASS and
272
+ // TTL are outside the signed data — they MUST be validated explicitly or
273
+ // an attacker could flip them in transit and still verify (RFC 8945 §4.2:
274
+ // CLASS = ANY, TTL = 0).
275
+ var rrClass = buf.readUInt16BE(off + 2); // allow:raw-byte-literal — CLASS offset within RR header
276
+ var rrTtl = buf.readUInt32BE(off + 4); // allow:raw-byte-literal — TTL offset within RR header
277
+ if (rrClass !== CLASS_ANY) throw new TsigError("tsig/bad-rr", "tsig: TSIG RR CLASS must be ANY (255), got " + rrClass);
278
+ if (rrTtl !== 0) throw new TsigError("tsig/bad-rr", "tsig: TSIG RR TTL must be 0, got " + rrTtl);
279
+ off += 8; // allow:raw-byte-literal — type(2)+class(2)+ttl(4)
280
+ var rdlen = buf.readUInt16BE(off); off += 2;
281
+ var rdStart = off;
282
+ var alg = _readName(buf, off); off = alg.end;
283
+ var timeSigned = buf.readUIntBE(off, 6); off += 6; // allow:raw-byte-literal — 48-bit time-signed
284
+ var fudge = buf.readUInt16BE(off); off += 2;
285
+ var macSize = buf.readUInt16BE(off); off += 2;
286
+ var mac = buf.slice(off, off + macSize); off += macSize;
287
+ var originalId = buf.readUInt16BE(off); off += 2;
288
+ var error = buf.readUInt16BE(off); off += 2;
289
+ var otherLen = buf.readUInt16BE(off); off += 2;
290
+ var otherData = buf.slice(off, off + otherLen); off += otherLen;
291
+ if (off !== rdStart + rdlen) throw new TsigError("tsig/bad-rdata", "tsig: RDATA length mismatch");
292
+ return {
293
+ keyName: n.name, algName: alg.name.replace(/\.$/, ""), timeSigned: timeSigned, fudge: fudge,
294
+ mac: mac, originalId: originalId, error: error, otherData: otherData, rrStart: rrStart,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * @primitive b.network.dns.tsig.verify
300
+ * @signature b.network.dns.tsig.verify(message, opts)
301
+ * @since 0.12.70
302
+ * @status stable
303
+ * @related b.network.dns.tsig.sign
304
+ *
305
+ * Verify the TSIG record on a DNS message: locate the trailing TSIG RR,
306
+ * recompute the HMAC over the RFC 8945 §4.3.3 digest, compare it in
307
+ * constant time, and check that <code>now</code> is within
308
+ * <code>fudge</code> seconds of <code>timeSigned</code>. Returns
309
+ * <code>{ valid, keyName, algorithm, timeSigned, fudge, error, macValid,
310
+ * timeValid, reason }</code>; <code>valid</code> is true only when the MAC
311
+ * matches, the time window holds, and the embedded error is NOERROR. Never
312
+ * throws for an authentication failure — only for a malformed message or
313
+ * unknown key shape.
314
+ *
315
+ * @opts
316
+ * keys: object, // { "<keyName>": { secret, algorithm } }
317
+ * keyName: string, // single-key form (with secret)
318
+ * secret: string | Buffer, // single-key form
319
+ * algorithm: string, // expected algorithm (single-key form)
320
+ * now: number, // default: now (Unix seconds)
321
+ * requestMac: Buffer, // when verifying a response (§5.4.1)
322
+ * allowLegacy: boolean,
323
+ *
324
+ * @example
325
+ * var r = b.network.dns.tsig.verify(received, {
326
+ * keys: { "update.key.": { secret: "<base64>" } },
327
+ * });
328
+ * if (!r.valid) refuse(r.reason);
329
+ */
330
+ function verify(message, opts) {
331
+ opts = opts || {};
332
+ if (!Buffer.isBuffer(message) || message.length < 12) throw new TsigError("tsig/bad-message", "tsig.verify: message must be a DNS wire Buffer");
333
+ var rrStart = _findTsigRr(message);
334
+ var rr = _parseTsigRr(message, rrStart);
335
+
336
+ // Resolve the key for this RR's owner name. DNS names are
337
+ // case-insensitive, so match the lower-cased, dot-trimmed forms.
338
+ function _normName(s) { return String(s).toLowerCase().replace(/\.$/, ""); }
339
+ var rrKeyNorm = _normName(rr.keyName);
340
+ var keyEntry = null;
341
+ if (opts.keys && typeof opts.keys === "object") {
342
+ var ks = Object.keys(opts.keys);
343
+ for (var ki = 0; ki < ks.length; ki++) {
344
+ if (_normName(ks[ki]) === rrKeyNorm) { keyEntry = opts.keys[ks[ki]]; break; }
345
+ }
346
+ } else if (opts.keyName != null && _normName(opts.keyName) === rrKeyNorm) {
347
+ keyEntry = { secret: opts.secret, algorithm: opts.algorithm };
348
+ }
349
+ if (!keyEntry) {
350
+ return { valid: false, keyName: rr.keyName, algorithm: rr.algName, timeSigned: rr.timeSigned, fudge: rr.fudge, error: ERROR.BADKEY, macValid: false, timeValid: false, reason: "unknown key '" + rr.keyName + "'" };
351
+ }
352
+ var alg = _normAlg(keyEntry.algorithm || rr.algName, opts.allowLegacy === true);
353
+ // The RR's algorithm must match the key's expected algorithm
354
+ // (case-insensitive — _normAlg already lower-cases alg.name).
355
+ if (alg.name !== rr.algName.toLowerCase()) {
356
+ return { valid: false, keyName: rr.keyName, algorithm: rr.algName, timeSigned: rr.timeSigned, fudge: rr.fudge, error: ERROR.BADKEY, macValid: false, timeValid: false, reason: "algorithm mismatch (key expects " + alg.name + ", message used " + rr.algName + ")" };
357
+ }
358
+ var secret = _secretBuf(keyEntry.secret);
359
+
360
+ // Reconstruct the digested message: bytes before the TSIG RR, with
361
+ // ARCOUNT decremented and the ID restored to the original ID.
362
+ var digestMsg = Buffer.from(message.slice(0, rrStart));
363
+ digestMsg.writeUInt16BE(rr.originalId, 0);
364
+ digestMsg.writeUInt16BE(digestMsg.readUInt16BE(10) - 1, 10); // allow:raw-byte-literal — ARCOUNT offset
365
+
366
+ var digest = Buffer.concat([
367
+ _requestMacPrefix(opts.requestMac),
368
+ digestMsg,
369
+ _tsigVariables(rr.keyName, rr.algName + ".", rr.timeSigned, rr.fudge, rr.error, rr.otherData),
370
+ ]);
371
+ var expected = nodeCrypto.createHmac(alg.hash, secret).update(digest).digest();
372
+
373
+ // Constant-time compare. A truncated MAC (macSize < full) is only valid
374
+ // down to the RFC 8945 §5.2.2.1 floor of max(10, fullLen/2) octets.
375
+ var macValid = false;
376
+ if (rr.mac.length === expected.length) {
377
+ macValid = timingSafeEqual(rr.mac, expected);
378
+ } else if (rr.mac.length >= Math.max(10, expected.length / 2) && rr.mac.length < expected.length) { // allow:raw-byte-literal — RFC 8945 §5.2.2.1 minimum truncated-MAC length
379
+ macValid = timingSafeEqual(rr.mac, expected.slice(0, rr.mac.length));
380
+ }
381
+
382
+ var now = opts.now == null ? Math.floor(Date.now() / 1000) : opts.now; // allow:raw-time-literal — ms→s
383
+ var timeValid = Math.abs(now - rr.timeSigned) <= rr.fudge;
384
+
385
+ var reason = null;
386
+ if (!macValid) reason = "MAC mismatch";
387
+ else if (!timeValid) reason = "time outside fudge window";
388
+ else if (rr.error !== ERROR.NOERROR) reason = "TSIG error code " + rr.error;
389
+
390
+ return {
391
+ valid: macValid && timeValid && rr.error === ERROR.NOERROR,
392
+ keyName: rr.keyName, algorithm: rr.algName, timeSigned: rr.timeSigned, fudge: rr.fudge,
393
+ error: macValid ? (timeValid ? rr.error : ERROR.BADTIME) : ERROR.BADSIG,
394
+ macValid: macValid, timeValid: timeValid, reason: reason,
395
+ };
396
+ }
397
+
398
+ module.exports = {
399
+ sign: sign,
400
+ verify: verify,
401
+ ALGORITHMS: ALGORITHMS,
402
+ ERROR: ERROR,
403
+ TsigError: TsigError,
404
+ };
package/lib/network.js CHANGED
@@ -37,6 +37,7 @@ var networkDns = require("./network-dns");
37
37
  networkDns.resolver = require("./network-dns-resolver");
38
38
  networkDns.dnssec = require("./network-dnssec");
39
39
  networkDns.dane = require("./network-dane");
40
+ networkDns.tsig = require("./network-tsig");
40
41
  var networkProxy = require("./network-proxy");
41
42
  var networkTls = require("./network-tls");
42
43
  var heartbeat = require("./network-heartbeat");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.68",
3
+ "version": "0.12.70",
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:fafe52fa-5ff1-4704-af03-e5aeca4e55cf",
5
+ "serialNumber": "urn:uuid:70f206dc-ef72-4080-8b01-8febb27e0978",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-26T14:23:38.200Z",
8
+ "timestamp": "2026-05-26T17:53:56.836Z",
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.68",
22
+ "bom-ref": "@blamejs/core@0.12.70",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.68",
25
+ "version": "0.12.70",
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.68",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.70",
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.68",
57
+ "ref": "@blamejs/core@0.12.70",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]