@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.
- package/CHANGELOG.md +30 -0
- package/index.js +4 -0
- package/lib/asn1-der.js +356 -0
- package/lib/audit.js +2 -0
- package/lib/compliance.js +114 -0
- package/lib/constants.js +8 -0
- package/lib/crypto.js +92 -0
- package/lib/dora.js +347 -0
- package/lib/framework-error.js +22 -0
- package/lib/gate-contract.js +20 -4
- package/lib/mail-auth.js +661 -0
- package/lib/mail-dkim.js +309 -4
- package/lib/mail.js +8 -0
- package/lib/network-smtp-policy.js +551 -0
- package/lib/network-tls.js +1169 -0
- package/lib/network.js +7 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/mail-auth.js
ADDED
|
@@ -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
|
+
};
|