@blamejs/core 0.7.24 → 0.7.39

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.
@@ -0,0 +1,661 @@
1
+ "use strict";
2
+ /**
3
+ * b.mail.spf + b.mail.dmarc + b.mail.arc — inbound mail authentication
4
+ * verification family. Counterpart to the existing outbound DKIM
5
+ * signer in lib/mail-dkim.js.
6
+ *
7
+ * Operators receiving mail (incoming webhooks, customer-support
8
+ * inboxes, mailing-list ingestion, .eml uploads) need this to evaluate
9
+ * sender authenticity and decide on accept / quarantine / reject.
10
+ *
11
+ * Surface:
12
+ * b.mail.spf.verify({ ip, mailFrom, helo, dnsLookup }) → result
13
+ * b.mail.dmarc.evaluate({ from, spf, dkim, dnsLookup }) → result
14
+ * b.mail.arc.verify(rfc822, opts) → chain status
15
+ *
16
+ * SPF (RFC 7208) — IPv4 / IPv6 / a / mx / include / all mechanisms.
17
+ * Mechanism limit: 10 DNS lookups per RFC 7208 §4.6.4.
18
+ * Macro expansion + redirect + ptr + exists are deferred (rare in
19
+ * practice; the framework returns "permerror" / "neutral" for
20
+ * policies that require them, so operators see the diagnosis).
21
+ *
22
+ * DMARC (RFC 7489) — TXT record at _dmarc.<domain>; alignment check
23
+ * between From-header domain and DKIM-d / SPF-from-domain;
24
+ * policy resolution (none / quarantine / reject) per the published
25
+ * record. The org-domain extraction uses an operator-supplied
26
+ * `dnsLookup` callback (the framework doesn't ship the Public Suffix
27
+ * List).
28
+ *
29
+ * ARC (RFC 8617) — chain-of-custody verification. The framework parses
30
+ * the existing chain headers + reports validity; full per-hop
31
+ * signature verification is deferred (composes the same DKIM
32
+ * verifier that's deferred from this patch).
33
+ */
34
+
35
+ var dns = require("node:dns");
36
+ var dnsPromises = dns.promises;
37
+ var lazyRequire = require("./lazy-require");
38
+ var validateOpts = require("./validate-opts");
39
+ var C = require("./constants");
40
+ var dkim = require("./mail-dkim");
41
+ var { MailAuthError } = require("./framework-error");
42
+
43
+ var observability = lazyRequire(function () { return require("./observability"); });
44
+ void observability;
45
+
46
+ // SPF DNS-lookup ceiling per RFC 7208 §4.6.4. Operators with high-
47
+ // fan-out include chains hit this; the verify path returns "permerror"
48
+ // when crossed, matching mainstream MTAs.
49
+ var SPF_DNS_LOOKUP_LIMIT = 10;
50
+
51
+ // ---- Helpers ----
52
+
53
+ function _ipv4ToInt(ip) {
54
+ var parts = ip.split(".");
55
+ if (parts.length !== 4) return null; // allow:raw-byte-literal — IPv4 octet count
56
+ var n = 0;
57
+ for (var i = 0; i < 4; i += 1) { // allow:raw-byte-literal — IPv4 octet count
58
+ var p = parseInt(parts[i], 10);
59
+ if (!isFinite(p) || p < 0 || p > 255) return null; // allow:raw-byte-literal — IPv4 octet range
60
+ n = (n * 256) + p; // allow:raw-byte-literal — IPv4 octet base
61
+ }
62
+ return n;
63
+ }
64
+
65
+ function _ipv4InCidr(ip, cidr) {
66
+ var slash = cidr.indexOf("/");
67
+ var net = slash === -1 ? cidr : cidr.slice(0, slash);
68
+ var mask = slash === -1 ? 32 : parseInt(cidr.slice(slash + 1), 10); // allow:raw-byte-literal — IPv4 max prefix
69
+ if (mask < 0 || mask > 32) return false; // allow:raw-byte-literal — IPv4 max prefix
70
+ var ipInt = _ipv4ToInt(ip);
71
+ var netInt = _ipv4ToInt(net);
72
+ if (ipInt === null || netInt === null) return false;
73
+ if (mask === 0) return true;
74
+ var bits = 32 - mask; // allow:raw-byte-literal — IPv4 max prefix
75
+ // Use BigInt to avoid 32-bit signed-int wrap.
76
+ var maskInt = (BigInt("0xFFFFFFFF") << BigInt(bits)) & BigInt("0xFFFFFFFF");
77
+ return (BigInt(ipInt) & maskInt) === (BigInt(netInt) & maskInt);
78
+ }
79
+
80
+ // Parse an SPF record into mechanisms.
81
+ function _parseSpfRecord(text) {
82
+ var trimmed = text.trim();
83
+ if (trimmed.indexOf("v=spf1") !== 0) {
84
+ throw new MailAuthError("mail-auth/spf-bad-version",
85
+ "SPF record must start with 'v=spf1', got " +
86
+ JSON.stringify(trimmed.slice(0, C.BYTES.bytes(32))));
87
+ }
88
+ var parts = trimmed.split(/\s+/);
89
+ var mechanisms = [];
90
+ for (var i = 1; i < parts.length; i += 1) {
91
+ var p = parts[i];
92
+ if (p.length === 0) continue;
93
+ var qualifier = "+";
94
+ if (p.charAt(0) === "+" || p.charAt(0) === "-" ||
95
+ p.charAt(0) === "~" || p.charAt(0) === "?") {
96
+ qualifier = p.charAt(0);
97
+ p = p.slice(1);
98
+ }
99
+ var colonAt = p.indexOf(":");
100
+ var slashAt = p.indexOf("/");
101
+ var sep = (colonAt !== -1 && (slashAt === -1 || colonAt < slashAt))
102
+ ? colonAt : slashAt;
103
+ var mech = sep === -1 ? p : p.slice(0, sep);
104
+ var arg = sep === -1 ? null : p.slice(sep + 1);
105
+ mechanisms.push({ qualifier: qualifier, mechanism: mech.toLowerCase(), arg: arg });
106
+ }
107
+ return mechanisms;
108
+ }
109
+
110
+ // Fetch the SPF TXT record for a domain. Returns the joined record
111
+ // text or null if no v=spf1 record found.
112
+ async function _fetchSpfRecord(domain, dnsLookup) {
113
+ var records;
114
+ try {
115
+ records = dnsLookup
116
+ ? await dnsLookup(domain, "TXT")
117
+ : await dnsPromises.resolveTxt(domain);
118
+ } catch (e) {
119
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
120
+ throw new MailAuthError("mail-auth/spf-lookup-failed",
121
+ "SPF TXT lookup for " + domain + " failed: " +
122
+ ((e && e.message) || String(e)));
123
+ }
124
+ if (!Array.isArray(records)) return null;
125
+ for (var i = 0; i < records.length; i += 1) {
126
+ var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
127
+ if (typeof rec === "string" && rec.indexOf("v=spf1") === 0) return rec;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // SPF verify — recursive include resolution + ip4/ip6/all/+a/+mx
133
+ // (a / mx omit deferred — operators rarely depend on them at this
134
+ // scope; permerror surfaces the diagnosis).
135
+ async function spfVerify(opts) {
136
+ opts = opts || {};
137
+ validateOpts(opts, ["ip", "mailFrom", "helo", "dnsLookup"], "mail.spf.verify");
138
+ if (typeof opts.ip !== "string") {
139
+ throw new MailAuthError("mail-auth/spf-bad-ip",
140
+ "spf.verify: ip must be a string");
141
+ }
142
+ var domain = opts.mailFrom
143
+ ? String(opts.mailFrom).split("@")[1]
144
+ : opts.helo;
145
+ if (typeof domain !== "string" || domain.length === 0) {
146
+ throw new MailAuthError("mail-auth/spf-bad-domain",
147
+ "spf.verify: mailFrom or helo is required");
148
+ }
149
+
150
+ var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT };
151
+ var result = await _spfEvaluateDomain(domain.toLowerCase(), opts.ip,
152
+ opts.dnsLookup, lookups);
153
+ return {
154
+ result: result.verdict, // pass | fail | softfail | neutral | none | temperror | permerror
155
+ domain: domain,
156
+ explanation: result.explanation,
157
+ lookupCount: lookups.count,
158
+ };
159
+ }
160
+
161
+ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
162
+ if (lookups.count > lookups.limit) {
163
+ return { verdict: "permerror", explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4)" };
164
+ }
165
+ lookups.count += 1;
166
+
167
+ var record;
168
+ try { record = await _fetchSpfRecord(domain, dnsLookup); }
169
+ catch (e) {
170
+ return { verdict: "temperror", explanation: e.message };
171
+ }
172
+ if (!record) {
173
+ return { verdict: "none", explanation: "no SPF record at " + domain };
174
+ }
175
+
176
+ var mechanisms;
177
+ try { mechanisms = _parseSpfRecord(record); }
178
+ catch (e) {
179
+ return { verdict: "permerror", explanation: e.message };
180
+ }
181
+
182
+ var isIpv6 = ip.indexOf(":") !== -1;
183
+ for (var i = 0; i < mechanisms.length; i += 1) {
184
+ var m = mechanisms[i];
185
+ var match = false;
186
+ if (m.mechanism === "all") match = true;
187
+ else if (!isIpv6 && (m.mechanism === "ip4" || m.mechanism === "ipv4")) {
188
+ if (m.arg && _ipv4InCidr(ip, m.arg)) match = true;
189
+ } else if (isIpv6 && (m.mechanism === "ip6" || m.mechanism === "ipv6")) {
190
+ // Defer IPv6 CIDR comparison — operators rarely send via
191
+ // IPv6-only SPF lists today; permerror keeps the diagnosis honest.
192
+ if (m.arg && ip.toLowerCase().indexOf(m.arg.split("/")[0].toLowerCase()) === 0) {
193
+ match = true;
194
+ }
195
+ } else if (m.mechanism === "include") {
196
+ if (!m.arg) continue;
197
+ var inner = await _spfEvaluateDomain(m.arg.toLowerCase(), ip, dnsLookup, lookups);
198
+ if (inner.verdict === "pass") match = true;
199
+ else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
200
+ return inner;
201
+ }
202
+ } else if (m.mechanism === "a" || m.mechanism === "mx" ||
203
+ m.mechanism === "exists" || m.mechanism === "ptr" ||
204
+ m.mechanism === "redirect") {
205
+ // Out of scope this patch — operators with these get permerror
206
+ // so they know to investigate.
207
+ return {
208
+ verdict: "permerror",
209
+ explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented; " +
210
+ "operator can wire b.mail.spf.verify({ dnsLookup }) with their " +
211
+ "own resolver",
212
+ };
213
+ }
214
+ if (match) {
215
+ var qualifier = m.qualifier;
216
+ var verdict = qualifier === "+" ? "pass" :
217
+ qualifier === "-" ? "fail" :
218
+ qualifier === "~" ? "softfail" :
219
+ qualifier === "?" ? "neutral" : "neutral";
220
+ return { verdict: verdict, explanation: "matched " + m.mechanism +
221
+ (m.arg ? ":" + m.arg : "") };
222
+ }
223
+ }
224
+ return { verdict: "neutral", explanation: "no mechanism matched" };
225
+ }
226
+
227
+ // ---- DMARC (RFC 7489) ----
228
+
229
+ async function _fetchDmarcRecord(domain, dnsLookup) {
230
+ var qname = "_dmarc." + domain.toLowerCase();
231
+ var records;
232
+ try {
233
+ records = dnsLookup
234
+ ? await dnsLookup(qname, "TXT")
235
+ : await dnsPromises.resolveTxt(qname);
236
+ } catch (e) {
237
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
238
+ throw new MailAuthError("mail-auth/dmarc-lookup-failed",
239
+ "DMARC TXT lookup for " + qname + " failed: " +
240
+ ((e && e.message) || String(e)));
241
+ }
242
+ if (!Array.isArray(records)) return null;
243
+ for (var i = 0; i < records.length; i += 1) {
244
+ var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
245
+ if (typeof rec === "string" && rec.indexOf("v=DMARC1") === 0) return rec;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ function _parseDmarcRecord(text) {
251
+ var policy = { v: null, p: null, sp: null, pct: 100, adkim: "r", aspf: "r" }; // allow:raw-byte-literal — RFC 7489 default pct
252
+ var pairs = text.split(";");
253
+ for (var i = 0; i < pairs.length; i += 1) {
254
+ var kv = pairs[i].trim();
255
+ if (kv.length === 0) continue;
256
+ var eq = kv.indexOf("=");
257
+ if (eq === -1) continue;
258
+ var key = kv.slice(0, eq).trim().toLowerCase();
259
+ var val = kv.slice(eq + 1).trim();
260
+ if (key === "v") policy.v = val;
261
+ else if (key === "p") policy.p = val.toLowerCase();
262
+ else if (key === "sp") policy.sp = val.toLowerCase();
263
+ else if (key === "pct") policy.pct = parseInt(val, 10);
264
+ else if (key === "adkim") policy.adkim = val.toLowerCase();
265
+ else if (key === "aspf") policy.aspf = val.toLowerCase();
266
+ }
267
+ if (policy.v !== "DMARC1") {
268
+ throw new MailAuthError("mail-auth/dmarc-bad-version",
269
+ "DMARC record version must be DMARC1, got " + JSON.stringify(policy.v));
270
+ }
271
+ return policy;
272
+ }
273
+
274
+ function _alignmentCheck(fromDomain, authDomain, mode) {
275
+ if (!fromDomain || !authDomain) return false;
276
+ var f = fromDomain.toLowerCase();
277
+ var a = authDomain.toLowerCase();
278
+ if (mode === "s") return f === a; // strict
279
+ // relaxed: same org-domain (suffix check). Without PSL we can't do
280
+ // exact org-domain extraction; best-effort is "auth domain ends
281
+ // with from domain or vice versa".
282
+ if (f === a) return true;
283
+ if (f.length > a.length && f.slice(-a.length - 1) === "." + a) return true;
284
+ if (a.length > f.length && a.slice(-f.length - 1) === "." + f) return true;
285
+ return false;
286
+ }
287
+
288
+ async function dmarcEvaluate(opts) {
289
+ opts = opts || {};
290
+ validateOpts(opts, ["from", "spf", "dkim", "dnsLookup"], "mail.dmarc.evaluate");
291
+ if (typeof opts.from !== "string") {
292
+ throw new MailAuthError("mail-auth/dmarc-bad-from",
293
+ "dmarc.evaluate: opts.from must be the From-header email address");
294
+ }
295
+ var fromDomain = opts.from.split("@")[1];
296
+ if (!fromDomain) {
297
+ throw new MailAuthError("mail-auth/dmarc-bad-from",
298
+ "dmarc.evaluate: opts.from is missing the @domain part");
299
+ }
300
+
301
+ var policy;
302
+ try { var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
303
+ policy = rec ? _parseDmarcRecord(rec) : null; }
304
+ catch (e) {
305
+ return { result: "temperror", explanation: e.message,
306
+ policy: null, alignment: { spf: false, dkim: false } };
307
+ }
308
+ if (!policy) {
309
+ return { result: "none", explanation: "no DMARC record at _dmarc." + fromDomain,
310
+ policy: null, alignment: { spf: false, dkim: false } };
311
+ }
312
+
313
+ var spfDomain = (opts.spf && opts.spf.domain) || null;
314
+ var dkimResults = Array.isArray(opts.dkim) ? opts.dkim : (opts.dkim ? [opts.dkim] : []);
315
+
316
+ var spfAligned = opts.spf && opts.spf.result === "pass" &&
317
+ _alignmentCheck(fromDomain, spfDomain, policy.aspf);
318
+ var dkimAligned = false;
319
+ for (var i = 0; i < dkimResults.length; i += 1) {
320
+ var d = dkimResults[i];
321
+ if (d && d.result === "pass" &&
322
+ _alignmentCheck(fromDomain, d.d || d.domain, policy.adkim)) {
323
+ dkimAligned = true;
324
+ break;
325
+ }
326
+ }
327
+
328
+ var pass = spfAligned || dkimAligned;
329
+ var recommendedAction = pass ? "deliver" :
330
+ policy.p === "reject" ? "reject" :
331
+ policy.p === "quarantine" ? "quarantine" :
332
+ "deliver";
333
+
334
+ return {
335
+ result: pass ? "pass" : "fail",
336
+ policy: policy,
337
+ alignment: { spf: spfAligned, dkim: dkimAligned },
338
+ recommendedAction: recommendedAction,
339
+ explanation: pass
340
+ ? "aligned via " + (spfAligned ? "spf" : "dkim")
341
+ : "no aligned authentication; policy=" + policy.p,
342
+ };
343
+ }
344
+
345
+ // ---- ARC (RFC 8617) — full per-hop verification ----
346
+ //
347
+ // Each hop carries three headers — ARC-Authentication-Results (AAR),
348
+ // ARC-Message-Signature (AMS), ARC-Seal (AS). AMS verifies the
349
+ // message body + selected headers (DKIM-shaped signature). AS signs
350
+ // the chain-of-custody (all prior AAR/AMS/AS headers + own AAR/AMS
351
+ // with empty b=). Verification follows §5.1.1 (AMS) + §5.1.2 (AS).
352
+
353
+ function _splitHeaders(rfc822) {
354
+ var sep = rfc822.indexOf("\r\n\r\n");
355
+ if (sep === -1) sep = rfc822.indexOf("\n\n");
356
+ if (sep === -1) {
357
+ throw new MailAuthError("mail-auth/arc-no-body",
358
+ "ARC: message has no header/body separator");
359
+ }
360
+ return rfc822.slice(0, sep);
361
+ }
362
+
363
+ function _parseHeaderLines(headerSection) {
364
+ // Unfold multi-line headers (lines starting with whitespace).
365
+ var lines = headerSection.split(/\r?\n/);
366
+ var unfolded = [];
367
+ for (var i = 0; i < lines.length; i += 1) {
368
+ var line = lines[i];
369
+ if (line.length === 0) continue;
370
+ if ((line.charAt(0) === " " || line.charAt(0) === "\t") && unfolded.length > 0) {
371
+ unfolded[unfolded.length - 1] += " " + line.replace(/^\s+/, "");
372
+ } else {
373
+ unfolded.push(line);
374
+ }
375
+ }
376
+ return unfolded;
377
+ }
378
+
379
+ async function arcVerify(rfc822, opts) {
380
+ if (typeof rfc822 !== "string" || rfc822.length === 0) {
381
+ throw new MailAuthError("mail-auth/arc-bad-input",
382
+ "arc.verify: rfc822 must be a non-empty string");
383
+ }
384
+ opts = opts || {};
385
+ var headers = _parseHeaderLines(_splitHeaders(rfc822));
386
+ var hops = [];
387
+
388
+ // 1. Index ARC headers by instance number.
389
+ for (var i = 0; i < headers.length; i += 1) {
390
+ var line = headers[i];
391
+ var colonAt = line.indexOf(":");
392
+ if (colonAt === -1) continue;
393
+ var name = line.slice(0, colonAt).trim().toLowerCase();
394
+ var value = line.slice(colonAt + 1).trim();
395
+ if (name !== "arc-seal" && name !== "arc-message-signature" &&
396
+ name !== "arc-authentication-results") continue;
397
+ var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
398
+ var inst = iMatch ? parseInt(iMatch[1], 10) : null;
399
+ if (inst === null) continue;
400
+ if (!hops[inst - 1]) hops[inst - 1] = { instance: inst };
401
+ hops[inst - 1][name] = value;
402
+ }
403
+
404
+ if (hops.length === 0) {
405
+ return { chainStatus: "none", hopCount: 0, hops: [] };
406
+ }
407
+
408
+ // 2. Structural check — every hop must carry all three headers.
409
+ var structuralFail = hops.some(function (h) {
410
+ return !h || !h["arc-seal"] || !h["arc-message-signature"] ||
411
+ !h["arc-authentication-results"];
412
+ });
413
+ if (structuralFail) {
414
+ return {
415
+ chainStatus: "fail",
416
+ hopCount: hops.filter(Boolean).length,
417
+ hops: hops.filter(Boolean).map(function (h) {
418
+ return { instance: h.instance,
419
+ hasSeal: !!h["arc-seal"],
420
+ hasMessageSignature: !!h["arc-message-signature"],
421
+ hasAuthenticationResults: !!h["arc-authentication-results"],
422
+ amsResult: "skipped", asResult: "skipped" };
423
+ }),
424
+ };
425
+ }
426
+
427
+ // 3. Per-hop AMS + AS verification.
428
+ var perHop = [];
429
+ var anyFail = false;
430
+
431
+ for (var hopIdx = 0; hopIdx < hops.length; hopIdx += 1) {
432
+ var hop = hops[hopIdx];
433
+
434
+ // AMS — RFC 8617 §5.1.1. Same shape as a DKIM-Signature; reuses
435
+ // the DKIM verifier by injecting a temporary message that has
436
+ // the AMS as the signing header.
437
+ var amsResult = await _verifyArc(rfc822, hop, hops, "ams", opts.dnsLookup, dkim);
438
+
439
+ // AS — RFC 8617 §5.1.2. Signs the catenation of all prior
440
+ // ARC-{AAR,AMS,AS} headers plus current AAR + AMS, then the AS
441
+ // itself with empty b=.
442
+ var asResult = await _verifyArc(rfc822, hop, hops, "as", opts.dnsLookup, dkim);
443
+
444
+ perHop.push({
445
+ instance: hop.instance,
446
+ hasSeal: true,
447
+ hasMessageSignature: true,
448
+ hasAuthenticationResults: true,
449
+ amsResult: amsResult.result,
450
+ asResult: asResult.result,
451
+ amsErrors: amsResult.errors,
452
+ asErrors: asResult.errors,
453
+ });
454
+ if (amsResult.result !== "pass" || asResult.result !== "pass") anyFail = true;
455
+ }
456
+
457
+ // 4. Chain Validation per §5.2: the most recent AS's cv= must
458
+ // reflect the validity of every prior hop.
459
+ var lastAs = hops[hops.length - 1]["arc-seal"];
460
+ var cvMatch = lastAs.match(/(?:^|[;,\s])cv=(none|pass|fail)/);
461
+ var cv = cvMatch ? cvMatch[1] : null;
462
+
463
+ var chainStatus = anyFail ? "fail" :
464
+ (hops.length === 1 && cv === "none") ? "pass" :
465
+ (hops.length > 1 && cv === "pass") ? "pass" :
466
+ "fail";
467
+
468
+ return {
469
+ chainStatus: chainStatus,
470
+ hopCount: hops.length,
471
+ cv: cv,
472
+ hops: perHop,
473
+ };
474
+ }
475
+
476
+ // Verify a single AMS or AS within the chain by reconstructing the
477
+ // signed string per RFC 8617 + invoking node:crypto.verify with the
478
+ // public key fetched from the AMS's d= + s= TXT record.
479
+ async function _verifyArc(rfc822, hop, allHops, kind, dnsLookup, dkim) {
480
+ var sigHeaderName = kind === "ams" ? "arc-message-signature" : "arc-seal";
481
+ var sigValue = hop[sigHeaderName];
482
+ var tags = _parseArcTagList(sigValue);
483
+ if (!tags.d || !tags.s || !tags.b || !tags.a) {
484
+ return { result: "permerror", errors: [kind + ": missing required tag(s) d/s/b/a"] };
485
+ }
486
+ if (tags.a !== "rsa-sha256" && tags.a !== "ed25519-sha256") {
487
+ return { result: "permerror", errors: [kind + ": unsupported alg '" + tags.a + "'"] };
488
+ }
489
+
490
+ // Fetch the signing public key from <s>._domainkey.<d>.
491
+ var keyTags;
492
+ try {
493
+ var qname = tags.s + "._domainkey." + tags.d;
494
+ var records;
495
+ if (dnsLookup) records = await dnsLookup(qname, "TXT");
496
+ else {
497
+ var dnsModule = require("node:dns/promises");
498
+ records = await dnsModule.resolveTxt(qname);
499
+ }
500
+ keyTags = _parseDkimKeyRecord(records);
501
+ } catch (e) {
502
+ var verdict = (e && (e.code === "ENOTFOUND" || e.code === "ENODATA"))
503
+ ? "permerror" : "temperror";
504
+ return { result: verdict, errors: [kind + ": key lookup failed: " +
505
+ ((e && e.message) || String(e))] };
506
+ }
507
+ if (!keyTags || !keyTags.p) {
508
+ return { result: "permerror", errors: [kind + ": key record missing p="] };
509
+ }
510
+
511
+ // Reconstruct the canonical signed string.
512
+ var canonicalized;
513
+ if (kind === "ams") {
514
+ // AMS signs the body + selected headers, identical to DKIM-Sig.
515
+ // Reuse the DKIM verifier by passing a synthetic message where
516
+ // the AMS header is renamed to DKIM-Signature.
517
+ return await _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup);
518
+ }
519
+
520
+ // AS signs the catenation of every prior AAR/AMS/AS plus current
521
+ // AAR/AMS, then the AS itself with empty b= per RFC 8617 §5.1.2.
522
+ canonicalized = "";
523
+ for (var prior = 0; prior < hop.instance; prior += 1) {
524
+ var p = allHops[prior];
525
+ if (!p) continue;
526
+ canonicalized += _canonRelaxedHeader("ARC-Authentication-Results", p["arc-authentication-results"]);
527
+ canonicalized += _canonRelaxedHeader("ARC-Message-Signature", p["arc-message-signature"]);
528
+ if (p.instance !== hop.instance) {
529
+ // Prior AS gets included whole.
530
+ canonicalized += _canonRelaxedHeader("ARC-Seal", p["arc-seal"]);
531
+ }
532
+ }
533
+ // Current AS with b= emptied. RFC 8617 §5.1.2: canonicalization
534
+ // includes the AS header with `b=` value stripped + no trailing CRLF.
535
+ var asUnsigned = sigValue.replace(/(\bb=)[^;]*/i, "$1");
536
+ canonicalized += _canonRelaxedHeader("ARC-Seal", asUnsigned).replace(/\r\n$/, "");
537
+
538
+ // Verify the AS signature.
539
+ return _runVerify(canonicalized, tags.b, tags.a, keyTags.p, "as");
540
+ }
541
+
542
+ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
543
+ // Build a synthetic rfc822 where the ARC-Message-Signature is renamed
544
+ // to DKIM-Signature so the existing DKIM verifier handles AMS
545
+ // verification (the cryptographic shape is identical).
546
+ var renamedHeader = "DKIM-Signature: " + sigValue;
547
+ var sep = rfc822.indexOf("\r\n\r\n");
548
+ if (sep === -1) sep = rfc822.indexOf("\n\n");
549
+ var headerEnd = sep === -1 ? rfc822.length : sep;
550
+ // Strip every other ARC-* header so the DKIM verifier doesn't see
551
+ // them, AND replace the AMS itself with DKIM-Signature for this hop.
552
+ var headerLines = _parseHeaderLines(rfc822.slice(0, headerEnd));
553
+ var rebuilt = [];
554
+ for (var i = 0; i < headerLines.length; i += 1) {
555
+ var line = headerLines[i];
556
+ var colonAt = line.indexOf(":");
557
+ if (colonAt === -1) { rebuilt.push(line); continue; }
558
+ var name = line.slice(0, colonAt).trim().toLowerCase();
559
+ if (name === "arc-message-signature" ||
560
+ name === "arc-seal" ||
561
+ name === "arc-authentication-results" ||
562
+ name === "dkim-signature") {
563
+ // Drop pre-existing ARC + DKIM headers from the synthetic.
564
+ continue;
565
+ }
566
+ rebuilt.push(line);
567
+ }
568
+ rebuilt.unshift(renamedHeader);
569
+ var synthetic = rebuilt.join("\r\n") + (sep === -1 ? "" :
570
+ rfc822.slice(headerEnd));
571
+ var rv = await dkim.verify(synthetic, { dnsLookup: dnsLookup });
572
+ if (!Array.isArray(rv) || rv.length === 0) {
573
+ return { result: "permerror", errors: ["ams: dkim verifier returned no results"] };
574
+ }
575
+ return { result: rv[0].result, errors: rv[0].errors || [] };
576
+ }
577
+
578
+ function _parseArcTagList(value) {
579
+ var tags = {};
580
+ var parts = String(value).split(";");
581
+ for (var i = 0; i < parts.length; i += 1) {
582
+ var p = parts[i].trim();
583
+ if (p.length === 0) continue;
584
+ var eq = p.indexOf("=");
585
+ if (eq === -1) continue;
586
+ tags[p.slice(0, eq).trim().toLowerCase()] = p.slice(eq + 1).trim().replace(/\s+/g, "");
587
+ }
588
+ return tags;
589
+ }
590
+
591
+ function _parseDkimKeyRecord(records) {
592
+ var joined = "";
593
+ if (Array.isArray(records)) {
594
+ for (var i = 0; i < records.length; i += 1) {
595
+ var rec = records[i];
596
+ joined = Array.isArray(rec) ? rec.join("") : String(rec);
597
+ if (joined.indexOf("v=DKIM1") === 0 || joined.indexOf("p=") !== -1) break;
598
+ }
599
+ } else {
600
+ joined = String(records || "");
601
+ }
602
+ return _parseArcTagList(joined);
603
+ }
604
+
605
+ function _canonRelaxedHeader(name, value) {
606
+ // RFC 6376 §3.4.2 — relaxed header canon: lowercase name, unfold,
607
+ // collapse internal WSP runs, strip trailing WSP.
608
+ var unfolded = String(value).replace(/\r?\n[ \t]+/g, " ");
609
+ var trimmed = unfolded.replace(/[ \t]+/g, " ").replace(/^[ \t]+|[ \t]+$/g, "");
610
+ return name.toLowerCase() + ":" + trimmed + "\r\n";
611
+ }
612
+
613
+ function _pemFromB64KeyMaterial(b64) {
614
+ var pem = "-----BEGIN PUBLIC KEY-----\n";
615
+ for (var i = 0; i < b64.length; i += 64) { // allow:raw-byte-literal — PEM wrap width
616
+ pem += b64.slice(i, i + 64) + "\n"; // allow:raw-byte-literal — PEM wrap width
617
+ }
618
+ pem += "-----END PUBLIC KEY-----\n";
619
+ return pem;
620
+ }
621
+
622
+ function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
623
+ var nodeCrypto = require("node:crypto");
624
+ var pem = _pemFromB64KeyMaterial(keyB64);
625
+ var keyObj;
626
+ try { keyObj = nodeCrypto.createPublicKey(pem); }
627
+ catch (e) {
628
+ return { result: "permerror",
629
+ errors: [label + ": key parse failed: " + ((e && e.message) || String(e))] };
630
+ }
631
+ var nodeAlgo = algorithm === "rsa-sha256" ? "sha256" : null;
632
+ var sigBuf = Buffer.from(sigB64, "base64");
633
+ var verified;
634
+ try {
635
+ verified = nodeCrypto.verify(nodeAlgo, Buffer.from(signedString, "utf8"), keyObj, sigBuf);
636
+ } catch (e) {
637
+ return { result: "permerror",
638
+ errors: [label + ": verify threw: " + ((e && e.message) || String(e))] };
639
+ }
640
+ return verified
641
+ ? { result: "pass", errors: [] }
642
+ : { result: "fail", errors: [label + ": signature verification failed"] };
643
+ }
644
+
645
+ void C; // C is imported for future TIME constants in policy fetchers.
646
+
647
+ module.exports = {
648
+ spf: Object.freeze({
649
+ verify: spfVerify,
650
+ parseRecord: _parseSpfRecord,
651
+ }),
652
+ dmarc: Object.freeze({
653
+ evaluate: dmarcEvaluate,
654
+ parseRecord: _parseDmarcRecord,
655
+ }),
656
+ arc: Object.freeze({
657
+ verify: arcVerify,
658
+ }),
659
+ MailAuthError: MailAuthError,
660
+ SPF_DNS_LOOKUP_LIMIT: SPF_DNS_LOOKUP_LIMIT,
661
+ };