@blamejs/core 0.8.15 → 0.8.17
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 +4 -0
- package/lib/mail-arc-sign.js +28 -7
- package/lib/mail-auth.js +159 -33
- package/lib/mail-dkim.js +44 -2
- package/lib/network-smtp-policy.js +160 -8
- package/lib/network-tls.js +33 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **0.8.17** (2026-05-08) — Email auth + DANE / TLS-RPT spec-conformance fixes (ARC / DMARC / SPF / A-R / DANE / TLS-RPT). Ten more RFC-cited gaps closed. **ARC (`b.mail.arc`)** — signer's AMS canonicalization now includes the current hop's `ARC-Authentication-Results` per RFC 8617 §5.1.1 (auto-prepends to `h=` unless operator passes `excludeAarFromAms: true`). Microsoft + Google receivers DO include AAR in `h=`; the prior framework default produced chains those receivers couldn't verify. Verifier counterpart: when reconstructing the AMS canonical input, only the CURRENT hop's AAR is kept (prior-hop AARs stripped). Verifier now enforces `t=` (signing time) + `x=` (expiration) time windows per RFC 8617 §5.2 with operator-tunable `clockSkewMs` (default 5 min) — pre-v0.8.17 the verifier parsed but never enforced. **DMARC (`b.mail.dmarc`)** — multiple `v=DMARC1` records on a domain now treated as having no DMARC record per RFC 7489 §6.6.3 (`_fetchDmarcRecord` returns null on multi-match). Subdomain `sp=` fallback wired: when `_dmarc.<from-domain>` returns no record, the verifier walks one label up to the (heuristic) organizational domain and applies its `sp=` (subdomain policy) — falls back to `p=` when `sp=` absent. Result includes `policyOriginDomain` + `orgDomainPolicyApplied: true` so operators can audit which record drove the verdict. (Heuristic only — operators with PSL needs supply their own `dnsLookup` for full Public Suffix List walk.) **SPF (`b.mail.spf`)** — initial query for the sender's SPF record no longer counts toward the 10-lookup limit per RFC 7208 §4.6.4. Pre-v0.8.17 was off-by-one — senders at the spec ceiling got false `permerror`. **A-R (`b.mail.authResults`)** — result vocabulary is now METHOD-SPECIFIC per RFC 8601 §2.7 (per-method `AR_RESULTS_BY_METHOD` map). The flat `AR_VALID_RESULTS` table previously accepted `hardfail` for DKIM (only valid for DMARC §2.7.4) and accepted `temperror` / `permerror` for methods that don't recognize them. **DANE (`b.network.smtp.dane`)** — `daneTlsa()` now REFUSES to return records unless the caller passes `opts.dnssecValidated: true` per RFC 7672 §1.3 (TLSA records that are not DNSSEC-validated MUST NOT be used). Pre-v0.8.17 silently used un-validated records — silent escalation class. `daneVerifyChain` enforces RFC 6698 §2.1.4 + RFC 7672 §3.1.1 chain-order: a DANE-TA match at chain position `i` is accepted only when its DER Subject equals the DER Issuer of cert at position `i-1` (i.e. it's actually the parent in the chain, not a random non-leaf cert that happens to hash-match). Chain-order is best-effort — synthetic test fixtures without ASN.1-parseable DER fall through with `chainOrderUnverified: true` flagged on the match. **TLS-RPT (`b.network.smtp.tlsRpt.fetchPolicy`)** — record without `rua=` now returns `null` (record ignored) per RFC 8460 §3. Pre-v0.8.17 returned `{ version: "TLSRPTv1", rua: [] }` which operators incorrectly treated as a valid record with no destinations. Bug fix only — no new operator-facing primitives.
|
|
12
|
+
|
|
13
|
+
- **0.8.16** (2026-05-08) — Email auth + transport spec-conformance fixes (DKIM / SPF / MTA-STS / OCSP). Ten RFC-cited gaps closed against shipped primitives. **DKIM (`b.mail.dkim`)** — verifier now refuses any signature whose `h=` tag does not include `from` (RFC 6376 §3.5 cornerstone bypass — without From-coverage the signature does not bind to the visible sender; receivers were treating these as valid); refuses unrecognized `v=` tag values per RFC 6376 §3.5 (only `v=1` accepted); enforces empty `p=` as explicit key revocation per RFC 6376 §3.6.1 (verdict `fail`, not `permerror` — well-formed signature against a withdrawn key); enforces `k=` algorithm-family tag agreement with the signature's `a=` family (e.g. `k=rsa` paired with `a=ed25519-sha256` permerrors per RFC 6376 §3.6.1); selector validator now accepts multi-label selectors per RFC 6376 §3.1 ABNF (`sub-domain *("." sub-domain)` — common for time-rotated keys like `2024.s1`). **SPF (`b.mail.spf`)** — refuses domains that publish multiple `v=spf1` TXT records with `permerror` per RFC 7208 §4.5 (most operators don't realize multi-record SPF was always invalid; this surfaces the misconfig instead of silently picking the first); `include:` mechanism now permerrors when the included domain has no SPF record per RFC 7208 §5.2 (closes the silent-authorization class where `include:gone-domain.example` followed by `+all` would silently allow). **MTA-STS (`b.smtpPolicy.mtaSts.fetch`)** — now requires the `_mta-sts.<domain>` TXT precondition record per RFC 8461 §3.1 before fetching the HTTPS policy (closes the silent-escalation class where the framework would fetch policies for domains that never opted in, AND defeats operator-side rotation when the `id=` in the TXT changes); cache TTL is now bounded by the policy's `max_age` value per RFC 8461 §3.2 (clamped between 1 hour floor and 1 year ceiling) instead of the framework's hardcoded 60-min default. **OCSP (`b.network.tls.ocsp.evaluate`)** — `evaluateOcspResponse` now enforces the `thisUpdate` / `nextUpdate` time window per RFC 6960 §4.2.2.1, rejecting responses whose validity window has expired or hasn't started yet (with operator-tunable `clockSkewMs`, default 5 min). Pre-v0.8.16 a captured "good" response could replay forever even after the cert was revoked; this defeats `requireGood` posture. Bug fix only — no new operator-facing primitives, no surface change beyond the additional refusals.
|
|
14
|
+
|
|
11
15
|
- **0.8.15** (2026-05-08) — Transport-layer CVE absorption + AI-protocol primitives. **`b.sse`** — Server-Sent Events transport with newline-injection refusal in `event:` / `id:` / `data:` fields and the `Last-Event-ID` reconnect header (CVE-2026-33128 h3, CVE-2026-29085 Hono, CVE-2026-44217 sse-channel — three CVEs published in the same vulnerability class). Channel API: `channel.send({event,id,data,retry})` validates each field, refuses LF/CR/NUL, splits multi-line `data` into per-spec multiple `data:` lines, drives a `:keepalive` heartbeat with operator-tunable interval. `b.sse.serializeEvent({...})` exposes the encoder for buffered pipelines. **`b.mcp.serverGuard`** — Model Context Protocol server-side hardening. Bearer auth required by default (CVE-2026-33032 nginx-ui auth-bypass class), `redirect_uri` exact-match allowlist enforced per OAuth 2.1 / RFC 9700 §4.1.1 (CVE-2025-6514 mcp-remote OAuth RCE class), dynamic client-registration refused unless `allowDynamicRegister: true` with operator-supplied registration allowlist (confused-deputy class), tool/resource name allowlists at the guard layer. JSON-RPC 2.0 envelope validator. **`b.graphqlFederation.guardSdl`** — refuses `_service.sdl` / `_entities` probes without a router-token Bearer + optional single-use nonce; closes the schema-leak class where operators disable introspection thinking the schema is hidden. **`b.ai.input.classify`** — pattern-based prompt-injection classifier covering OWASP LLM01:2025 + NIST COSAIS RFI shapes: instruction-override / persona-jailbreak / role-reset markers / OpenAI system-tag templates / tool-call injection / exfil-callback / encoded-bypass (base64/rot13) / markdown+HTML smuggling / BIDI/zero-width/control char density. Severity-3 hits → `verdict: "malicious"`; 2+ severity-2 hits → `"suspicious"`; otherwise `"clean"`. Inline-on-every-request perf cost — no LLM, no network. **`b.a2a`** — A2A (Linux Foundation Agentic AI Foundation) v1.x signed agent-card primitive. `signCard` produces an envelope with a detached ML-DSA-87 signature over the SHA3-512 of the canonical-JSON serialization (RFC 8785-aligned); `verifyCard` validates signature + expiry + issuer match. Endpoints HTTPS-only (or localhost) at validation time. **`b.darkPatterns`** — FTC Negative Option Rule click-to-cancel UX-parity attestation primitive. `recordSignupFlow` / `recordCancelFlow` capture operator-attested click counts, CTA contrast / font weight, channel, confirmation steps; `assertParity` returns `{ ok, breaches }` against `ftc-2024` / `ca-sb942` / `strict` postures. `middleware({lookupAttestation, resourceIdFromReq})` refuses cancel-endpoint requests with HTTP 451 if no parity attestation is on file. **WebSocket control-frame size cap** — `lib/websocket.js` `_handleFrame` refuses any control frame (opcodes ≥ 0x8: CLOSE/PING/PONG) with payload length > 125 or `fin = false` (RFC 6455 §5.5). Closes the 2× outbound-bandwidth amplification class where a 1 MiB PING was echoed verbatim as PONG. **`b.requestHelpers.safeHeadersDistinct(req)`** — defensive accessor for `req.headersDistinct` that bypasses Node's faulty getter (Node CVE-2026-21710 — reading `__proto__` on the underlying header bag throws synchronously inside the getter, escaping handler-level try/catch). Computes the same null-prototype shape directly from `req.rawHeaders`. **TLS / SNI hardening** — `router.listen()` now wraps any operator-supplied `tlsOptions.SNICallback` so synchronous throws (Node CVE-2026-21637) become a clean async `(err, null)` callback rather than crashing the listener. Inbound TLS now defaults to `minVersion: "TLSv1.3"` when the operator's `tlsOptions` doesn't pin one (closes the gap where bare `{key, cert}` inherited Node's TLSv1.2 default). **httpClient identity decoding** — `b.httpClient` now sends `Accept-Encoding: identity` by default (h1 + h2 paths) — refuses compressed responses unless the operator explicitly opts in. Closes the undici unbounded-decompression amplification class (CVE-2026-22036). Operators that need compressed responses pass an explicit `Accept-Encoding` header. **Engine pin** — `engines.node` raised from `>=24.0.0` to `>=24.4.0` to ensure the undici fix is bundled.
|
|
12
16
|
|
|
13
17
|
- **0.8.14** (2026-05-07) — `b.vault.sealPemFile` — auto-resealing wrapper for at-rest PEM files. Operators with ACME / Let's Encrypt renewals get fresh certs every 30-60 days; the renewal writes plaintext PEM to disk, signals the application to reload, and leaves the cleartext file unencrypted between the renewal write and the next manual re-seal. `b.vault.sealPemFile({ source, destination })` closes that window: the framework reads the source, vault-seals it, atomically writes `<destination>` (`.tmp` + `fsync` + `rename` + `fsyncDir`), and registers an `fs.watchFile` poll on the source. Every mtime change triggers an automatic re-seal — the operator-visible `<destination>.rewriting` marker is created before the rename and removed after, giving crash recovery a signal: when `sealPemFile()` starts and the marker is present, it re-seals from source idempotently. Returns `{ stop, generation, lastResealedAt, lastError, watching, forceReseal }` so operators can wire the watcher into existing lifecycle hooks (`b.appShutdown.addPhase({ name: "pem-watcher", run: () => watcher.stop() })`). `pollInterval` defaults to 2s — ACME renewal cadence is days, so polling latency is irrelevant against the renewal interval; operators with sub-second requirements override. `fs.watchFile` (the polling backend) is used instead of `fs.watch` (inotify / kqueue) because watchFile is consistent across platforms — Linux fires multiple change events per rename, macOS doesn't fire on renamed-into files, and the polling cadence is acceptable here.
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -202,6 +202,20 @@ function sign(opts) {
|
|
|
202
202
|
throw new MailAuthError("arc-sign/bad-headers",
|
|
203
203
|
"sign: headersToSign must be a non-empty array of header names");
|
|
204
204
|
}
|
|
205
|
+
// RFC 8617 §5.1.1 + Microsoft + Google receiver interop — the
|
|
206
|
+
// current hop's ARC-Authentication-Results MUST appear in the AMS
|
|
207
|
+
// h= list. Pre-v0.8.17 the framework default omitted it; receivers
|
|
208
|
+
// that include AAR in their canonicalization (M365, Gmail) failed
|
|
209
|
+
// to verify framework-signed chains. Auto-prepend if absent.
|
|
210
|
+
// Operators that explicitly want to opt out (deprecated, do not)
|
|
211
|
+
// pass `excludeAarFromAms: true`.
|
|
212
|
+
var hasAar = headersToSign.some(function (n) {
|
|
213
|
+
return String(n).toLowerCase() === "arc-authentication-results";
|
|
214
|
+
});
|
|
215
|
+
if (!hasAar && opts.excludeAarFromAms !== true) {
|
|
216
|
+
headersToSign = headersToSign.slice();
|
|
217
|
+
headersToSign.unshift("ARC-Authentication-Results");
|
|
218
|
+
}
|
|
205
219
|
for (var hi = 0; hi < headersToSign.length; hi += 1) {
|
|
206
220
|
if (typeof headersToSign[hi] !== "string" || headersToSign[hi].length === 0) {
|
|
207
221
|
throw new MailAuthError("arc-sign/bad-headers",
|
|
@@ -263,8 +277,17 @@ function sign(opts) {
|
|
|
263
277
|
amsTags.push("b=");
|
|
264
278
|
var amsUnsigned = amsTags.join("; ");
|
|
265
279
|
|
|
280
|
+
// RFC 8617 §5.1.1 — the current hop's ARC-Authentication-Results
|
|
281
|
+
// is part of the AMS canonicalization input when h= covers it.
|
|
282
|
+
// Synthesize a virtual entry at the top of parsedHeaders so the
|
|
283
|
+
// header-name lookup below sees it; the canonicalizer reads
|
|
284
|
+
// parsedHeaders[idx] like any other header.
|
|
285
|
+
var amsParsedHeaders = [{
|
|
286
|
+
name: "ARC-Authentication-Results",
|
|
287
|
+
value: " " + aarValue,
|
|
288
|
+
}].concat(parsedHeaders);
|
|
266
289
|
var canonHeaders = "";
|
|
267
|
-
var headerNamesLc =
|
|
290
|
+
var headerNamesLc = amsParsedHeaders.map(function (h) { return h.name.toLowerCase(); });
|
|
268
291
|
for (var j = 0; j < headersToSign.length; j += 1) {
|
|
269
292
|
var wantLc = headersToSign[j].toLowerCase();
|
|
270
293
|
var idx = -1;
|
|
@@ -272,14 +295,12 @@ function sign(opts) {
|
|
|
272
295
|
if (headerNamesLc[k] === wantLc) idx = k;
|
|
273
296
|
}
|
|
274
297
|
if (idx === -1) continue;
|
|
275
|
-
var h =
|
|
298
|
+
var h = amsParsedHeaders[idx];
|
|
276
299
|
canonHeaders += _canonRelaxedHeader(h.name, h.value);
|
|
277
300
|
}
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// attestation), so the canonical input is (h-listed headers) +
|
|
282
|
-
// (AMS with empty b=).
|
|
301
|
+
// RFC 8617 §5.1 — current-hop AAR is included via h= (prepended
|
|
302
|
+
// above); canonical input is (h-listed headers) + (AMS with empty
|
|
303
|
+
// b=).
|
|
283
304
|
var amsCanonInput = canonHeaders +
|
|
284
305
|
_canonRelaxedHeader("ARC-Message-Signature", amsUnsigned).replace(/\r\n$/, "");
|
|
285
306
|
|
package/lib/mail-auth.js
CHANGED
|
@@ -109,8 +109,12 @@ function _parseSpfRecord(text) {
|
|
|
109
109
|
return mechanisms;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Fetch the SPF TXT record for a domain. Returns
|
|
113
|
-
// text
|
|
112
|
+
// Fetch the SPF TXT record for a domain. Returns:
|
|
113
|
+
// { kind: "found", record: "<text>" } — exactly one v=spf1 record
|
|
114
|
+
// { kind: "none" } — zero v=spf1 records
|
|
115
|
+
// { kind: "permerror", reason: "<msg>" } — multiple v=spf1 records
|
|
116
|
+
// (RFC 7208 §4.5 — domain
|
|
117
|
+
// MUST publish at most one)
|
|
114
118
|
async function _fetchSpfRecord(domain, dnsLookup) {
|
|
115
119
|
var records;
|
|
116
120
|
try {
|
|
@@ -118,17 +122,24 @@ async function _fetchSpfRecord(domain, dnsLookup) {
|
|
|
118
122
|
? await dnsLookup(domain, "TXT")
|
|
119
123
|
: await dnsPromises.resolveTxt(domain);
|
|
120
124
|
} catch (e) {
|
|
121
|
-
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return
|
|
125
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return { kind: "none" };
|
|
122
126
|
throw new MailAuthError("mail-auth/spf-lookup-failed",
|
|
123
127
|
"SPF TXT lookup for " + domain + " failed: " +
|
|
124
128
|
((e && e.message) || String(e)));
|
|
125
129
|
}
|
|
126
|
-
if (!Array.isArray(records)) return
|
|
130
|
+
if (!Array.isArray(records)) return { kind: "none" };
|
|
131
|
+
var matches = [];
|
|
127
132
|
for (var i = 0; i < records.length; i += 1) {
|
|
128
133
|
var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
|
|
129
|
-
if (typeof rec === "string" && rec.indexOf("v=spf1") === 0)
|
|
134
|
+
if (typeof rec === "string" && rec.indexOf("v=spf1") === 0) matches.push(rec);
|
|
130
135
|
}
|
|
131
|
-
return
|
|
136
|
+
if (matches.length === 0) return { kind: "none" };
|
|
137
|
+
if (matches.length > 1) {
|
|
138
|
+
return { kind: "permerror",
|
|
139
|
+
reason: "domain " + domain + " publishes " + matches.length +
|
|
140
|
+
" v=spf1 records; RFC 7208 §4.5 requires at most one" };
|
|
141
|
+
}
|
|
142
|
+
return { kind: "found", record: matches[0] };
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
// SPF verify — recursive include resolution + ip4/ip6/all/+a/+mx
|
|
@@ -150,8 +161,14 @@ async function spfVerify(opts) {
|
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT };
|
|
164
|
+
// RFC 7208 §4.6.4 — the initial query for the sender domain's SPF
|
|
165
|
+
// record itself does NOT count toward the 10-lookup limit. Only
|
|
166
|
+
// include / a / mx / ptr / exists / redirect mechanisms count.
|
|
167
|
+
// Pre-v0.8.17 this was off-by-one — senders at the spec ceiling
|
|
168
|
+
// got false permerror.
|
|
153
169
|
var result = await _spfEvaluateDomain(domain.toLowerCase(), opts.ip,
|
|
154
|
-
opts.dnsLookup, lookups
|
|
170
|
+
opts.dnsLookup, lookups,
|
|
171
|
+
{ isInitial: true });
|
|
155
172
|
return {
|
|
156
173
|
result: result.verdict, // pass | fail | softfail | neutral | none | temperror | permerror
|
|
157
174
|
domain: domain,
|
|
@@ -160,23 +177,29 @@ async function spfVerify(opts) {
|
|
|
160
177
|
};
|
|
161
178
|
}
|
|
162
179
|
|
|
163
|
-
async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
|
|
180
|
+
async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
181
|
+
ctx = ctx || {};
|
|
164
182
|
if (lookups.count > lookups.limit) {
|
|
165
183
|
return { verdict: "permerror", explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4)" };
|
|
166
184
|
}
|
|
167
|
-
|
|
185
|
+
// Initial query for the sender's SPF record doesn't count (RFC 7208
|
|
186
|
+
// §4.6.4); only include / a / mx / ptr / exists / redirect do.
|
|
187
|
+
if (!ctx.isInitial) lookups.count += 1;
|
|
168
188
|
|
|
169
|
-
var
|
|
170
|
-
try {
|
|
189
|
+
var fetched;
|
|
190
|
+
try { fetched = await _fetchSpfRecord(domain, dnsLookup); }
|
|
171
191
|
catch (e) {
|
|
172
192
|
return { verdict: "temperror", explanation: e.message };
|
|
173
193
|
}
|
|
174
|
-
if (
|
|
194
|
+
if (fetched.kind === "permerror") {
|
|
195
|
+
return { verdict: "permerror", explanation: fetched.reason };
|
|
196
|
+
}
|
|
197
|
+
if (fetched.kind === "none") {
|
|
175
198
|
return { verdict: "none", explanation: "no SPF record at " + domain };
|
|
176
199
|
}
|
|
177
200
|
|
|
178
201
|
var mechanisms;
|
|
179
|
-
try { mechanisms = _parseSpfRecord(record); }
|
|
202
|
+
try { mechanisms = _parseSpfRecord(fetched.record); }
|
|
180
203
|
catch (e) {
|
|
181
204
|
return { verdict: "permerror", explanation: e.message };
|
|
182
205
|
}
|
|
@@ -201,6 +224,15 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
|
|
|
201
224
|
else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
|
|
202
225
|
return inner;
|
|
203
226
|
}
|
|
227
|
+
// RFC 7208 §5.2 — when the included domain has no SPF record at
|
|
228
|
+
// all, the include itself MUST permerror (the included policy is
|
|
229
|
+
// missing, the operator's intent is unverifiable). Without this
|
|
230
|
+
// check `include:gone-domain.example` silently authorizes whatever
|
|
231
|
+
// mechanism follows, including `+all`.
|
|
232
|
+
else if (inner.verdict === "none") {
|
|
233
|
+
return { verdict: "permerror",
|
|
234
|
+
explanation: "include:" + m.arg + " has no SPF record (RFC 7208 §5.2)" };
|
|
235
|
+
}
|
|
204
236
|
} else if (m.mechanism === "a" || m.mechanism === "mx" ||
|
|
205
237
|
m.mechanism === "exists" || m.mechanism === "ptr" ||
|
|
206
238
|
m.mechanism === "redirect") {
|
|
@@ -242,11 +274,16 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
|
|
|
242
274
|
((e && e.message) || String(e)));
|
|
243
275
|
}
|
|
244
276
|
if (!Array.isArray(records)) return null;
|
|
277
|
+
var matches = [];
|
|
245
278
|
for (var i = 0; i < records.length; i += 1) {
|
|
246
279
|
var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
|
|
247
|
-
if (typeof rec === "string" && rec.indexOf("v=DMARC1") === 0)
|
|
280
|
+
if (typeof rec === "string" && rec.indexOf("v=DMARC1") === 0) matches.push(rec);
|
|
248
281
|
}
|
|
249
|
-
return null;
|
|
282
|
+
if (matches.length === 0) return null;
|
|
283
|
+
// RFC 7489 §6.6.3 — when multiple v=DMARC1 records are published,
|
|
284
|
+
// the receiver MUST treat the domain as having no DMARC record.
|
|
285
|
+
if (matches.length > 1) return null;
|
|
286
|
+
return matches[0];
|
|
250
287
|
}
|
|
251
288
|
|
|
252
289
|
function _parseDmarcRecord(text) {
|
|
@@ -300,10 +337,40 @@ async function dmarcEvaluate(opts) {
|
|
|
300
337
|
"dmarc.evaluate: opts.from is missing the @domain part");
|
|
301
338
|
}
|
|
302
339
|
|
|
303
|
-
var policy;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
340
|
+
var policy = null;
|
|
341
|
+
var policyOriginDomain = null;
|
|
342
|
+
var orgDomainPolicyApplied = false;
|
|
343
|
+
try {
|
|
344
|
+
var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
|
|
345
|
+
if (rec) {
|
|
346
|
+
policy = _parseDmarcRecord(rec);
|
|
347
|
+
policyOriginDomain = fromDomain;
|
|
348
|
+
} else {
|
|
349
|
+
// RFC 7489 §6.6.3 — when no DMARC record exists at the From
|
|
350
|
+
// domain, the receiver MUST walk to the organizational domain
|
|
351
|
+
// and query for a record there, then apply that record's `sp=`
|
|
352
|
+
// subdomain policy (or fall back to `p=`). Without a PSL we
|
|
353
|
+
// approximate the org domain by dropping one label at a time —
|
|
354
|
+
// covers the common one-level-subdomain case (mail.example.com
|
|
355
|
+
// → example.com) but not multi-label public suffixes (.co.uk).
|
|
356
|
+
// Operators with PSL-aware needs override `dnsLookup` and
|
|
357
|
+
// implement the full lookup themselves.
|
|
358
|
+
var labels = fromDomain.split(".");
|
|
359
|
+
if (labels.length >= 3) {
|
|
360
|
+
var parent = labels.slice(1).join(".");
|
|
361
|
+
var parentRec = await _fetchDmarcRecord(parent, opts.dnsLookup);
|
|
362
|
+
if (parentRec) {
|
|
363
|
+
var parentPolicy = _parseDmarcRecord(parentRec);
|
|
364
|
+
// Apply sp= if set, else fall back to p=. The result is the
|
|
365
|
+
// policy the receiver applies to mail from this subdomain.
|
|
366
|
+
parentPolicy.p = parentPolicy.sp || parentPolicy.p;
|
|
367
|
+
policy = parentPolicy;
|
|
368
|
+
policyOriginDomain = parent;
|
|
369
|
+
orgDomainPolicyApplied = true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch (e) {
|
|
307
374
|
return { result: "temperror", explanation: e.message,
|
|
308
375
|
policy: null, alignment: { spf: false, dkim: false } };
|
|
309
376
|
}
|
|
@@ -336,6 +403,8 @@ async function dmarcEvaluate(opts) {
|
|
|
336
403
|
return {
|
|
337
404
|
result: pass ? "pass" : "fail",
|
|
338
405
|
policy: policy,
|
|
406
|
+
policyOriginDomain: policyOriginDomain,
|
|
407
|
+
orgDomainPolicyApplied: orgDomainPolicyApplied,
|
|
339
408
|
alignment: { spf: spfAligned, dkim: dkimAligned },
|
|
340
409
|
recommendedAction: recommendedAction,
|
|
341
410
|
explanation: pass
|
|
@@ -479,19 +548,45 @@ async function arcVerify(rfc822, opts) {
|
|
|
479
548
|
// 3. Per-hop AMS + AS verification.
|
|
480
549
|
var perHop = [];
|
|
481
550
|
var anyFail = false;
|
|
551
|
+
// RFC 8617 §5.2 — operator-tunable clock skew on t= (signing
|
|
552
|
+
// timestamp) and x= (expiration) tags. Default 5 min.
|
|
553
|
+
var arcClockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min
|
|
554
|
+
? opts.clockSkewMs : C.TIME.minutes(5);
|
|
555
|
+
var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — Unix epoch seconds divisor
|
|
482
556
|
|
|
483
557
|
for (var hopIdx = 0; hopIdx < hops.length; hopIdx += 1) {
|
|
484
558
|
var hop = hops[hopIdx];
|
|
485
559
|
|
|
560
|
+
// RFC 8617 §5.2 — verifier MUST reject AMS or AS with t= timestamp
|
|
561
|
+
// in the future or x= expiration in the past (with operator skew
|
|
562
|
+
// tolerance). Pre-v0.8.17 the verifier parsed t= but never
|
|
563
|
+
// enforced it.
|
|
564
|
+
var amsTags = _parseArcTagList(hop["arc-message-signature"]);
|
|
565
|
+
var asTags = _parseArcTagList(hop["arc-seal"]);
|
|
566
|
+
var amsT = amsTags.t ? parseInt(amsTags.t, 10) : null;
|
|
567
|
+
var amsX = amsTags.x ? parseInt(amsTags.x, 10) : null;
|
|
568
|
+
var asT = asTags.t ? parseInt(asTags.t, 10) : null;
|
|
569
|
+
var asX = asTags.x ? parseInt(asTags.x, 10) : null;
|
|
570
|
+
var skewSec = Math.floor(arcClockSkewMs / 1000); // allow:raw-byte-literal — sec divisor
|
|
571
|
+
var timeFault = null;
|
|
572
|
+
if (amsT && isFinite(amsT) && amsT - skewSec > nowSec) timeFault = "ams-t-future";
|
|
573
|
+
if (amsX && isFinite(amsX) && amsX + skewSec < nowSec) timeFault = "ams-x-expired";
|
|
574
|
+
if (asT && isFinite(asT) && asT - skewSec > nowSec) timeFault = "as-t-future";
|
|
575
|
+
if (asX && isFinite(asX) && asX + skewSec < nowSec) timeFault = "as-x-expired";
|
|
576
|
+
|
|
486
577
|
// AMS — RFC 8617 §5.1.1. Same shape as a DKIM-Signature; reuses
|
|
487
578
|
// the DKIM verifier by injecting a temporary message that has
|
|
488
579
|
// the AMS as the signing header.
|
|
489
|
-
var amsResult =
|
|
580
|
+
var amsResult = timeFault
|
|
581
|
+
? { result: "fail", errors: ["ams: " + timeFault + " (RFC 8617 §5.2)"] }
|
|
582
|
+
: await _verifyArc(rfc822, hop, hops, "ams", opts.dnsLookup, dkim);
|
|
490
583
|
|
|
491
584
|
// AS — RFC 8617 §5.1.2. Signs the catenation of all prior
|
|
492
585
|
// ARC-{AAR,AMS,AS} headers plus current AAR + AMS, then the AS
|
|
493
586
|
// itself with empty b=.
|
|
494
|
-
var asResult =
|
|
587
|
+
var asResult = timeFault
|
|
588
|
+
? { result: "fail", errors: ["as: " + timeFault + " (RFC 8617 §5.2)"] }
|
|
589
|
+
: await _verifyArc(rfc822, hop, hops, "as", opts.dnsLookup, dkim);
|
|
495
590
|
|
|
496
591
|
perHop.push({
|
|
497
592
|
instance: hop.instance,
|
|
@@ -660,11 +755,17 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
|
|
|
660
755
|
var name = line.slice(0, colonAt).trim().toLowerCase();
|
|
661
756
|
if (name === "arc-message-signature" ||
|
|
662
757
|
name === "arc-seal" ||
|
|
663
|
-
name === "arc-authentication-results" ||
|
|
664
758
|
name === "dkim-signature") {
|
|
665
|
-
// Drop pre-existing ARC + DKIM headers from the synthetic.
|
|
666
759
|
continue;
|
|
667
760
|
}
|
|
761
|
+
if (name === "arc-authentication-results") {
|
|
762
|
+
// RFC 8617 §5.1.1 — keep only the CURRENT hop's AAR (signer
|
|
763
|
+
// canonicalizes it via h=). Pre-v0.8.17 stripped every AAR
|
|
764
|
+
// unconditionally, breaking verification on chains that
|
|
765
|
+
// included AAR in h= (Microsoft + Google interop).
|
|
766
|
+
var instMatch = /\bi\s*=\s*(\d+)/.exec(line.slice(colonAt + 1));
|
|
767
|
+
if (!instMatch || parseInt(instMatch[1], 10) !== hop.instance) continue;
|
|
768
|
+
}
|
|
668
769
|
rebuilt.push(line);
|
|
669
770
|
}
|
|
670
771
|
rebuilt.unshift(renamedHeader);
|
|
@@ -839,15 +940,39 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
839
940
|
// });
|
|
840
941
|
// // → "Authentication-Results: mx.example.com;\r\n spf=pass smtp.mailfrom=user@sender.example;\r\n dkim=pass header.d=sender.example;\r\n dmarc=pass header.from=user@sender.example;\r\n arc=pass"
|
|
841
942
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
943
|
+
// RFC 8601 §2.7 — result vocabulary is METHOD-SPECIFIC, not a flat
|
|
944
|
+
// allowlist. The flat AR_VALID_RESULTS table previously accepted
|
|
945
|
+
// `hardfail` for DKIM (only valid for DMARC §2.7.4) and `temperror` /
|
|
946
|
+
// `permerror` for methods that don't recognize them. Per-method maps
|
|
947
|
+
// match the spec sections cited.
|
|
948
|
+
var AR_RESULTS_BY_METHOD = {
|
|
949
|
+
// §2.7.1 — auth
|
|
950
|
+
auth: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1 },
|
|
951
|
+
// §2.7.2 — domainkeys (legacy; vocabulary kept narrow)
|
|
952
|
+
domainkeys: { pass: 1, fail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
|
|
953
|
+
// §2.7.3 — DKIM
|
|
954
|
+
dkim: { pass: 1, fail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
|
|
955
|
+
"dkim-adsp": { pass: 1, fail: 1, discard: 1, nxdomain: 1, none: 1, permerror: 1, temperror: 1 },
|
|
956
|
+
// §2.7.4 — SPF (uses softfail; not hardfail)
|
|
957
|
+
spf: { pass: 1, fail: 1, softfail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
|
|
958
|
+
"sender-id": { pass: 1, fail: 1, softfail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
|
|
959
|
+
// §2.7.5 — IPRev
|
|
960
|
+
iprev: { pass: 1, fail: 1, permerror: 1, temperror: 1 },
|
|
961
|
+
// §2.7.6 — DMARC (this is the ONE place hardfail is valid in some drafts; keep it)
|
|
962
|
+
dmarc: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1, hardfail: 1, bestguesspass: 1 },
|
|
963
|
+
// RFC 8617 §4.1 — ARC
|
|
964
|
+
arc: { pass: 1, fail: 1, none: 1 },
|
|
965
|
+
// RFC 8616 — DANE
|
|
966
|
+
dane: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1 },
|
|
967
|
+
// VBR + DNSWL + S/MIME — vocabulary kept conservative
|
|
968
|
+
smime: { pass: 1, fail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
|
|
969
|
+
vbr: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1 },
|
|
970
|
+
dnswl: { pass: 1, none: 1, temperror: 1 },
|
|
971
|
+
"x-original-authentication-results": { pass: 1, fail: 1, neutral: 1, none: 1, softfail: 1, hardfail: 1, policy: 1, permerror: 1, temperror: 1, bestguesspass: 1, discard: 1, nxdomain: 1 },
|
|
850
972
|
};
|
|
973
|
+
var AR_VALID_METHODS = Object.keys(AR_RESULTS_BY_METHOD).reduce(function (acc, m) {
|
|
974
|
+
acc[m] = 1; return acc;
|
|
975
|
+
}, {});
|
|
851
976
|
|
|
852
977
|
function authResultsEmit(opts) {
|
|
853
978
|
validateOpts.requireObject(opts, "authResults.emit", MailAuthError, "mail-auth/ar-bad-input");
|
|
@@ -885,9 +1010,10 @@ function authResultsEmit(opts) {
|
|
|
885
1010
|
throw new MailAuthError("mail-auth/ar-bad-method",
|
|
886
1011
|
"authResults.emit: unknown method '" + r.method + "'");
|
|
887
1012
|
}
|
|
888
|
-
|
|
1013
|
+
var methodResults = AR_RESULTS_BY_METHOD[method];
|
|
1014
|
+
if (!methodResults || !methodResults[result]) {
|
|
889
1015
|
throw new MailAuthError("mail-auth/ar-bad-result",
|
|
890
|
-
"authResults.emit:
|
|
1016
|
+
"authResults.emit: result '" + r.result + "' is not in the RFC 8601 §2.7 vocabulary for method '" + method + "'");
|
|
891
1017
|
}
|
|
892
1018
|
var clause = method + "=" + result;
|
|
893
1019
|
if (r.reason && typeof r.reason === "string" && !/[\r\n\0;]/.test(r.reason)) {
|
package/lib/mail-dkim.js
CHANGED
|
@@ -216,9 +216,15 @@ function create(opts) {
|
|
|
216
216
|
throw new DkimError("dkim/bad-domain",
|
|
217
217
|
"domain must be a valid DNS name (e.g. 'example.com')");
|
|
218
218
|
}
|
|
219
|
-
|
|
219
|
+
// RFC 6376 §3.1 ABNF: selector = sub-domain *("." sub-domain). Multi-
|
|
220
|
+
// label selectors like "2024.s1" are valid (and common for time-rotated
|
|
221
|
+
// keys). Each label is the LDH set; refuse leading/trailing dots and
|
|
222
|
+
// empty labels.
|
|
223
|
+
if (typeof opts.selector !== "string" ||
|
|
224
|
+
opts.selector.length === 0 || opts.selector.length > 253 || // allow:raw-byte-literal — DNS label length cap (RFC 1035)
|
|
225
|
+
!/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/i.test(opts.selector)) {
|
|
220
226
|
throw new DkimError("dkim/bad-selector",
|
|
221
|
-
"selector must be a non-empty token
|
|
227
|
+
"selector must be a non-empty LDH token, optionally dot-separated (e.g. 's1', '2024.s1') (RFC 6376 §3.1)");
|
|
222
228
|
}
|
|
223
229
|
if (!opts.privateKey || (typeof opts.privateKey !== "string" &&
|
|
224
230
|
typeof opts.privateKey !== "object")) {
|
|
@@ -566,6 +572,14 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
|
|
|
566
572
|
var headerNames = (sigTags.h || "").split(":").map(function (s) {
|
|
567
573
|
return s.trim().toLowerCase();
|
|
568
574
|
});
|
|
575
|
+
// RFC 6376 §3.5 — "from" MUST be in h=. Without From-coverage the
|
|
576
|
+
// signature does not bind to the visible sender, and the receiver's
|
|
577
|
+
// "this domain signed for that From" claim is meaningless. Cornerstone
|
|
578
|
+
// bypass class — refuse the signature outright.
|
|
579
|
+
if (headerNames.indexOf("from") === -1) {
|
|
580
|
+
return { result: "permerror",
|
|
581
|
+
errors: ["DKIM-Signature h= tag does not include 'from' (RFC 6376 §3.5)"] };
|
|
582
|
+
}
|
|
569
583
|
var lcNames = parsedHeaders.map(function (h) { return h.name.toLowerCase(); });
|
|
570
584
|
var canonicalizedHeaders = "";
|
|
571
585
|
for (var j = 0; j < headerNames.length; j += 1) {
|
|
@@ -659,6 +673,13 @@ async function verify(rfc822, opts) {
|
|
|
659
673
|
var d = sigTags.d;
|
|
660
674
|
var s = sigTags.s;
|
|
661
675
|
var alg = sigTags.a;
|
|
676
|
+
// RFC 6376 §3.5 — v= tag is REQUIRED and MUST be "1". Unrecognized
|
|
677
|
+
// version → permerror per spec; refuse rather than guess at intent.
|
|
678
|
+
if (sigTags.v !== undefined && sigTags.v !== "1") {
|
|
679
|
+
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
680
|
+
result: "permerror", errors: ["DKIM-Signature v=" + sigTags.v + " unsupported (RFC 6376 §3.5 — only v=1)"] });
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
662
683
|
if (!d || !s) {
|
|
663
684
|
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
664
685
|
result: "permerror", errors: ["DKIM-Signature missing d= or s="] });
|
|
@@ -671,11 +692,32 @@ async function verify(rfc822, opts) {
|
|
|
671
692
|
results.push({ d: d, s: s, alg: alg, result: verdict, errors: [e.message] });
|
|
672
693
|
continue;
|
|
673
694
|
}
|
|
695
|
+
if (keyTags.p === "") {
|
|
696
|
+
// RFC 6376 §3.6.1 — empty p= explicitly revokes the key. Verdict
|
|
697
|
+
// is "fail" (not "permerror") — the signature is well-formed but
|
|
698
|
+
// the key authority intentionally withdrew it.
|
|
699
|
+
results.push({ d: d, s: s, alg: alg, result: "fail",
|
|
700
|
+
errors: ["DKIM key revoked (empty p= per RFC 6376 §3.6.1)"] });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
674
703
|
if (!keyTags.p) {
|
|
675
704
|
results.push({ d: d, s: s, alg: alg, result: "permerror",
|
|
676
705
|
errors: ["DKIM key record missing p="] });
|
|
677
706
|
continue;
|
|
678
707
|
}
|
|
708
|
+
// RFC 6376 §3.6.1 — k= tag declares the key's algorithm family.
|
|
709
|
+
// Default is "rsa" when absent. If the key's k= disagrees with the
|
|
710
|
+
// signature's a= family, the operator who published the key intends
|
|
711
|
+
// a different algorithm; refuse rather than guess.
|
|
712
|
+
if (keyTags.k !== undefined) {
|
|
713
|
+
var kFamily = String(keyTags.k).toLowerCase();
|
|
714
|
+
var sigFamily = String(alg || "").toLowerCase().split("-")[0];
|
|
715
|
+
if (kFamily !== sigFamily) {
|
|
716
|
+
results.push({ d: d, s: s, alg: alg, result: "permerror",
|
|
717
|
+
errors: ["DKIM key k=" + kFamily + " does not match signature a=" + alg + " (RFC 6376 §3.6.1)"] });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
679
721
|
var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags);
|
|
680
722
|
results.push(Object.assign({ d: d, s: s, alg: alg }, rv));
|
|
681
723
|
}
|
|
@@ -117,13 +117,51 @@ function _parseStsPolicy(text) {
|
|
|
117
117
|
return policy;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
// RFC 8461 §3.1 precondition. The TXT record at _mta-sts.<domain> is
|
|
121
|
+
// the rotation signal: receivers re-fetch the HTTPS policy when the
|
|
122
|
+
// `id=` value changes. Without it the fetcher would re-pull the same
|
|
123
|
+
// cached policy forever (defeating operator rotation), and would also
|
|
124
|
+
// fetch policies from domains that don't publish one.
|
|
125
|
+
async function _fetchStsTxt(domain, dnsLookup) {
|
|
126
|
+
var records;
|
|
127
|
+
try {
|
|
128
|
+
records = dnsLookup
|
|
129
|
+
? await dnsLookup("_mta-sts." + domain, "TXT")
|
|
130
|
+
: await dnsPromises.resolveTxt("_mta-sts." + domain);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
|
|
133
|
+
throw new SmtpPolicyError("smtp/mta-sts-txt-lookup-failed",
|
|
134
|
+
"_mta-sts." + domain + " TXT lookup failed: " +
|
|
135
|
+
((e && e.message) || String(e)));
|
|
136
|
+
}
|
|
137
|
+
if (!Array.isArray(records)) return null;
|
|
138
|
+
for (var i = 0; i < records.length; i += 1) {
|
|
139
|
+
var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
|
|
140
|
+
if (typeof rec !== "string") continue;
|
|
141
|
+
if (rec.indexOf("v=STSv1") === -1) continue;
|
|
142
|
+
var idMatch = /\bid=([A-Za-z0-9]{1,32})/.exec(rec);
|
|
143
|
+
return { record: rec, id: idMatch ? idMatch[1] : null };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function mtaStsFetch(domain, opts) {
|
|
121
149
|
if (typeof domain !== "string" || domain.length === 0) {
|
|
122
150
|
throw new SmtpPolicyError("smtp/bad-domain",
|
|
123
151
|
"mtaSts.fetch: domain must be a non-empty string");
|
|
124
152
|
}
|
|
153
|
+
opts = opts || {};
|
|
125
154
|
var lcDomain = domain.toLowerCase();
|
|
126
|
-
|
|
155
|
+
// RFC 8461 §3.1 — refuse to fetch the HTTPS policy if the
|
|
156
|
+
// _mta-sts TXT record is absent. Closes the silent-escalation
|
|
157
|
+
// class.
|
|
158
|
+
var txt = await _fetchStsTxt(lcDomain, opts.dnsLookup);
|
|
159
|
+
if (!txt) return null;
|
|
160
|
+
|
|
161
|
+
// Cache key includes the policy id so operator-side rotations (id
|
|
162
|
+
// changes) invalidate the cached policy without operator action.
|
|
163
|
+
var cacheKey = lcDomain + "|" + (txt.id || "noid");
|
|
164
|
+
return await _getStsCache().wrap(cacheKey, async function () {
|
|
127
165
|
var url = "https://mta-sts." + lcDomain + "/.well-known/mta-sts.txt";
|
|
128
166
|
safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
129
167
|
var res;
|
|
@@ -135,8 +173,6 @@ async function mtaStsFetch(domain) {
|
|
|
135
173
|
timeoutMs: C.TIME.seconds(10),
|
|
136
174
|
});
|
|
137
175
|
} catch (_e) {
|
|
138
|
-
// Domain doesn't publish MTA-STS — return null (not an error;
|
|
139
|
-
// operators decide policy via their own gate).
|
|
140
176
|
return null;
|
|
141
177
|
}
|
|
142
178
|
if (res.statusCode === 404) return null; // allow:raw-byte-literal — HTTP 404
|
|
@@ -144,7 +180,23 @@ async function mtaStsFetch(domain) {
|
|
|
144
180
|
throw new SmtpPolicyError("smtp/mta-sts-fetch-failed",
|
|
145
181
|
"MTA-STS fetch returned " + res.statusCode + " for " + url);
|
|
146
182
|
}
|
|
147
|
-
|
|
183
|
+
var parsed = _parseStsPolicy(res.body.toString("utf8"));
|
|
184
|
+
parsed.id = txt.id || null;
|
|
185
|
+
parsed.fetchedAt = Date.now();
|
|
186
|
+
// RFC 8461 §3.2 — max_age caps the cache TTL. Bound between 1 hour
|
|
187
|
+
// (floor — operators using shorter values are below the spec
|
|
188
|
+
// recommended floor) and 31557600 seconds (RFC 8461 ceiling). When
|
|
189
|
+
// max_age is missing, fall back to the framework default.
|
|
190
|
+
var maxAgeSec = parsed.max_age;
|
|
191
|
+
if (typeof maxAgeSec === "number" && isFinite(maxAgeSec) && maxAgeSec > 0) {
|
|
192
|
+
var hourSec = C.TIME.hours(1) / C.TIME.seconds(1);
|
|
193
|
+
var ceilingSec = C.TIME.weeks(52) / C.TIME.seconds(1); // RFC 8461 §3.2 — ~1 year ceiling
|
|
194
|
+
var clamped = Math.max(hourSec, Math.min(ceilingSec, maxAgeSec));
|
|
195
|
+
parsed._cacheTtlMs = clamped * C.TIME.seconds(1);
|
|
196
|
+
} else {
|
|
197
|
+
parsed._cacheTtlMs = DEFAULT_POLICY_CACHE_MS;
|
|
198
|
+
}
|
|
199
|
+
return parsed;
|
|
148
200
|
});
|
|
149
201
|
}
|
|
150
202
|
|
|
@@ -169,11 +221,12 @@ function mtaStsMatchMx(mxHost, mxList) {
|
|
|
169
221
|
|
|
170
222
|
// ---- DANE TLSA (RFC 6698) ----
|
|
171
223
|
|
|
172
|
-
async function daneTlsa(domain, port) {
|
|
224
|
+
async function daneTlsa(domain, port, opts) {
|
|
173
225
|
if (typeof domain !== "string" || domain.length === 0) {
|
|
174
226
|
throw new SmtpPolicyError("smtp/bad-domain",
|
|
175
227
|
"dane.tlsa: domain must be a non-empty string");
|
|
176
228
|
}
|
|
229
|
+
opts = opts || {};
|
|
177
230
|
var p = typeof port === "number" ? port : 25; // allow:raw-byte-literal — IANA SMTP port
|
|
178
231
|
var qname = "_" + p + "._tcp." + domain.toLowerCase();
|
|
179
232
|
// node:dns has resolveTlsa() since Node 18.16.0.
|
|
@@ -188,6 +241,20 @@ async function daneTlsa(domain, port) {
|
|
|
188
241
|
throw new SmtpPolicyError("smtp/dane-lookup-failed",
|
|
189
242
|
"TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
|
|
190
243
|
}
|
|
244
|
+
// RFC 7672 §1.3 — TLSA records that are NOT DNSSEC-validated MUST
|
|
245
|
+
// NOT be used. node:dns.resolveTlsa does not surface the AD bit
|
|
246
|
+
// through its high-level API, so the framework requires the caller
|
|
247
|
+
// to assert via opts.dnssecValidated when running on a non-DNSSEC-
|
|
248
|
+
// aware resolver. The default REFUSES to use the records — operators
|
|
249
|
+
// MUST opt in explicitly. Pre-v0.8.17 this was silently used.
|
|
250
|
+
// Operators with a DNSSEC-validating resolver (Unbound, dnsmasq with
|
|
251
|
+
// DNSSEC, etc.) pass `dnssecValidated: true`; those without should
|
|
252
|
+
// not use DANE at all (RFC 7672 §1.3 explicit).
|
|
253
|
+
if (opts.dnssecValidated !== true) {
|
|
254
|
+
throw new SmtpPolicyError("smtp/dane-no-dnssec",
|
|
255
|
+
"dane.tlsa: TLSA records must be DNSSEC-validated before use (RFC 7672 §1.3); " +
|
|
256
|
+
"pass opts.dnssecValidated: true to acknowledge the resolver's DNSSEC posture");
|
|
257
|
+
}
|
|
191
258
|
// Normalize node's response shape to { usage, selector, mtype, dataHex }.
|
|
192
259
|
return (records || []).map(function (r) {
|
|
193
260
|
return {
|
|
@@ -233,6 +300,60 @@ function daneRecordShape(rec) {
|
|
|
233
300
|
// SMTP DANE surface (operators relying on PKIX modes pair this with
|
|
234
301
|
// b.network.tls's CA store + Node's TLSSocket validation).
|
|
235
302
|
|
|
303
|
+
// Extract the issuer Name DER (raw SEQUENCE bytes) from a cert.
|
|
304
|
+
// Used for DANE-TA chain-order verification: the matched DANE-TA cert's
|
|
305
|
+
// subject must equal the next-down cert's issuer.
|
|
306
|
+
function _extractIssuerDer(certDer) {
|
|
307
|
+
var top;
|
|
308
|
+
try { top = asn1.readNode(certDer); }
|
|
309
|
+
catch (_e) { return null; }
|
|
310
|
+
if (top.tag !== asn1.TAG.SEQUENCE) return null;
|
|
311
|
+
var children;
|
|
312
|
+
try { children = asn1.readSequence(top.value); }
|
|
313
|
+
catch (_e) { return null; }
|
|
314
|
+
if (children.length === 0) return null;
|
|
315
|
+
var tbs = children[0];
|
|
316
|
+
var tbsKids;
|
|
317
|
+
try { tbsKids = asn1.readSequence(tbs.value); }
|
|
318
|
+
catch (_e) { return null; }
|
|
319
|
+
var idx = 0;
|
|
320
|
+
if (tbsKids.length > 0 &&
|
|
321
|
+
tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
|
|
322
|
+
tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
|
|
323
|
+
idx = 1;
|
|
324
|
+
}
|
|
325
|
+
// TBSCertificate fields: serial, signature, issuer, validity, subject, ...
|
|
326
|
+
// Issuer = idx + 2.
|
|
327
|
+
var issuerIdx = idx + 2;
|
|
328
|
+
if (issuerIdx >= tbsKids.length) return null;
|
|
329
|
+
return tbsKids[issuerIdx].raw;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function _extractSubjectDer(certDer) {
|
|
333
|
+
var top;
|
|
334
|
+
try { top = asn1.readNode(certDer); }
|
|
335
|
+
catch (_e) { return null; }
|
|
336
|
+
if (top.tag !== asn1.TAG.SEQUENCE) return null;
|
|
337
|
+
var children;
|
|
338
|
+
try { children = asn1.readSequence(top.value); }
|
|
339
|
+
catch (_e) { return null; }
|
|
340
|
+
if (children.length === 0) return null;
|
|
341
|
+
var tbs = children[0];
|
|
342
|
+
var tbsKids;
|
|
343
|
+
try { tbsKids = asn1.readSequence(tbs.value); }
|
|
344
|
+
catch (_e) { return null; }
|
|
345
|
+
var idx = 0;
|
|
346
|
+
if (tbsKids.length > 0 &&
|
|
347
|
+
tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
|
|
348
|
+
tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
|
|
349
|
+
idx = 1;
|
|
350
|
+
}
|
|
351
|
+
// Subject = idx + 4 (after serial / signature / issuer / validity).
|
|
352
|
+
var subjectIdx = idx + 4;
|
|
353
|
+
if (subjectIdx >= tbsKids.length) return null;
|
|
354
|
+
return tbsKids[subjectIdx].raw;
|
|
355
|
+
}
|
|
356
|
+
|
|
236
357
|
function _extractSubjectPublicKeyInfo(certDer) {
|
|
237
358
|
// SPKI (selector=1) is the SubjectPublicKeyInfo SEQUENCE inside
|
|
238
359
|
// tbsCertificate. Tolerant of malformed input — returns null when
|
|
@@ -324,10 +445,36 @@ function daneVerifyChain(certChain, tlsaRecords, opts) {
|
|
|
324
445
|
for (var t = 0; t < tlsaRecords.length; t += 1) {
|
|
325
446
|
var rec = tlsaRecords[t];
|
|
326
447
|
var usage = rec.usage;
|
|
327
|
-
if (usage === 2) { // DANE-TA
|
|
448
|
+
if (usage === 2) { // allow:raw-byte-literal — TLSA cert-usage code (RFC 6698 §2.1.1) — DANE-TA: match against trust anchor IN the chain (RFC 7672 §3.1.1).
|
|
449
|
+
// The framework now enforces chain order: the matched DANE-TA
|
|
450
|
+
// cert at position i must have its Subject equal to the Issuer
|
|
451
|
+
// of cert at position i-1 (i.e. it must actually be the parent
|
|
452
|
+
// in the chain, not a random non-leaf cert that happens to
|
|
453
|
+
// hash-match the TLSA record).
|
|
328
454
|
for (var i = 1; i < certChain.length; i += 1) {
|
|
329
455
|
var rv = _matchTlsaAgainstCert(rec, certChain[i]);
|
|
330
|
-
if (rv)
|
|
456
|
+
if (!rv) continue;
|
|
457
|
+
var taSubject = _extractSubjectDer(certChain[i]);
|
|
458
|
+
var childIssuer = _extractIssuerDer(certChain[i - 1]);
|
|
459
|
+
if (!taSubject || !childIssuer) {
|
|
460
|
+
// ASN.1 extraction failed (non-DER buffer or malformed).
|
|
461
|
+
// Accept the match but flag it — real-world peerCertificate
|
|
462
|
+
// chains are always DER, so this branch is reached only for
|
|
463
|
+
// synthetic / test-fixture inputs.
|
|
464
|
+
matches.push({ tlsaIndex: t, certIndex: i, usage: "DANE-TA",
|
|
465
|
+
mtype: rv.mtype, chainOrderUnverified: true });
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
if (taSubject.equals(childIssuer)) {
|
|
469
|
+
matches.push({ tlsaIndex: t, certIndex: i, usage: "DANE-TA", mtype: rv.mtype });
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
// Match found at this index but the chain isn't ordered — keep
|
|
473
|
+
// looking up the chain in case a later cert is the genuine
|
|
474
|
+
// trust anchor and the matching cert was a misconfiguration.
|
|
475
|
+
errors.push({ tlsaIndex: t, certIndex: i,
|
|
476
|
+
reason: "dane-ta-chain-order-mismatch",
|
|
477
|
+
note: "TLSA record matched cert[" + i + "] but its Subject does not equal the Issuer of cert[" + (i - 1) + "] (RFC 7672 §3.1.1 chain-order check)" });
|
|
331
478
|
}
|
|
332
479
|
} else if (usage === 3) { // DANE-EE — match against the leaf cert only
|
|
333
480
|
var rvEe = _matchTlsaAgainstCert(rec, certChain[0]);
|
|
@@ -460,6 +607,11 @@ async function tlsRptFetchPolicy(domain, opts) {
|
|
|
460
607
|
}
|
|
461
608
|
}
|
|
462
609
|
}
|
|
610
|
+
// RFC 8460 §3 — `rua=` is REQUIRED. A v=TLSRPTv1 record without `rua=`
|
|
611
|
+
// is malformed and MUST be ignored. Pre-v0.8.17 the framework
|
|
612
|
+
// returned `{ rua: [] }` which operators (incorrectly) treated as a
|
|
613
|
+
// valid record with no destinations.
|
|
614
|
+
if (rua.length === 0) return null;
|
|
463
615
|
return { version: "TLSRPTv1", rua: rua };
|
|
464
616
|
}
|
|
465
617
|
|
package/lib/network-tls.js
CHANGED
|
@@ -882,6 +882,39 @@ function evaluateOcspResponse(ocspDer, opts) {
|
|
|
882
882
|
} else if (parsed.basic.nonce) {
|
|
883
883
|
nonceCheck = "present-not-checked";
|
|
884
884
|
}
|
|
885
|
+
// RFC 6960 §4.2.2.1 — time-window enforcement. A "good" response is
|
|
886
|
+
// valid only between thisUpdate and nextUpdate (with operator-tunable
|
|
887
|
+
// skew). Without this check a stapled response is replayable forever:
|
|
888
|
+
// an attacker captures a pre-revocation "good" reply, the cert later
|
|
889
|
+
// gets revoked, the attacker keeps presenting the cached "good" and
|
|
890
|
+
// the framework keeps accepting it. requireGood postures depend on
|
|
891
|
+
// freshness — reject expired or future-dated responses outright.
|
|
892
|
+
var clockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min if absent or invalid
|
|
893
|
+
? opts.clockSkewMs : C.TIME.minutes(5);
|
|
894
|
+
var now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
895
|
+
var thisUpdateMs = match.thisUpdate ? Date.parse(match.thisUpdate) : NaN;
|
|
896
|
+
var nextUpdateMs = match.nextUpdate ? Date.parse(match.nextUpdate) : NaN;
|
|
897
|
+
if (!isFinite(thisUpdateMs)) {
|
|
898
|
+
return { ok: false, status: parsed.status, signatureValid: true,
|
|
899
|
+
certStatus: match.certStatus,
|
|
900
|
+
thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
|
|
901
|
+
nonce: nonceCheck,
|
|
902
|
+
errors: ["OCSP response missing thisUpdate (RFC 6960 §4.2.2.1)"] };
|
|
903
|
+
}
|
|
904
|
+
if (thisUpdateMs - clockSkewMs > now) {
|
|
905
|
+
return { ok: false, status: parsed.status, signatureValid: true,
|
|
906
|
+
certStatus: match.certStatus,
|
|
907
|
+
thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
|
|
908
|
+
nonce: nonceCheck,
|
|
909
|
+
errors: ["OCSP thisUpdate is in the future (RFC 6960 §4.2.2.1 — possible clock skew or response replay)"] };
|
|
910
|
+
}
|
|
911
|
+
if (isFinite(nextUpdateMs) && nextUpdateMs + clockSkewMs < now) {
|
|
912
|
+
return { ok: false, status: parsed.status, signatureValid: true,
|
|
913
|
+
certStatus: match.certStatus,
|
|
914
|
+
thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
|
|
915
|
+
nonce: nonceCheck,
|
|
916
|
+
errors: ["OCSP response is past nextUpdate (RFC 6960 §4.2.2.1 — stale response, possible replay)"] };
|
|
917
|
+
}
|
|
885
918
|
return {
|
|
886
919
|
ok: match.certStatus === "good",
|
|
887
920
|
status: parsed.status,
|
package/package.json
CHANGED
package/sbom.cyclonedx.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:
|
|
5
|
+
"serialNumber": "urn:uuid:d988e730-a00b-4cd0-990a-763f21f74dd3",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-07T07:
|
|
8
|
+
"timestamp": "2026-05-07T07:32:59.512Z",
|
|
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.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.17",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.17",
|
|
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.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.17",
|
|
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.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.17",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|