mailauth 5.0.3 → 7.0.0

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.
Files changed (48) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +117 -41
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +82 -31
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +499 -45
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +267 -53
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/index.js +2 -1
  36. package/lib/dmarc/verify.js +205 -53
  37. package/lib/mailauth.js +86 -40
  38. package/lib/mta-sts.d.ts +77 -11
  39. package/lib/mta-sts.js +497 -182
  40. package/lib/parse-dkim-headers.js +376 -191
  41. package/lib/parse-received.js +104 -32
  42. package/lib/spf/index.d.ts +37 -1
  43. package/lib/spf/index.js +260 -28
  44. package/lib/spf/macro.js +50 -13
  45. package/lib/spf/spf-verify.js +440 -204
  46. package/lib/spf/syntax.js +366 -0
  47. package/lib/tools.js +524 -118
  48. package/package.json +12 -12
@@ -1,86 +1,300 @@
1
1
  'use strict';
2
2
 
3
3
  const dns = require('node:dns').promises;
4
- const tldts = require('tldts');
5
- const { TLDTS_OPTS } = require('../tools');
4
+ const net = require('node:net');
5
+ const { formatDomain } = require('../tools');
6
6
 
7
7
  // Tags whose values are ABNF literals in RFC 7489 6.4 / RFC 9989 4.7 and are therefore
8
8
  // case insensitive (RFC 5234 2.3). Values of other tags keep their case: "v" is defined
9
9
  // with explicit hex literals and is case sensitive, "rua"/"ruf" are URIs.
10
10
  const CASE_INSENSITIVE_TAGS = new Set(['p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rf', 't', 'psd']);
11
11
 
12
- // Returns every DMARC record published for a domain. The caller distinguishes an empty set,
13
- // which continues policy discovery at the organizational domain, from a multi record set,
14
- // which terminates it (RFC 7489 6.6.3 step 5).
15
12
  // The version tag must be the first tag and its value "DMARC1" must match precisely
16
13
  // (RFC 9989 4.7 "v"), while the ABNF allows *WSP around "=" and a case insensitive
17
14
  // tag name, hence [vV] instead of an /i flag that would also relax the value
18
15
  const DMARC_VERSION_RE = /^[vV][ \t]*=[ \t]*DMARC1[ \t]*(?:;|$)/;
19
16
 
20
- const resolveTxt = async (domain, resolver) => {
17
+ // Resolver errors that mean the name has no record, as opposed to a transient failure.
18
+ // EBADNAME is a name that can not exist in the DNS at all.
19
+ const NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'EBADNAME']);
20
+
21
+ // A Tree Walk shortens an author domain of eight or more labels straight to seven (RFC 9989 4.10 step 4)
22
+ const WALK_MAX_LABELS = 7;
23
+
24
+ // A domain literal or an IP address is not a DNS name and can not publish a DMARC record
25
+ const isAddress = domain => domain.startsWith('[') || net.isIP(domain) !== 0;
26
+
27
+ // Whether a name can exist in the DNS: no empty labels, labels of at most 63 octets and a
28
+ // name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a resolver
29
+ // error that looks like a transient failure.
30
+ const isValidName = name => name.length <= 253 && name.split('.').every(label => label.length && label.length <= 63);
31
+
32
+ const POLICY_VALUES = new Set(['none', 'quarantine', 'reject']);
33
+
34
+ // A reporting URI (RFC 9989 4.8 dmarc-uri): an RFC 3986 URI, so a scheme, a ":" and a possibly
35
+ // empty rest made of URI characters, where commas and "!" must be percent-encoded. "mailto:" is
36
+ // therefore valid and a bare address is not. An obsolete "!size" suffix does not change that.
37
+ const REPORTING_URI = /^[a-z][a-z0-9+.-]*:(?:[a-z0-9\-._~:/?#[\]@$&'()*+;=]|%[0-9a-f]{2})*$/i;
38
+ const OBS_REPORT_SIZE = /![0-9]+[kmgt]?$/i;
39
+
40
+ // "rua" lists at least one syntactically valid reporting URI (RFC 9989 4.10.1)
41
+ const hasReportingUri = record =>
42
+ typeof record.rua === 'string' && record.rua.split(',').some(uri => REPORTING_URI.test(uri.trim().replace(OBS_REPORT_SIZE, '')));
43
+
44
+ // Returns the policy tags a record is applied with (RFC 9989 4.10.1). A record without a
45
+ // valid "p", or with an invalid "sp" or "np", acts as p=none when it asks for aggregate
46
+ // reports, and gets no DMARC processing at all (null) otherwise.
47
+ const getPolicyTags = record => {
48
+ const isValid = value => value === undefined || POLICY_VALUES.has(value);
49
+ if (POLICY_VALUES.has(record.p) && isValid(record.sp) && isValid(record.np)) {
50
+ return { p: record.p, sp: record.sp, np: record.np };
51
+ }
52
+ return hasReportingUri(record) ? { p: 'none' } : null;
53
+ };
54
+
55
+ // Domain existence test (RFC 9989 A.4): any RR makes a name exist, only NXDOMAIN means it
56
+ // does not. A query for A tells the two apart, NODATA is an existing name without A records.
57
+ const domainExists = async (domain, resolver) => {
58
+ if (!isValidName(domain)) {
59
+ return false;
60
+ }
21
61
  try {
22
- let txt = await resolver(`_dmarc.${domain}`, 'TXT');
23
- return (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
62
+ await (resolver || dns.resolve)(domain, 'A');
63
+ return true;
24
64
  } catch (err) {
25
- if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
26
- return [];
65
+ if (err.code === 'ENODATA') {
66
+ return true;
67
+ }
68
+ if (err.code === 'ENOTFOUND' || err.code === 'EBADNAME') {
69
+ return false;
27
70
  }
28
71
  throw err;
29
72
  }
30
73
  };
31
74
 
32
- const getDmarcRecord = async (domain, resolver) => {
33
- resolver = resolver || dns.resolve;
75
+ // Tag names registered for DMARC (RFC 9989 9.3), including the historic ones
76
+ const KNOWN_TAGS = new Set(['v', 'p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rua', 'ruf', 'rf', 'ri', 'pct', 't', 'psd']);
77
+
78
+ // Parses a DMARC Policy Record. DMARC uses the DKIM tag-list syntax (RFC 9989 4.7), where tag names
79
+ // are case sensitive and a duplicated tag makes the whole tag-list invalid (RFC 6376 3.2).
80
+ // By default tag names are case-folded and the last of duplicated tags wins, which is reported in
81
+ // "warnings". With "strict" the RFC rules apply and a record with a duplicated tag is invalid (null).
82
+ const parseDmarcRecord = (txt, opts) => {
83
+ const { strict, warnings } = opts || {};
84
+
85
+ let seen = new Set();
86
+ let duplicate = false;
87
+
88
+ let entries = txt
89
+ .split(';')
90
+ .map(e => e.trim())
91
+ .filter(e => e)
92
+ .map((e, i) => {
93
+ let splitPos = e.indexOf('=');
94
+ if (splitPos < 0) {
95
+ // not a tag, a syntax error that is ignored
96
+ return [e.toLowerCase().trim(), false];
97
+ } else if (splitPos === 0) {
98
+ return [false, e];
99
+ }
100
+ // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
101
+ let name = e.substr(0, splitPos).trim();
102
+ let key = name.toLowerCase();
103
+ if (strict && i > 0) {
104
+ // the version tag is matched case-insensitively by its own ABNF rule, other names are not
105
+ key = name;
106
+ } else if (key !== name && KNOWN_TAGS.has(key) && i > 0) {
107
+ warnings?.add('tag-case');
108
+ }
109
+
110
+ if (seen.has(key)) {
111
+ duplicate = true;
112
+ }
113
+ seen.add(key);
114
+
115
+ let val = e.substr(splitPos + 1).trim();
116
+ if (['pct', 'ri'].includes(key)) {
117
+ val = parseInt(val, 10) || 0;
118
+ } else if (CASE_INSENSITIVE_TAGS.has(key)) {
119
+ val = val.toLowerCase();
120
+ }
121
+ return [key, val];
122
+ });
34
123
 
35
- let records = await resolveTxt(domain, resolver);
36
- let isOrgRecord = false;
37
-
38
- if (!records.length) {
39
- let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
40
- // tldts returns null for IPs, single labels and other non-domains, and querying
41
- // "_dmarc.null" would coerce that into a hostname
42
- if (orgDomain && orgDomain !== domain) {
43
- // try org domain as well
44
- records = await resolveTxt(orgDomain, resolver);
45
- isOrgRecord = true;
124
+ if (duplicate) {
125
+ if (strict) {
126
+ return null;
46
127
  }
128
+ warnings?.add('duplicate-tag');
47
129
  }
48
130
 
49
- if (records.length !== 1) {
50
- // no record, or a multi record set that terminates policy discovery
131
+ let parsed = Object.fromEntries(entries);
132
+
133
+ parsed.rr = txt;
134
+
135
+ return parsed;
136
+ };
137
+
138
+ // Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
139
+ // records counts as no record, it is discarded (RFC 9989 4.10 steps 2 and 6).
140
+ const fetchRecord = async (name, resolver, opts) => {
141
+ if (!isValidName(`_dmarc.${name}`)) {
51
142
  return false;
52
143
  }
53
144
 
54
- let txt = records[0];
55
-
56
- let parsed = Object.fromEntries(
57
- txt
58
- .split(';')
59
- .map(e => e.trim())
60
- .filter(e => e)
61
- .map(e => {
62
- let splitPos = e.indexOf('=');
63
- if (splitPos < 0) {
64
- return [e.toLowerCase().trim(), false];
65
- } else if (splitPos === 0) {
66
- return [false, e];
67
- }
68
- // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
69
- let key = e.substr(0, splitPos).toLowerCase().trim();
70
- let val = e.substr(splitPos + 1).trim();
71
- if (['pct', 'ri'].includes(key)) {
72
- val = parseInt(val, 10) || 0;
73
- } else if (CASE_INSENSITIVE_TAGS.has(key)) {
74
- val = val.toLowerCase();
75
- }
76
- return [key, val];
77
- })
78
- );
145
+ let records;
146
+ try {
147
+ let txt = await resolver(`_dmarc.${name}`, 'TXT');
148
+ // a custom resolver might return plain strings instead of lists of character-strings
149
+ records = (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trim()).filter(row => DMARC_VERSION_RE.test(row));
150
+ } catch (err) {
151
+ if (NO_RECORD_CODES.has(err.code)) {
152
+ return false;
153
+ }
154
+ throw err;
155
+ }
156
+
157
+ // a record that is not a valid tag-list is discarded like one that is not a DMARC record
158
+ return (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
159
+ };
79
160
 
80
- parsed.rr = txt;
81
- parsed.isOrgRecord = isOrgRecord;
161
+ // Tree Walks for one message share most of their names, so each name is queried only once.
162
+ // opts.strict parses records by the RFC 6376 tag-list rules, opts.warnings is a Set that collects
163
+ // what the default parsing accepted and strict parsing would not.
164
+ const createRecordLookup = (resolver, opts) => {
165
+ resolver = resolver || dns.resolve;
166
+ let cache = new Map();
167
+ return name => {
168
+ if (!cache.has(name)) {
169
+ cache.set(name, fetchRecord(name, resolver, opts));
170
+ }
171
+ return cache.get(name);
172
+ };
173
+ };
82
174
 
83
- return parsed;
175
+ // DNS Tree Walk (RFC 9989 4.10). Returns the records found, longest name first. The walk
176
+ // stops at a record with a psd=y or psd=n tag, or when no labels are left, so it makes
177
+ // at most eight queries. A DNS error ends the walk and is returned next to the records
178
+ // found before it, because a record found at the start domain is usable without the rest.
179
+ const treeWalk = async (domain, lookup) => {
180
+ let found = [];
181
+ let labels = domain.split('.');
182
+
183
+ while (labels.length) {
184
+ let name = labels.join('.');
185
+ let record;
186
+ try {
187
+ record = await lookup(name);
188
+ } catch (err) {
189
+ return { found, error: err };
190
+ }
191
+
192
+ if (record) {
193
+ found.push({ domain: name, record });
194
+ if (record.psd === 'y' || record.psd === 'n') {
195
+ break;
196
+ }
197
+ }
198
+
199
+ labels = labels.length > WALK_MAX_LABELS ? labels.slice(-WALK_MAX_LABELS) : labels.slice(1);
200
+ }
201
+
202
+ return { found };
203
+ };
204
+
205
+ // Organizational Domain selection for a completed Tree Walk (RFC 9989 4.10.2)
206
+ const selectOrgDomain = (domain, found) => {
207
+ for (let { domain: recordDomain, record } of found) {
208
+ if (record.psd === 'n') {
209
+ return recordDomain;
210
+ }
211
+ if (record.psd === 'y' && recordDomain !== domain) {
212
+ // the domain one label below the PSD
213
+ let labelCount = recordDomain.split('.').length + 1;
214
+ return domain.split('.').slice(-labelCount).join('.');
215
+ }
216
+ }
217
+
218
+ // the record with the fewest labels, or the start domain itself when nothing was found
219
+ return found.length ? found[found.length - 1].domain : domain;
220
+ };
221
+
222
+ // Returns the Organizational Domain of a normalized domain name. Throws on DNS errors.
223
+ const getOrgDomain = async (domain, lookup) => {
224
+ if (isAddress(domain)) {
225
+ return domain;
226
+ }
227
+ let { found, error } = await treeWalk(domain, lookup);
228
+ if (error) {
229
+ throw error;
230
+ }
231
+ return selectOrgDomain(domain, found);
232
+ };
233
+
234
+ // The record that holds the policy of an Organizational Domain: its own record, or else the record
235
+ // of the PSD above it. Returns false when there is neither.
236
+ const findOrgRecord = (orgDomain, found) => {
237
+ let last = found[found.length - 1];
238
+ let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
239
+ return entry || false;
240
+ };
241
+
242
+ // DMARC Policy Discovery (RFC 9989 4.10.1) for a normalized author domain. Returns
243
+ // { record, recordDomain, orgDomain, orgRecord, error }, where "record" is false when no policy
244
+ // applies and "orgRecord" is the record that holds the Organizational Domain's policy (see findOrgRecord).
245
+ // When the Tree Walk fails after a record was found at the author domain, "orgDomain" and
246
+ // "orgRecord" are null and "error" says why. Throws on DNS errors that leave the policy unknown.
247
+ const discoverPolicy = async (domain, lookup) => {
248
+ if (isAddress(domain)) {
249
+ return { record: false, recordDomain: null, orgDomain: domain, orgRecord: false };
250
+ }
251
+
252
+ let { found, error } = await treeWalk(domain, lookup);
253
+
254
+ if (found[0]?.domain === domain) {
255
+ // the author domain's own record applies regardless of what is above it
256
+ if (error) {
257
+ return { record: found[0].record, recordDomain: domain, orgDomain: null, orgRecord: null, error };
258
+ }
259
+ let orgDomain = selectOrgDomain(domain, found);
260
+ return { record: found[0].record, recordDomain: domain, orgDomain, orgRecord: findOrgRecord(orgDomain, found).record || false };
261
+ }
262
+
263
+ if (error) {
264
+ throw error;
265
+ }
266
+
267
+ let orgDomain = selectOrgDomain(domain, found);
268
+
269
+ // otherwise the record of the Organizational Domain, and failing that the record of the PSD.
270
+ // A walk only stops at a psd tag, so a psd=y record can only be the last one found.
271
+ let entry = findOrgRecord(orgDomain, found);
272
+
273
+ return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain, orgRecord: entry ? entry.record : false };
274
+ };
275
+
276
+ // Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
277
+ // opts.strict parses records by the RFC 6376 tag-list rules (see parseDmarcRecord).
278
+ const getDmarcRecord = async (domain, resolver, opts) => {
279
+ domain = formatDomain(domain || '');
280
+
281
+ let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver, { strict: !!opts?.strict }));
282
+ if (!record) {
283
+ return false;
284
+ }
285
+
286
+ return Object.assign(record, {
287
+ // the record is inherited from the Organizational Domain or the PSD
288
+ isOrgRecord: recordDomain !== domain,
289
+ recordDomain,
290
+ orgDomain
291
+ });
84
292
  };
85
293
 
86
294
  module.exports = getDmarcRecord;
295
+ module.exports.createRecordLookup = createRecordLookup;
296
+ module.exports.discoverPolicy = discoverPolicy;
297
+ module.exports.getOrgDomain = getOrgDomain;
298
+ module.exports.getPolicyTags = getPolicyTags;
299
+ module.exports.domainExists = domainExists;
300
+ module.exports.parseDmarcRecord = parseDmarcRecord;
@@ -6,6 +6,33 @@ import { DMARCOptions, DMARCResult } from '../../index';
6
6
  * Verifies DMARC policy for a message
7
7
  *
8
8
  * @param opts - DMARC verification options
9
- * @returns DMARC verification result
9
+ * @returns DMARC verification result, or false when the From header has no domain or more than one domain
10
10
  */
11
- export function dmarc(opts: DMARCOptions): Promise<DMARCResult | false>;
11
+ export function dmarc(opts: DMARCOptions & { strict?: boolean }): Promise<DMARCResult | false>;
12
+
13
+ /**
14
+ * Verifies DMARC policy for a message, with details that dmarc() does not return
15
+ *
16
+ * @param opts - DMARC verification options
17
+ */
18
+ export function evaluateDmarc(opts: DMARCOptions & { strict?: boolean }): Promise<{
19
+ /**
20
+ * The dmarc() result
21
+ */
22
+ response: DMARCResult | false;
23
+
24
+ /**
25
+ * Signing domains (normalized) of the DKIM signatures that aligned
26
+ */
27
+ dkimAligned?: Set<string>;
28
+
29
+ /**
30
+ * Why DMARC validation was not possible (RFC 9989 5.3.1), set when response is false
31
+ */
32
+ reason?: 'no-author-domain' | 'multiple-author-domains' | 'invalid-author-domain' | 'multiple-from-fields';
33
+
34
+ /**
35
+ * The Author Domains found, set when response is false
36
+ */
37
+ authorDomains?: string[];
38
+ }>;
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const verifyDmarc = require('./verify');
4
+ const { evaluateDmarc } = verifyDmarc;
4
5
 
5
6
  const dmarc = async opts => verifyDmarc(opts);
6
7
 
7
- module.exports = { dmarc };
8
+ module.exports = { dmarc, evaluateDmarc };