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,48 +1,64 @@
1
1
  'use strict';
2
2
 
3
3
  const dns = require('node:dns').promises;
4
- const punycode = require('punycode.js');
5
- const tldts = require('tldts');
6
- const { formatAuthHeaderRow, getAlignment, TLDTS_OPTS } = require('../tools');
7
- const getDmarcRecord = require('./get-dmarc-record');
4
+ const { formatAuthHeaderRow, formatDomain } = require('../tools');
5
+ const { createRecordLookup, discoverPolicy, getOrgDomain, getPolicyTags, domainExists } = require('./get-dmarc-record');
6
+ const { getAuthorDomain } = require('./author-domain');
8
7
 
9
- const verifyDmarc = async opts => {
10
- let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts;
8
+ // Every Tree Walk is capped at eight queries, but the number of walks is not (RFC 9989 4.10.2),
9
+ // so a message can only make this many identifiers walk for relaxed alignment. An identifier
10
+ // past the limit is not known to align or not, so it is treated like one whose walk failed.
11
+ const MAX_ALIGNMENT_WALKS = 10;
11
12
 
12
- resolver = resolver || dns.resolve;
13
+ // t=y asks for the policy one level below the published one (RFC 9989 4.7 "t")
14
+ const TEST_MODE_POLICY = { reject: 'quarantine', quarantine: 'none', none: 'none' };
13
15
 
14
- if (Array.isArray(headerFrom)) {
15
- if (headerFrom.length === 1) {
16
- headerFrom = headerFrom[0];
17
- } else {
18
- // invalid number of FROM addresses found
19
- return false;
20
- }
21
- }
16
+ const POLICY_STRENGTH = { none: 0, quarantine: 1, reject: 2 };
22
17
 
23
- let atPos = headerFrom.indexOf('@');
24
- let domain = atPos >= 0 ? headerFrom.substr(atPos + 1) : headerFrom;
18
+ // Two domains can only have the same Organizational Domain if they have a common parent. With
19
+ // no common label at all no Tree Walk result could make them align.
20
+ const shareParent = (a, b) => a.split('.').pop() === b.split('.').pop();
25
21
 
26
- domain = domain.toLowerCase().trim();
22
+ const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
27
23
 
28
- try {
29
- domain = punycode.toASCII(domain);
30
- } catch (err) {
31
- // ignore punycode conversion errors
24
+ const evaluateDmarc = async opts => {
25
+ let { headerFrom, fromFields, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
26
+
27
+ strict = !!strict;
28
+ resolver = resolver || dns.resolve;
29
+
30
+ // RFC 9989 5.3.1: several mailboxes are fine if they share one domain, but with zero or more
31
+ // than one Author Domain DMARC validation is not possible. The response stays false, as it
32
+ // always was for these messages, and "reason" says why.
33
+ const author = getAuthorDomain(headerFrom, fromFields);
34
+ if (!author.authorDomain) {
35
+ return { response: false, reason: author.reason, authorDomains: author.domains };
32
36
  }
33
37
 
38
+ // "domain" is reported in header.from. "authorDomain" is the name to query and compare,
39
+ // where "example.com." and "example.com" are the same domain.
40
+ const { domain, authorDomain } = author;
41
+
42
+ // what the default mode accepted that strict mode would not
43
+ const warnings = new Set();
44
+
34
45
  let formatResponse = response => {
35
- response.info = formatAuthHeaderRow('dmarc', response.status);
46
+ if (warnings.size) {
47
+ response.warnings = Array.from(warnings);
48
+ }
49
+
50
+ response.info = formatAuthHeaderRow('dmarc', response.status, { strict });
36
51
 
37
52
  if (typeof response.status.comment === 'boolean') {
38
53
  delete response.status.comment;
39
54
  }
40
55
 
56
+ // for BIMI (draft-brand-indicators-for-message-identification 7.1), not part of the result
57
+ setHidden(response, 'authorMailboxes', author.mailboxes);
58
+
41
59
  return response;
42
60
  };
43
61
 
44
- let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
45
-
46
62
  let status = {
47
63
  result: 'neutral',
48
64
  comment: false,
@@ -54,67 +70,203 @@ const verifyDmarc = async opts => {
54
70
  }
55
71
  };
56
72
 
57
- let dmarcRecord;
73
+ // the strict parsing rejects what the default parsing would note, so it adds no warnings
74
+ const lookup = createRecordLookup(resolver, { strict, warnings });
75
+
76
+ let policyDiscovery;
58
77
  try {
59
- dmarcRecord = await getDmarcRecord(domain, resolver);
78
+ policyDiscovery = await discoverPolicy(authorDomain, lookup);
60
79
  } catch (err) {
61
80
  // temperror?
62
81
  status.result = 'temperror';
63
- return formatResponse({ status, domain: orgDomain || domain, error: err.message });
82
+ return { response: formatResponse({ status, domain: authorDomain, error: err.message }) };
64
83
  }
65
84
 
85
+ let { record: dmarcRecord, recordDomain, orgDomain, orgRecord } = policyDiscovery;
86
+
66
87
  if (!dmarcRecord) {
67
88
  // nothing to do here
68
89
  // none
69
90
  status.result = 'none';
70
- return formatResponse({ status, domain: orgDomain || domain });
91
+ return { response: formatResponse({ status, domain: orgDomain }) };
92
+ }
93
+
94
+ const policyTags = getPolicyTags(dmarcRecord);
95
+ if (!policyTags) {
96
+ // no valid policy and nowhere to report to, the record gets no DMARC processing (RFC 9989 4.10.1)
97
+ status.result = 'none';
98
+ return { response: formatResponse({ status, domain: orgDomain || authorDomain }) };
71
99
  }
72
100
 
73
- // the domain the applied policy record was published at
74
- status.header.d = dmarcRecord.isOrgRecord ? orgDomain : domain;
101
+ if (!strict) {
102
+ // The domain the applied policy record was published at, the DMARC Policy Domain. This
103
+ // property is not registered for dmarc (RFC 9989 9.1), so strict mode leaves it out and
104
+ // the domain is only reported in "policyDomain".
105
+ status.header.d = recordDomain;
106
+ }
107
+
108
+ let policy = policyTags.p;
109
+ // set when the existence query that decides between "sp" and "np" failed
110
+ let existenceError = null;
111
+ if (recordDomain !== authorDomain) {
112
+ // A record inherited by a subdomain applies "np" if the subdomain does not exist and "sp"
113
+ // if it does, both falling back to "p" (RFC 9989 4.10.1). Existence only matters with "np".
114
+ policy = policyTags.sp || policyTags.p;
115
+ if (policyTags.np) {
116
+ try {
117
+ if (!(await domainExists(authorDomain, resolver))) {
118
+ policy = policyTags.np;
119
+ }
120
+ } catch (err) {
121
+ if (policyTags.np !== policy) {
122
+ // The policy is one of the two, but which one is not known. This only matters
123
+ // for a message that does not pass. Until then the weaker one is assumed, so
124
+ // that nothing (such as BIMI) treats the domain as more enforcing than it is.
125
+ existenceError = err;
126
+ if (POLICY_STRENGTH[policyTags.np] < POLICY_STRENGTH[policy]) {
127
+ policy = policyTags.np;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ const testMode = dmarcRecord.t === 'y';
135
+ if (testMode) {
136
+ policy = TEST_MODE_POLICY[policy];
137
+ }
75
138
 
76
139
  status.comment = []
77
- .concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
78
- .concat(dmarcRecord.sp ? `sp=${dmarcRecord.sp.toUpperCase()}` : [])
140
+ .concat(`p=${policyTags.p.toUpperCase()}`)
141
+ .concat(policyTags.sp ? `sp=${policyTags.sp.toUpperCase()}` : [])
79
142
  .concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : [])
80
143
  .join(' ');
81
144
 
82
- // use "sp" if this is a subdomain of an org domain and "sp" is set, otherwise use "p"
83
- const policy = dmarcRecord.isOrgRecord && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
84
-
85
145
  const adkimStrict = dmarcRecord.adkim === 's';
86
146
  const aspfStrict = dmarcRecord.aspf === 's';
87
147
 
88
- const dkimAlignment = getAlignment(domain, dkimDomains, adkimStrict);
89
- const spfAlignment = getAlignment(domain, spfDomains, aspfStrict);
148
+ // Relaxed alignment compares Organizational Domains found by Tree Walks (RFC 9989 4.10.2).
149
+ // An identifier's Organizational Domain is always the identifier itself or one of its
150
+ // parents, so only identifiers at or below the author's Organizational Domain need a walk.
151
+ let orgDomains = new Map();
152
+ // throws when a Tree Walk the answer depends on failed, or was not made because of the limit
153
+ const alignsRelaxed = async entryDomain => {
154
+ if (entryDomain === authorDomain) {
155
+ return true;
156
+ }
157
+ if (!orgDomain) {
158
+ // The author's own Tree Walk failed, so there is nothing to compare against,
159
+ // unless the two domains have no parent in common and can never align
160
+ if (!shareParent(entryDomain, authorDomain)) {
161
+ return false;
162
+ }
163
+ throw policyDiscovery.error;
164
+ }
165
+ if (entryDomain !== orgDomain && !entryDomain.endsWith(`.${orgDomain}`)) {
166
+ return false;
167
+ }
168
+ if (!orgDomains.has(entryDomain)) {
169
+ if (orgDomains.size >= MAX_ALIGNMENT_WALKS) {
170
+ let err = new Error(`Alignment of ${entryDomain} not checked, limit of ${MAX_ALIGNMENT_WALKS} Tree Walks per message reached`);
171
+ err.code = 'EWALKLIMIT';
172
+ throw err;
173
+ }
174
+ orgDomains.set(entryDomain, getOrgDomain(entryDomain, lookup));
175
+ }
176
+ return (await orgDomains.get(entryDomain)) === orgDomain;
177
+ };
178
+
179
+ // Returns every entry that aligns, most preferred first, and the first walk error seen.
180
+ // Signatures with an l= tag come last, so that a signature covering the whole body is
181
+ // reported when several align.
182
+ const findAligned = async (entries, strictAlignment) => {
183
+ let list = []
184
+ .concat(entries || [])
185
+ .map(entry => (typeof entry === 'string' ? { domain: entry } : entry))
186
+ .filter(entry => entry && typeof entry.domain === 'string')
187
+ .sort((a, b) => (a.underSized || 0) - (b.underSized || 0));
188
+
189
+ let aligned = [];
190
+ let error = null;
191
+ for (let entry of list) {
192
+ let entryDomain = formatDomain(entry.domain);
193
+ if (!authorDomain || !entryDomain) {
194
+ // a bare root label normalizes to an empty string, which must not align with anything
195
+ continue;
196
+ }
197
+ try {
198
+ if (strictAlignment ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
199
+ aligned.push(entry);
200
+ }
201
+ } catch (err) {
202
+ error = error || err;
203
+ }
204
+ }
205
+
206
+ return { aligned, error };
207
+ };
208
+
209
+ // SPF first, so a flood of DKIM signatures can not use up the walks it needs
210
+ const spf = await findAligned(spfDomains, aspfStrict);
211
+ // every signature is checked, so that each one's own alignment can be reported
212
+ const dkim = await findAligned(dkimDomains, adkimStrict);
213
+ const spfAlignment = spf.aligned[0];
214
+ const dkimAlignment = dkim.aligned[0];
215
+ const walkError = spf.error || dkim.error;
90
216
 
91
217
  // "underSized" warns that a passing DKIM signature covers only part of the body (l= tag).
92
218
  // It describes the signature rather than the alignment mode, so it is still reported when
93
- // strict alignment rejected a signature that aligns at the org level.
94
- const underSized = (adkimStrict && !dkimAlignment ? getAlignment(domain, dkimDomains, false) : dkimAlignment)?.underSized;
219
+ // strict alignment rejected a signature that aligns at the org level. A walk that fails
220
+ // for this warning alone does not affect the result.
221
+ const underSized = adkimStrict && !dkimAlignment ? (await findAligned(dkimDomains, false)).aligned[0]?.underSized : dkimAlignment?.underSized;
222
+
223
+ let response = {
224
+ status,
225
+ domain: orgDomain || authorDomain,
226
+ // the DMARC Policy Domain (RFC 9989 3.2.5), where the applied record was published.
227
+ // This is the domain RFC 9990 aggregate reports list as the published policy's domain.
228
+ policyDomain: recordDomain,
229
+ policy,
230
+ p: policyTags.p,
231
+ sp: policyTags.sp || policyTags.p,
232
+ np: policyTags.np,
233
+ testMode,
234
+ rr: dmarcRecord.rr,
235
+
236
+ alignment: {
237
+ spf: { result: spfAlignment?.domain, strict: aspfStrict },
238
+ dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
239
+ }
240
+ };
95
241
 
96
242
  if (dkimAlignment || spfAlignment) {
97
243
  // pass
98
244
  status.result = 'pass';
245
+ } else if (walkError || existenceError) {
246
+ // an identifier might have aligned if its Tree Walk had not failed, or the
247
+ // policy to apply is not known because the author domain's existence is not
248
+ status.result = 'temperror';
249
+ response.error = (walkError || existenceError).message;
99
250
  } else {
100
251
  // fail
101
252
  status.result = 'fail';
102
253
  }
103
254
 
104
- return formatResponse({
105
- status,
106
- domain: orgDomain || domain,
107
- policy,
108
- p: dmarcRecord.p,
109
- sp: dmarcRecord.sp || dmarcRecord.p,
110
- pct: dmarcRecord.pct,
111
- rr: dmarcRecord.rr,
255
+ if (status.result !== 'temperror' && !existenceError) {
256
+ // RFC 9989 9.1: the evaluated policy after the policy options have been processed
257
+ status.policy = { dmarc: policy };
258
+ }
112
259
 
113
- alignment: {
114
- spf: { result: spfAlignment?.domain, strict: aspfStrict },
115
- dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
116
- }
117
- });
260
+ // for BIMI (draft-brand-indicators-for-message-identification 7.1), not part of the result:
261
+ // the applied record and the record holding the Organizational Domain's policy (null if unknown)
262
+ setHidden(response, 'record', dmarcRecord);
263
+ setHidden(response, 'orgRecord', orgRecord);
264
+
265
+ return { response: formatResponse(response), dkimAligned: new Set(dkim.aligned.map(entry => formatDomain(entry.domain))) };
118
266
  };
119
267
 
268
+ const verifyDmarc = async opts => (await evaluateDmarc(opts)).response;
269
+
120
270
  module.exports = verifyDmarc;
271
+ // also returns the signing domains that aligned, for the per signature flags of authenticate()
272
+ module.exports.evaluateDmarc = evaluateDmarc;
package/lib/mailauth.js CHANGED
@@ -3,12 +3,12 @@
3
3
  const { dkimSign, DkimSignStream } = require('./dkim/sign');
4
4
  const { dkimVerify } = require('./dkim/verify');
5
5
  const { spf } = require('./spf');
6
- const { dmarc } = require('./dmarc');
6
+ const { dmarc, evaluateDmarc } = require('./dmarc');
7
7
  const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
8
8
  const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
9
9
  const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
10
- const { parseReceived } = require('./parse-received');
11
- const { getAlignment } = require('./tools');
10
+ const { parseReceived, getClientAddress } = require('./parse-received');
11
+ const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
12
12
  const libmime = require('libmime');
13
13
  const os = require('node:os');
14
14
  const { isIP } = require('net');
@@ -31,18 +31,25 @@ const { isIP } = require('net');
31
31
  * @param {Boolean} [opts.disableArc=false] If true then do not perform ARC validation and sealing
32
32
  * @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
33
33
  * @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
34
+ * @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
34
35
  * @returns Authentication result
35
36
  */
36
37
  const authenticate = async (input, opts) => {
37
38
  opts = Object.assign({}, opts); // copy keys
38
39
 
39
40
  opts.mta = opts.mta || os.hostname();
41
+ opts.strict = !!opts.strict;
42
+
43
+ // RFC 8601 section 2.5: the authserv-id, usually a host name. A value that is not a token is
44
+ // quoted, or throws under strict, before any work is done
45
+ const authservId = formatAuthservId(opts.mta, opts.strict);
40
46
 
41
47
  const dkimResult = await dkimVerify(input, {
42
48
  resolver: opts.resolver,
43
49
  sender: opts.sender, // defaults to Return-Path header
44
50
  seal: opts.seal,
45
- minBitLength: opts.minBitLength
51
+ minBitLength: opts.minBitLength,
52
+ strict: opts.strict
46
53
  });
47
54
 
48
55
  const receivedChain = dkimResult.headers?.parsed.filter(r => r.key === 'received').map(row => parseReceived(row.line));
@@ -52,13 +59,9 @@ const authenticate = async (input, opts) => {
52
59
  let rcvd = receivedChain?.find(row => row.from?.value);
53
60
  if (rcvd) {
54
61
  let helo = rcvd.from.value;
55
- let ip;
56
- if (rcvd.from.comment) {
57
- let ipMatch = rcvd.from.comment.match(/\[([^\]]+)\]/);
58
- if (ipMatch) {
59
- ip = ipMatch[1].replace(/^IPv6:/i, '');
60
- }
61
- }
62
+ // the address of the connection from the TCP-info part of the from clause
63
+ // (RFC 5321 section 4.4), never an address the client claimed in its HELO
64
+ let ip = getClientAddress(rcvd.from);
62
65
 
63
66
  if (ip && !opts.ip) {
64
67
  opts.ip = ip;
@@ -95,12 +98,16 @@ const authenticate = async (input, opts) => {
95
98
  if (!opts.disableArc) {
96
99
  arcResult = await arc(dkimResult.arc, {
97
100
  resolver: opts.resolver,
98
- minBitLength: opts.minBitLength
101
+ minBitLength: opts.minBitLength,
102
+ strict: opts.strict,
103
+ // reported as smtp.remote-ip in strict mode (RFC 8617 section 6)
104
+ ip: opts.ip
99
105
  });
100
106
  }
101
107
 
102
108
  let headers = [];
103
109
  let arHeader = [];
110
+ let receivedSpfHeader = false;
104
111
 
105
112
  dkimResult?.results?.forEach(row => {
106
113
  arHeader.push(`${libmime.foldLines(row.info, 160)}`);
@@ -108,7 +115,7 @@ const authenticate = async (input, opts) => {
108
115
 
109
116
  if (spfResult) {
110
117
  arHeader.push(libmime.foldLines(spfResult.info, 160));
111
- headers.push(spfResult.header);
118
+ receivedSpfHeader = spfResult.header;
112
119
  }
113
120
 
114
121
  if (arcResult?.info) {
@@ -116,9 +123,16 @@ const authenticate = async (input, opts) => {
116
123
  }
117
124
 
118
125
  let dmarcResult;
126
+ let dmarcSkipReason;
119
127
  if (!opts.disableDmarc && dkimResult?.headerFrom) {
120
- dmarcResult = await dmarc({
128
+ let dkimAligned;
129
+ ({
130
+ response: dmarcResult,
131
+ dkimAligned,
132
+ reason: dmarcSkipReason
133
+ } = await evaluateDmarc({
121
134
  headerFrom: dkimResult.headerFrom,
135
+ fromFields: dkimResult.fromFields,
122
136
  spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
123
137
  dkimDomains: (dkimResult.results || [])
124
138
  .filter(r => r.status.result === 'pass')
@@ -129,17 +143,24 @@ const authenticate = async (input, opts) => {
129
143
  underSized: r.status.underSized
130
144
  })),
131
145
  arcResult,
132
- resolver: opts.resolver
133
- });
134
- if (dmarcResult.info) {
146
+ resolver: opts.resolver,
147
+ strict: opts.strict
148
+ }));
149
+ if (dmarcResult?.info) {
135
150
  arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
136
151
  }
137
152
 
138
- if (dmarcResult.alignment?.dkim?.strict) {
139
- // the DKIM verifier only knows relaxed alignment, it runs before the DMARC record
153
+ if (dkimAligned && dmarcResult) {
154
+ // The DKIM verifier runs before the DMARC record and the Tree Walks are known, so its
155
+ // per signature flag only guesses relaxed alignment from the Public Suffix List.
156
+ // Passing signatures take the DMARC verdict, failing ones the record's alignment mode.
140
157
  let fromDomain = dmarcResult.status.header.from;
141
158
  for (let row of dkimResult.results || []) {
142
- row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
159
+ if (row.status.result === 'pass') {
160
+ row.status.aligned = dkimAligned.has(formatDomain(row.signingDomain)) ? row.signingDomain : false;
161
+ } else if (dmarcResult.alignment.dkim.strict) {
162
+ row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
163
+ }
143
164
  }
144
165
  }
145
166
  }
@@ -150,7 +171,8 @@ const authenticate = async (input, opts) => {
150
171
  dmarc: dmarcResult,
151
172
  headers: dkimResult.headers,
152
173
  bimiWithAlignedDkim: opts.bimiWithAlignedDkim,
153
- resolver: opts.resolver
174
+ resolver: opts.resolver,
175
+ strict: opts.strict
154
176
  });
155
177
  }
156
178
 
@@ -158,42 +180,57 @@ const authenticate = async (input, opts) => {
158
180
  arHeader.push(`${libmime.foldLines(bimiResult.info, 160)}`);
159
181
  }
160
182
 
161
- headers.push(`Authentication-Results: ${opts.mta};\r\n ` + arHeader.join(';\r\n '));
183
+ const arValue = `${authservId};\r\n ` + arHeader.join(';\r\n ');
184
+ const authResultsHeader = `Authentication-Results: ${arValue}`;
185
+
186
+ if (opts.strict) {
187
+ // RFC 8601 section 5: Authentication-Results "SHOULD be inserted above any other
188
+ // trace header fields", and Received-SPF is one (RFC 7208 section 9.1)
189
+ headers.push(authResultsHeader);
190
+ if (receivedSpfHeader) {
191
+ headers.push(receivedSpfHeader);
192
+ }
193
+ } else {
194
+ if (receivedSpfHeader) {
195
+ headers.push(receivedSpfHeader);
196
+ }
197
+ headers.push(authResultsHeader);
198
+ }
162
199
 
163
200
  if (arcResult) {
164
- arcResult.authResults = `${opts.mta};\r\n ` + arHeader.join(';\r\n ');
201
+ arcResult.authResults = arValue;
165
202
  }
166
203
 
167
204
  // seal only messages with a valid ARC chain
168
205
  if (dkimResult?.seal && (['none', 'pass'].includes(arcResult?.status?.result) || arcResult?.status?.shouldSeal)) {
169
- let i = arcResult.i + 1;
170
- let seal = Object.assign(
171
- {
172
- i,
173
- cv: arcResult.status.result,
174
- authResults: arcResult.authResults,
175
- signTime: new Date()
176
- },
177
- dkimResult.seal
178
- );
179
-
180
- // get ARC sealing headers to prepend to the message
206
+ // createSeal picks the instance: the next one after the highest on the message, even if
207
+ // some of the ARC headers were not part of a valid chain, and never a 51st set
181
208
  let sealResult = await createSeal(false, {
182
209
  headers: dkimResult.headers,
183
210
  arc: dkimResult.arc,
184
- seal
211
+ seal: Object.assign(
212
+ {
213
+ cv: arcResult.status.result,
214
+ authResults: arcResult.authResults,
215
+ signTime: new Date()
216
+ },
217
+ dkimResult.seal
218
+ ),
219
+ strict: opts.strict
185
220
  });
186
221
 
187
222
  if (sealResult?.errors?.length) {
188
- // sealing failed, usually an unusable key. The message goes out unsealed, so
189
- // say why somewhere the caller can find it rather than dropping it silently
223
+ // sealing failed, usually an unusable key or a chain that is already full. The
224
+ // message goes out unsealed, so say why somewhere the caller can find it rather
225
+ // than dropping it silently
190
226
  arcResult.sealErrors = sealResult.errors;
191
227
  }
192
228
 
193
- sealResult?.headers?.reverse().forEach(header => headers.unshift(header));
229
+ // the ARC headers go on top of the message
230
+ headers.unshift(...(sealResult?.headers || []));
194
231
  }
195
232
 
196
- return {
233
+ const result = {
197
234
  dkim: dkimResult,
198
235
  spf: spfResult,
199
236
  dmarc: dmarcResult || false,
@@ -202,6 +239,15 @@ const authenticate = async (input, opts) => {
202
239
  receivedChain,
203
240
  headers: headers.join('\r\n') + '\r\n'
204
241
  };
242
+
243
+ if (!dmarcResult && dmarcSkipReason) {
244
+ // RFC 9989 section 5.3.1: DMARC can not be evaluated for a message with no Author
245
+ // Domain or with more than one, and section 11.5 suggests treating such a message as
246
+ // suspicious. `dmarc` stays false, this says why
247
+ result.dmarcSkipReason = dmarcSkipReason;
248
+ }
249
+
250
+ return result;
205
251
  };
206
252
 
207
253
  module.exports = {