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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +117 -41
- package/bin/mailauth.js +14 -6
- package/cli.md +8 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +82 -31
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +499 -45
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +2 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +574 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +267 -53
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +205 -53
- package/lib/mailauth.js +86 -40
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +524 -118
- package/package.json +12 -12
package/lib/dmarc/verify.js
CHANGED
|
@@ -1,48 +1,64 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const dns = require('node:dns').promises;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
22
|
+
const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
policyDiscovery = await discoverPolicy(authorDomain, lookup);
|
|
60
79
|
} catch (err) {
|
|
61
80
|
// temperror?
|
|
62
81
|
status.result = 'temperror';
|
|
63
|
-
return formatResponse({ status, domain:
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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(
|
|
78
|
-
.concat(
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
189
|
-
// say why somewhere the caller can find it rather
|
|
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
|
-
|
|
229
|
+
// the ARC headers go on top of the message
|
|
230
|
+
headers.unshift(...(sealResult?.headers || []));
|
|
194
231
|
}
|
|
195
232
|
|
|
196
|
-
|
|
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 = {
|