@blamejs/core 0.14.22 → 0.14.24

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/lib/mail-auth.js CHANGED
@@ -2090,6 +2090,239 @@ function authResultsEmit(opts) {
2090
2090
  return "Authentication-Results: " + head + ";\r\n " + clauses.join(sep);
2091
2091
  }
2092
2092
 
2093
+ // ---- Inbound message-authentication pipeline (RFC 7489 §6.6) ----
2094
+ //
2095
+ // One call runs the receiver-side authentication set on a message as it
2096
+ // arrives: SPF (RFC 7208) on the envelope identity, DKIM (RFC 6376) on
2097
+ // the message bytes, DMARC (RFC 7489 / DMARCbis) policy + alignment on
2098
+ // the From-header domain, and — when an authserv-id is supplied — the
2099
+ // RFC 8601 Authentication-Results header the receiver prepends before
2100
+ // delivery. b.mail.server.mx composes this at DATA time via its
2101
+ // guardEnvelope opt; operators running their own listeners (or doing
2102
+ // post-delivery verification in an agent) call it directly:
2103
+ //
2104
+ // var v = await b.mail.inbound.verify({
2105
+ // ip: "203.0.113.5",
2106
+ // helo: "mail.sender.example",
2107
+ // mailFrom: "bounce@sender.example",
2108
+ // message: rfc5322Bytes, // string or Buffer
2109
+ // authservId: "mx.example.com",
2110
+ // });
2111
+ // // → { spf, dkim, from, dmarc, authResults }
2112
+ // if (v.dmarc.recommendedAction === "reject") { /* refuse 550 5.7.1 */ }
2113
+ //
2114
+ // From-header discipline (RFC 7489 §6.6.1): DMARC evaluates exactly one
2115
+ // author domain. A message with zero From fields, several From fields,
2116
+ // or several author addresses in one field is the header-duplication
2117
+ // spoofing shape — an attacker pairs an aligned-but-hidden From with the
2118
+ // one the mail client displays (the CVE-2024-7208 / CVE-2024-7209
2119
+ // hosted-relay spoofing class rides on exactly this ambiguity). Those
2120
+ // messages return `dmarc.result: "permerror"` with
2121
+ // `recommendedAction: "reject"` instead of picking one of the Froms.
2122
+
2123
+ // RFC 5322 §2.1 — the header block ends at the first empty line. SMTP
2124
+ // wire format is CRLF; bare-LF input is accepted defensively for
2125
+ // operator-fed strings that lost CRs in their own tooling.
2126
+ function _splitHeaderBlock(message) {
2127
+ var idx = message.indexOf("\r\n\r\n");
2128
+ if (idx !== -1) return { headers: message.slice(0, idx), body: message.slice(idx + 4) };
2129
+ idx = message.indexOf("\n\n");
2130
+ if (idx !== -1) return { headers: message.slice(0, idx), body: message.slice(idx + 2) };
2131
+ return { headers: message, body: "" };
2132
+ }
2133
+
2134
+ // Quote-aware single pass over a From field value (RFC 5322 phrase
2135
+ // quoting): counts angle-addr pairs that contain an `@` (a `<` inside
2136
+ // a quoted-string is display-name text — `"John <Jr.> Smith" <u@d>`
2137
+ // is one author, not two) and top-level commas (address-list
2138
+ // separators; a comma inside a quoted display-name like
2139
+ // `"Doe, John" <j@d>` does not count). Records the content of the
2140
+ // last @-bearing angle-addr for extraction.
2141
+ function _countFromAuthors(value) {
2142
+ var inQuote = false, inAngle = false, escaped = false;
2143
+ var angleAddrs = 0, topCommas = 0, angleStart = -1;
2144
+ var lastAddr = null;
2145
+ for (var i = 0; i < value.length; i += 1) {
2146
+ var ch = value.charAt(i);
2147
+ if (escaped) { escaped = false; continue; }
2148
+ if (ch === "\\") { escaped = true; continue; }
2149
+ if (ch === "\"" && !inAngle) { inQuote = !inQuote; continue; }
2150
+ if (inQuote) continue;
2151
+ if (ch === "<" && !inAngle) { inAngle = true; angleStart = i; continue; }
2152
+ if (ch === ">" && inAngle) {
2153
+ inAngle = false;
2154
+ var inner = value.slice(angleStart + 1, i).trim();
2155
+ if (inner.indexOf("@") !== -1) { angleAddrs += 1; lastAddr = inner; }
2156
+ continue;
2157
+ }
2158
+ if (ch === "," && !inAngle) topCommas += 1;
2159
+ }
2160
+ return { angleAddrs: angleAddrs, topCommas: topCommas, lastAddr: lastAddr };
2161
+ }
2162
+
2163
+ // Unfold (RFC 5322 §2.2.3), collect every From: field, and extract the
2164
+ // author address. `count` is the number of From fields, widened by
2165
+ // multiple-author detection inside a single field: several @-bearing
2166
+ // angle-addrs, or a bare address-list separated by top-level commas
2167
+ // (RFC 7489 §6.6.1 — a multi-author From is the header-duplication
2168
+ // spoofing shape and must not have "one" author picked from it).
2169
+ function _extractFromHeaders(headerBlock) {
2170
+ var unfolded = headerBlock.replace(/\r?\n[ \t]+/g, " ");
2171
+ var lines = unfolded.split(/\r?\n/);
2172
+ var fromValues = [];
2173
+ for (var i = 0; i < lines.length; i += 1) {
2174
+ var m = /^From[ \t]*:(.*)$/i.exec(lines[i]);
2175
+ if (m) fromValues.push(m[1].trim());
2176
+ }
2177
+ if (fromValues.length === 0) return { count: 0, address: null, domain: null };
2178
+ var count = fromValues.length;
2179
+ var value = fromValues[0];
2180
+ var authors = _countFromAuthors(value);
2181
+ if (count === 1) {
2182
+ if (authors.angleAddrs > 1) count = authors.angleAddrs;
2183
+ else if (authors.topCommas > 0) count = authors.topCommas + 1;
2184
+ }
2185
+ var address;
2186
+ if (authors.angleAddrs >= 1) {
2187
+ // count > 1 is refused by the caller before the address is used;
2188
+ // for the single-author case this is that author's angle-addr.
2189
+ address = authors.lastAddr;
2190
+ } else {
2191
+ // Bare addr-spec form. An RFC 5322 addr-spec cannot contain
2192
+ // whitespace or commas — their presence means an address list or
2193
+ // display-name soup; extracting "the" domain from it would pick
2194
+ // one of several authors (the §6.6.1 forbidden move), so the
2195
+ // field is treated as unparsable instead.
2196
+ address = value.trim();
2197
+ if (/[\s,]/.test(address)) address = null;
2198
+ }
2199
+ var at = address ? address.lastIndexOf("@") : -1;
2200
+ var domain = (at > 0 && address && at < address.length - 1)
2201
+ ? address.slice(at + 1).toLowerCase()
2202
+ : null;
2203
+ return { count: count, address: address || null, domain: domain };
2204
+ }
2205
+
2206
+ async function inboundVerify(opts) {
2207
+ validateOpts.requireObject(opts, "inbound.verify", MailAuthError, "mail-auth/inbound-bad-input");
2208
+ validateOpts(opts, ["ip", "helo", "mailFrom", "message", "dnsLookup", "domainExists",
2209
+ "maxSignatures", "clockSkewMs", "minRsaBits", "authservId"],
2210
+ "mail.inbound.verify");
2211
+ validateOpts.requireNonEmptyString(opts.ip, "inbound.verify: ip",
2212
+ MailAuthError, "mail-auth/inbound-bad-ip");
2213
+ if (opts.authservId !== undefined && opts.authservId !== null) {
2214
+ validateOpts.requireNonEmptyString(opts.authservId, "inbound.verify: authservId",
2215
+ MailAuthError, "mail-auth/inbound-bad-authserv-id");
2216
+ }
2217
+ var message = opts.message;
2218
+ if (Buffer.isBuffer(message)) {
2219
+ // DKIM canonicalization re-encodes the string form as UTF-8
2220
+ // (lib/mail-dkim.js hashes Buffer.from(canonicalized, "utf8")), so
2221
+ // the byte→string decode must be utf8 for valid-UTF-8 content to
2222
+ // round-trip exactly. Non-UTF-8 8-bit content cannot survive any
2223
+ // decode + utf8 re-encode; such messages verify as DKIM fail and
2224
+ // DMARC falls back to the SPF identity (RFC 7489 §4.2 — one
2225
+ // aligned authenticator is sufficient to pass).
2226
+ message = message.toString("utf8");
2227
+ }
2228
+ if (typeof message !== "string" || message.length === 0) {
2229
+ throw new MailAuthError("mail-auth/inbound-bad-message",
2230
+ "inbound.verify: message must be a non-empty string or Buffer (the full RFC 5322 message)");
2231
+ }
2232
+ var mailFrom = (typeof opts.mailFrom === "string" && opts.mailFrom.length > 0) ? opts.mailFrom : null;
2233
+ var helo = (typeof opts.helo === "string" && opts.helo.length > 0) ? opts.helo : null;
2234
+
2235
+ // SPF — envelope identity: MAIL FROM, falling back to HELO for the
2236
+ // null reverse-path (RFC 7208 §2.4). DNS failures surface as the
2237
+ // RFC's temperror result, not as throws.
2238
+ var spf;
2239
+ if (mailFrom || helo) {
2240
+ spf = await spfVerify({
2241
+ ip: opts.ip,
2242
+ mailFrom: mailFrom || undefined,
2243
+ helo: helo || undefined,
2244
+ dnsLookup: opts.dnsLookup,
2245
+ });
2246
+ } else {
2247
+ spf = { result: "none", domain: null,
2248
+ explanation: "no MAIL FROM or HELO identity supplied", lookupCount: 0 };
2249
+ }
2250
+
2251
+ // DKIM — every signature on the message (bounded by maxSignatures;
2252
+ // a signature-less message verifies as a single `none` entry).
2253
+ var dkimVerifyOpts = { dnsLookup: opts.dnsLookup };
2254
+ if (opts.clockSkewMs !== undefined) dkimVerifyOpts.clockSkewMs = opts.clockSkewMs;
2255
+ if (opts.maxSignatures !== undefined) dkimVerifyOpts.maxSignatures = opts.maxSignatures;
2256
+ if (opts.minRsaBits !== undefined) dkimVerifyOpts.minRsaBits = opts.minRsaBits;
2257
+ var dkimResults = await dkim.verify(message, dkimVerifyOpts);
2258
+
2259
+ // From header + DMARC policy/alignment.
2260
+ var from = _extractFromHeaders(_splitHeaderBlock(message).headers);
2261
+ var dmarc;
2262
+ if (from.count === 1 && from.address && from.domain) {
2263
+ dmarc = await dmarcEvaluate({
2264
+ from: from.address,
2265
+ spf: spf,
2266
+ dkim: dkimResults,
2267
+ dnsLookup: opts.dnsLookup,
2268
+ domainExists: opts.domainExists,
2269
+ });
2270
+ // RFC 7489 §6.6.2 — a fail verdict computed while an authenticator
2271
+ // returned temperror is not final: the very lookup that failed
2272
+ // transiently could have produced the aligned pass. Surface
2273
+ // temperror so the caller defers (the sender retries) instead of
2274
+ // permanently refusing a legitimate sender during a DNS blip. A
2275
+ // pass verdict stands — one aligned authenticator is sufficient
2276
+ // regardless of the other's transient failure.
2277
+ if (dmarc.result === "fail" &&
2278
+ (spf.result === "temperror" ||
2279
+ dkimResults.some(function (d) { return d.result === "temperror"; }))) {
2280
+ dmarc.result = "temperror";
2281
+ dmarc.recommendedAction = null;
2282
+ dmarc.explanation = (dmarc.explanation ? dmarc.explanation + "; " : "") +
2283
+ "fail computed while an authenticator returned temperror — transient, retry";
2284
+ }
2285
+ } else {
2286
+ dmarc = {
2287
+ result: "permerror",
2288
+ recommendedAction: "reject",
2289
+ policy: null,
2290
+ alignment: { spf: false, dkim: false },
2291
+ orgDomain: null,
2292
+ explanation: from.count === 0
2293
+ ? "message has no From header (RFC 7489 §6.6.1)"
2294
+ : (from.count > 1
2295
+ ? "message carries " + from.count + " From authors (RFC 7489 §6.6.1 — multi-From spoofing shape)"
2296
+ : "From header has no parsable author domain"),
2297
+ };
2298
+ }
2299
+
2300
+ // RFC 8601 Authentication-Results — only when the caller identifies
2301
+ // itself (the authserv-id is the receiver's own name; there is no
2302
+ // sensible default the framework could invent).
2303
+ var authResults = null;
2304
+ if (opts.authservId) {
2305
+ var arResults = [];
2306
+ var spfEntry = { method: "spf", result: spf.result };
2307
+ if (mailFrom) spfEntry.smtpMailfrom = mailFrom;
2308
+ else if (helo) spfEntry.smtpHelo = helo;
2309
+ arResults.push(spfEntry);
2310
+ for (var di = 0; di < dkimResults.length; di += 1) {
2311
+ var d = dkimResults[di];
2312
+ var dkimEntry = { method: "dkim", result: d.result };
2313
+ if (typeof d.d === "string" && d.d.length > 0) dkimEntry.domain = d.d;
2314
+ if (typeof d.s === "string" && d.s.length > 0) dkimEntry.selector = d.s;
2315
+ arResults.push(dkimEntry);
2316
+ }
2317
+ var dmarcEntry = { method: "dmarc", result: dmarc.result };
2318
+ if (from.address) dmarcEntry.from = from.address;
2319
+ arResults.push(dmarcEntry);
2320
+ authResults = authResultsEmit({ authservId: opts.authservId, results: arResults });
2321
+ }
2322
+
2323
+ return { spf: spf, dkim: dkimResults, from: from, dmarc: dmarc, authResults: authResults };
2324
+ }
2325
+
2093
2326
  // ---- DMARC aggregate (RUA) report parser (RFC 7489 §7.2 / draft-ietf-dmarc-aggregate-reporting) ----
2094
2327
  //
2095
2328
  // MTAs that publish a DMARC `rua=` policy receive aggregate reports
@@ -2971,6 +3204,9 @@ module.exports = {
2971
3204
  authResults: Object.freeze({
2972
3205
  emit: authResultsEmit,
2973
3206
  }),
3207
+ inbound: Object.freeze({
3208
+ verify: inboundVerify,
3209
+ }),
2974
3210
  MailAuthError: MailAuthError,
2975
3211
  SPF_DNS_LOOKUP_LIMIT: SPF_DNS_LOOKUP_LIMIT,
2976
3212
  };
package/lib/mail-dkim.js CHANGED
@@ -1242,6 +1242,7 @@ module.exports = {
1242
1242
  RSA_MIN_BITS: RSA_MIN_BITS,
1243
1243
  RSA_LEGACY_MIN_BITS: RSA_LEGACY_MIN_BITS,
1244
1244
  DKIM_MAX_SIGNATURES_PER_MESSAGE: DKIM_MAX_SIGNATURES_PER_MESSAGE,
1245
+ DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING: DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING,
1245
1246
  DKIM_CLOCK_SKEW_MS_MAX: DKIM_CLOCK_SKEW_MS_MAX,
1246
1247
  _canonHeaderRelaxedForTest: _canonHeaderRelaxed,
1247
1248
  _canonBodyRelaxedForTest: _canonBodyRelaxed,
@@ -73,6 +73,8 @@
73
73
  * - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
74
74
  * - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
75
75
  * - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
76
+ * - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC results + action (accept / quarantine / reject / defer) + gate mode
77
+ * - `mail.server.mx.envelope_error` — DATA-phase authentication pipeline failure or timeout (disposition follows onTemperror)
76
78
  * - `mail.server.mx.delivered` — agent.handoff ack
77
79
  * - `mail.server.mx.tls_handshake_failed` — handshake error
78
80
  * - `mail.server.mx.smtp_smuggling_detected` — CRLF.CRLF injection class
@@ -111,13 +113,20 @@
111
113
  * (connecting-IP DNS blocklist, evaluated once per connection) and
112
114
  * `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
113
115
  * RCPT TO and surface their verdicts on the `rcpt_to` event. The
114
- * message-authentication gates (`b.guardEnvelope` SPF/DKIM/DMARC
115
- * alignment) require the inbound SPF + DKIM verification results as
116
- * inputs; that inbound-auth pipeline composes `b.mail.spf` +
117
- * `b.mail.dmarc` + DKIM verification and lands as a follow-up, at
118
- * which point the DATA-phase envelope/DMARC gate wires in. Until
119
- * then operators run those checks on the delivered message via the
120
- * agent handoff.
116
+ * message-authentication gate (`opts.guardEnvelope`) runs at DATA
117
+ * completion through `b.mail.inbound.verify` SPF (RFC 7208) on the
118
+ * envelope identity, DKIM (RFC 6376) on the message bytes, DMARC
119
+ * (RFC 7489) policy + alignment on the From-header domain and in
120
+ * enforce mode refuses before the agent handoff: 550 5.7.26
121
+ * (RFC 7372) when the sender's published policy says reject, 550
122
+ * 5.7.1 on the RFC 7489 §6.6.1 multi-From spoofing shape, 451 4.7.0
123
+ * on DNS temperror or pipeline timeout (operator-tunable via
124
+ * `onTemperror` / `timeoutMs`). Accepted messages carry the verdict
125
+ * to the agent handoff as `auth` and gain the receiver's RFC 8601
126
+ * Authentication-Results header — any sender-attached header forging
127
+ * this receiver's authserv-id is stripped first (§5) — so downstream
128
+ * consumers act on authenticated results instead of re-verifying;
129
+ * monitor mode annotates without refusing.
121
130
  *
122
131
  * @card
123
132
  * Inbound SMTP / MX listener. RFC 5321 state machine with SMTP-
@@ -144,6 +153,12 @@ var mailServerTls = require("./mail-server-tls");
144
153
  var { defineClass } = require("./framework-error");
145
154
 
146
155
  var audit = lazyRequire(function () { return require("./audit"); });
156
+ // Lazy like the sibling host primitives' guard loads — the inbound
157
+ // authentication pipeline (and the DKIM verifier whose range
158
+ // constants the boot validation mirrors) only loads when an operator
159
+ // wires opts.guardEnvelope.
160
+ var mailAuth = lazyRequire(function () { return require("./mail-auth"); });
161
+ var dkim = lazyRequire(function () { return require("./mail-dkim"); });
147
162
 
148
163
  var MailServerMxError = defineClass("MailServerMxError", { alwaysPermanent: true });
149
164
 
@@ -178,6 +193,59 @@ var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
178
193
  var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
179
194
  var RE_SIZE = /SIZE=(\d+)/i;
180
195
 
196
+ // Map the b.mail.inbound.verify verdict to the DATA-phase gate action.
197
+ // The sender's published DMARC policy drives it (RFC 7489 §6.3 p= /
198
+ // §6.6.2 disposition): reject → refuse at the wire; quarantine →
199
+ // deliver annotated (an MX cannot spam-folder — the downstream agent
200
+ // owns disposition); none / pass → accept. DNS temperror defers or
201
+ // accepts per the operator's onTemperror choice. permerror carries a
202
+ // reject recommendation only for the multi-From spoofing shape
203
+ // (RFC 7489 §6.6.1), set by the pipeline itself.
204
+ function _envelopeActionFor(inbound, gate) {
205
+ var dmarc = inbound.dmarc || {};
206
+ if (dmarc.result === "temperror") {
207
+ return gate.onTemperror === "accept" ? "accept" : "defer";
208
+ }
209
+ if (dmarc.recommendedAction === "reject") return "reject";
210
+ if (dmarc.recommendedAction === "quarantine") return "quarantine";
211
+ return "accept";
212
+ }
213
+
214
+ // RFC 8601 §5 — an MTA adding its own Authentication-Results header
215
+ // MUST first remove any existing instance claiming its authserv-id: a
216
+ // sender can pre-attach a forged header carrying the receiver's name
217
+ // ("Authentication-Results: mx.example.com; dmarc=pass") and downstream
218
+ // consumers that trust the receiver's A-R header would read the forged
219
+ // verdict instead of the computed one. Headers naming OTHER
220
+ // authserv-ids are prior-hop information and stay. Operates on the
221
+ // header block only — the block is decoded as latin1 (byte-preserving
222
+ // round-trip) and the body bytes are never decoded at all, so 8-bit
223
+ // content is untouched.
224
+ function _stripForgedAuthResults(messageBuf, authservId) {
225
+ if (!authservId) return messageBuf;
226
+ var sepIdx = messageBuf.indexOf("\r\n\r\n");
227
+ var headerEnd = sepIdx === -1 ? messageBuf.length : sepIdx + 2;
228
+ var head = messageBuf.slice(0, headerEnd).toString("latin1");
229
+ var rest = messageBuf.slice(headerEnd);
230
+ if (head.toLowerCase().indexOf("authentication-results:") === -1) return messageBuf;
231
+ var lines = head.split("\r\n");
232
+ var out = [];
233
+ var skipping = false;
234
+ var prefix = "authentication-results:";
235
+ var wantId = authservId.toLowerCase();
236
+ for (var i = 0; i < lines.length; i += 1) {
237
+ var line = lines[i];
238
+ if (skipping && (line.charAt(0) === " " || line.charAt(0) === "\t")) continue; // folded continuation
239
+ skipping = false;
240
+ if (line.slice(0, prefix.length).toLowerCase() === prefix) {
241
+ var idTok = line.slice(prefix.length).trim().split(/[;\s]/)[0].toLowerCase();
242
+ if (idTok === wantId) { skipping = true; continue; }
243
+ }
244
+ out.push(line);
245
+ }
246
+ return Buffer.concat([Buffer.from(out.join("\r\n"), "latin1"), rest]);
247
+ }
248
+
181
249
  /**
182
250
  * @primitive b.mail.server.mx.create
183
251
  * @signature b.mail.server.mx.create(opts)
@@ -202,6 +270,16 @@ var RE_SIZE = /SIZE=(\d+)/i;
202
270
  * maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
203
271
  * idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
204
272
  * profile: "strict" | "balanced" | "permissive", // gate posture cascade
273
+ * guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC via b.mail.inbound.verify
274
+ * mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
275
+ * onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
276
+ * authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
277
+ * dnsLookup?: function, // async (qname, type) override for SPF/DKIM/DMARC lookups
278
+ * maxSignatures?: number, // DKIM verify cap (1-16)
279
+ * clockSkewMs?: number, // DKIM timestamp skew tolerance
280
+ * minRsaBits?: number, // DKIM minimum RSA key size
281
+ * timeoutMs?: number, // pipeline wall-clock ceiling; default 20s (timeout → temperror disposition)
282
+ * },
205
283
  *
206
284
  * @example
207
285
  * var tls = b.network.tls.context({ cert: certPem, key: keyPem });
@@ -269,6 +347,92 @@ function create(opts) {
269
347
  rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
270
348
  }
271
349
 
350
+ // DATA-phase message-authentication gate. `guardEnvelope: true`
351
+ // gates with defaults; an object tunes it. Like the sibling gates
352
+ // (helo / rbl / greylist) the phase is skipped when the operator
353
+ // doesn't wire it — the gate needs live DNS to evaluate the
354
+ // sender's published policy, which closed-network deployments may
355
+ // not have.
356
+ var envelopeGate = null;
357
+ if (opts.guardEnvelope !== undefined && opts.guardEnvelope !== false) {
358
+ if (opts.guardEnvelope !== true &&
359
+ (typeof opts.guardEnvelope !== "object" || opts.guardEnvelope === null ||
360
+ Array.isArray(opts.guardEnvelope))) {
361
+ throw new MailServerMxError("mail-server-mx/bad-opts",
362
+ "mail.server.mx.create: guardEnvelope must be true, false, or a config object");
363
+ }
364
+ var ge = opts.guardEnvelope === true ? {} : opts.guardEnvelope;
365
+ validateOpts(ge, ["mode", "onTemperror", "authservId", "dnsLookup",
366
+ "maxSignatures", "clockSkewMs", "minRsaBits", "timeoutMs"],
367
+ "mail.server.mx.guardEnvelope");
368
+ var geMode = (ge.mode === undefined || ge.mode === null)
369
+ ? (profile === "permissive" ? "monitor" : "enforce")
370
+ : ge.mode;
371
+ if (geMode !== "enforce" && geMode !== "monitor") {
372
+ throw new MailServerMxError("mail-server-mx/bad-opts",
373
+ "mail.server.mx.create: guardEnvelope.mode must be 'enforce' or 'monitor'");
374
+ }
375
+ var geOnTemperror = (ge.onTemperror === undefined || ge.onTemperror === null)
376
+ ? "defer" : ge.onTemperror;
377
+ if (geOnTemperror !== "defer" && geOnTemperror !== "accept") {
378
+ throw new MailServerMxError("mail-server-mx/bad-opts",
379
+ "mail.server.mx.create: guardEnvelope.onTemperror must be 'defer' or 'accept'");
380
+ }
381
+ if (ge.authservId !== undefined && ge.authservId !== null) {
382
+ validateOpts.requireNonEmptyString(ge.authservId,
383
+ "mail.server.mx.create: guardEnvelope.authservId",
384
+ MailServerMxError, "mail-server-mx/bad-opts");
385
+ }
386
+ if (ge.dnsLookup !== undefined && ge.dnsLookup !== null &&
387
+ typeof ge.dnsLookup !== "function") {
388
+ throw new MailServerMxError("mail-server-mx/bad-opts",
389
+ "mail.server.mx.create: guardEnvelope.dnsLookup must be a function");
390
+ }
391
+ // DKIM bounds caught at boot, not at the first DATA — mirroring
392
+ // the exact ranges b.mail.dkim.verify enforces per call, so an
393
+ // operator typo fails startup instead of turning every live
394
+ // message into an envelope_error + temperror disposition.
395
+ numericBounds.requireAllPositiveFiniteIntIfPresent(ge,
396
+ ["maxSignatures", "clockSkewMs", "minRsaBits", "timeoutMs"],
397
+ "mail.server.mx.guardEnvelope.", MailServerMxError, "mail-server-mx/bad-bound");
398
+ if (ge.maxSignatures !== undefined && ge.maxSignatures !== null &&
399
+ ge.maxSignatures > dkim().DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING) {
400
+ throw new MailServerMxError("mail-server-mx/bad-bound",
401
+ "mail.server.mx.create: guardEnvelope.maxSignatures " + ge.maxSignatures +
402
+ " exceeds the DKIM verifier ceiling " +
403
+ dkim().DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING +
404
+ " (RFC 6376 §6.1 fan-out DoS bound)");
405
+ }
406
+ if (ge.clockSkewMs !== undefined && ge.clockSkewMs !== null &&
407
+ ge.clockSkewMs > dkim().DKIM_CLOCK_SKEW_MS_MAX) {
408
+ throw new MailServerMxError("mail-server-mx/bad-bound",
409
+ "mail.server.mx.create: guardEnvelope.clockSkewMs " + ge.clockSkewMs +
410
+ " exceeds the DKIM verifier ceiling " + dkim().DKIM_CLOCK_SKEW_MS_MAX +
411
+ " (RFC 6376 §3.5 back-dating replay defense)");
412
+ }
413
+ envelopeGate = Object.freeze({
414
+ mode: geMode,
415
+ onTemperror: geOnTemperror,
416
+ // RFC 8601 authserv-id — the receiver's own name on the
417
+ // Authentication-Results header. Defaults to the first local
418
+ // domain; with neither, the header is skipped (the verdict
419
+ // still reaches the agent handoff).
420
+ authservId: ge.authservId || localDomains[0] || null,
421
+ dnsLookup: ge.dnsLookup || undefined,
422
+ maxSignatures: ge.maxSignatures,
423
+ clockSkewMs: ge.clockSkewMs,
424
+ minRsaBits: ge.minRsaBits,
425
+ // Wall-clock ceiling for the whole pipeline (SPF include chains
426
+ // + per-signature DKIM key fetches + DMARC policy walk). A
427
+ // message stuffed with signatures pointing at slow resolvers
428
+ // must not pin the connection slot — on timeout the message
429
+ // takes the temperror disposition (defer / accept per
430
+ // onTemperror).
431
+ timeoutMs: (ge.timeoutMs === undefined || ge.timeoutMs === null)
432
+ ? C.TIME.seconds(20) : ge.timeoutMs,
433
+ });
434
+ }
435
+
272
436
  // Default-on operator-supplied-domain hardening. opts.localDomains
273
437
  // and the HELO / MAIL FROM / RCPT TO domain validations all route
274
438
  // through `b.guardDomain` for IDN homograph / Punycode-spoof defense
@@ -885,6 +1049,110 @@ function create(opts) {
885
1049
  return;
886
1050
  }
887
1051
  }
1052
+ // DATA-phase message authentication (opts.guardEnvelope) — SPF /
1053
+ // DKIM / DMARC through b.mail.inbound.verify, refusing before the
1054
+ // agent handoff so a policy-failing message never reaches storage.
1055
+ var inboundAuth = null;
1056
+ if (envelopeGate) {
1057
+ var inboundVerdict = null;
1058
+ try {
1059
+ // Wall-clock ceiling around the whole pipeline — a message
1060
+ // stuffed with signatures pointing at slow resolvers must
1061
+ // not pin the connection slot. Timeout surfaces as
1062
+ // SafeAsyncError(async/timeout) into the catch below.
1063
+ inboundVerdict = await safeAsync.withTimeout(
1064
+ mailAuth().inbound.verify({
1065
+ ip: state.remoteAddress,
1066
+ helo: state.helo || undefined,
1067
+ mailFrom: state.mailFrom || undefined,
1068
+ message: dedotted,
1069
+ dnsLookup: envelopeGate.dnsLookup,
1070
+ maxSignatures: envelopeGate.maxSignatures,
1071
+ clockSkewMs: envelopeGate.clockSkewMs,
1072
+ minRsaBits: envelopeGate.minRsaBits,
1073
+ authservId: envelopeGate.authservId || undefined,
1074
+ }),
1075
+ envelopeGate.timeoutMs,
1076
+ { name: "mail.server.mx.guardEnvelope" });
1077
+ } catch (err) {
1078
+ // Pipeline infrastructure failure or wall-clock timeout (not
1079
+ // an authentication verdict). Same disposition as a DNS
1080
+ // temperror: defer so the sender retries, or accept
1081
+ // unauthenticated when the operator chose availability via
1082
+ // onTemperror.
1083
+ _emit("mail.server.mx.envelope_error", {
1084
+ connectionId: state.id,
1085
+ mailFrom: state.mailFrom,
1086
+ error: (err && err.message) || String(err),
1087
+ }, "failure");
1088
+ if (envelopeGate.mode === "enforce" && envelopeGate.onTemperror === "defer") {
1089
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
1090
+ "4.7.0 Message authentication could not be completed; try again later");
1091
+ _resetTransaction(state);
1092
+ return;
1093
+ }
1094
+ }
1095
+ if (inboundVerdict) {
1096
+ var envAction = _envelopeActionFor(inboundVerdict, envelopeGate);
1097
+ var dkimSummary = inboundVerdict.dkim.some(function (d) { return d.result === "pass"; })
1098
+ ? "pass"
1099
+ : (inboundVerdict.dkim[0] ? inboundVerdict.dkim[0].result : "none");
1100
+ _emit("mail.server.mx.envelope_verdict", {
1101
+ connectionId: state.id,
1102
+ mailFrom: state.mailFrom,
1103
+ fromDomain: inboundVerdict.from.domain,
1104
+ spf: inboundVerdict.spf.result,
1105
+ dkim: dkimSummary,
1106
+ dmarc: inboundVerdict.dmarc.result,
1107
+ action: envAction,
1108
+ mode: envelopeGate.mode,
1109
+ }, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
1110
+ if (envelopeGate.mode === "enforce" && envAction === "reject") {
1111
+ // RFC 7372 §3.2 — 5.7.26 ("multiple authentication checks
1112
+ // failed") for a DMARC evaluation that failed; the
1113
+ // multi-From / unparsable-author permerror shape is a
1114
+ // message-acceptability refusal and keeps the generic
1115
+ // 5.7.1.
1116
+ var enhanced = inboundVerdict.dmarc.result === "fail" ? "5.7.26" : "5.7.1";
1117
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
1118
+ enhanced + " Message refused by sender authentication policy (DMARC " +
1119
+ inboundVerdict.dmarc.result + "; SPF " + inboundVerdict.spf.result +
1120
+ ", DKIM " + dkimSummary + ")");
1121
+ _resetTransaction(state);
1122
+ return;
1123
+ }
1124
+ if (envelopeGate.mode === "enforce" && envAction === "defer") {
1125
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
1126
+ "4.7.0 Sender authentication temporarily unavailable (DNS); try again later");
1127
+ _resetTransaction(state);
1128
+ return;
1129
+ }
1130
+ // Accept / quarantine / monitor mode: the verdict rides to
1131
+ // the agent handoff as `auth`, and the receiver's RFC 8601
1132
+ // Authentication-Results header is prepended so downstream
1133
+ // consumers (spam-foldering quarantined mail included) act
1134
+ // on authenticated results instead of re-verifying.
1135
+ if (inboundVerdict.authResults) {
1136
+ // RFC 8601 §5 — strip any sender-attached A-R header
1137
+ // claiming this receiver's authserv-id before prepending
1138
+ // the computed one (forged-verdict shadowing defense).
1139
+ dedotted = _stripForgedAuthResults(dedotted, envelopeGate.authservId);
1140
+ dedotted = Buffer.concat([
1141
+ Buffer.from(inboundVerdict.authResults + "\r\n", "utf8"),
1142
+ dedotted,
1143
+ ]);
1144
+ }
1145
+ inboundAuth = {
1146
+ spf: inboundVerdict.spf,
1147
+ dkim: inboundVerdict.dkim,
1148
+ dmarc: inboundVerdict.dmarc,
1149
+ from: inboundVerdict.from,
1150
+ action: envAction,
1151
+ mode: envelopeGate.mode,
1152
+ quarantine: envAction === "quarantine",
1153
+ };
1154
+ }
1155
+ }
888
1156
  // operator-supplied agent handoff — when wired, persist via
889
1157
  // agent + write the 250 reply. When not wired, accept-and-drop
890
1158
  // (audit-only mode useful for staging deployments).
@@ -897,6 +1165,7 @@ function create(opts) {
897
1165
  remote: { address: state.remoteAddress, port: state.remotePort },
898
1166
  tls: state.tls,
899
1167
  helo: state.helo,
1168
+ auth: inboundAuth,
900
1169
  connectionId: state.id,
901
1170
  }).then(function (ack) {
902
1171
  _emit("mail.server.mx.delivered",
package/lib/mail.js CHANGED
@@ -1944,15 +1944,19 @@ module.exports = {
1944
1944
  // default, ed25519-sha256 opt-in). Wire it into the smtp transport
1945
1945
  // via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
1946
1946
  dkim: dkim,
1947
- // Inbound mail authentication-results verification: SPF (RFC 7208),
1948
- // DMARC (RFC 7489), ARC (RFC 8617). Outbound DKIM signing lives in
1949
- // .dkim above; per-hop DKIM verification is deferred (composes with
1950
- // the existing canonicalization helpers in lib/mail-dkim.js).
1947
+ // Inbound mail authentication verification: SPF (RFC 7208), DKIM
1948
+ // verify (RFC 6376, on .dkim above alongside outbound signing),
1949
+ // DMARC (RFC 7489), ARC (RFC 8617). `.inbound.verify` is the
1950
+ // one-call receiver pipeline SPF + DKIM + From-header extraction +
1951
+ // DMARC policy + the RFC 8601 Authentication-Results header —
1952
+ // composed by b.mail.server.mx at DATA time via its guardEnvelope
1953
+ // opt and callable directly by operator-built listeners.
1951
1954
  spf: mailAuth.spf,
1952
1955
  dmarc: mailAuth.dmarc,
1953
1956
  arc: mailAuth.arc,
1954
1957
  iprev: mailAuth.iprev,
1955
1958
  authResults: mailAuth.authResults,
1959
+ inbound: mailAuth.inbound,
1956
1960
  bimi: mailBimi,
1957
1961
  // Test-only export: lets unit tests inspect the wire format without
1958
1962
  // standing up a TLS-capable SMTP fixture. Operators don't call this.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.22",
3
+ "version": "0.14.24",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",