mailauth 5.0.2 → 6.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 +24 -0
- package/README.md +6 -2
- package/docs/dmarc.md +17 -13
- package/index.d.ts +12 -4
- package/lib/arc/index.d.ts +2 -2
- package/lib/arc/index.js +16 -21
- package/lib/dkim/body/line-endings.js +34 -0
- package/lib/dkim/body/relaxed.js +35 -38
- package/lib/dkim/body/simple.js +72 -36
- package/lib/dkim/dkim-signer.js +16 -0
- package/lib/dkim/dkim-verifier.js +4 -7
- package/lib/dkim/header/relaxed.js +7 -13
- package/lib/dkim/header/simple.js +7 -13
- package/lib/dkim/message-parser.js +21 -12
- package/lib/dmarc/get-dmarc-record.js +159 -38
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +108 -32
- package/lib/mailauth.js +20 -7
- package/lib/tools.js +90 -18
- package/package.json +12 -12
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const { formatSignatureHeaderLine, formatRelaxedLine, getCurTime } = require('../../../lib/tools');
|
|
4
|
+
const { formatSignatureHeaderLine, formatRelaxedLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
|
|
5
5
|
|
|
6
6
|
// generate headers for signing
|
|
7
7
|
const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
@@ -34,9 +34,11 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
|
34
34
|
opts.i = instance;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// The same opts object formats the line that gets signed here and, once the
|
|
38
|
+
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
39
|
+
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
40
|
+
// boundary falling between the two reads signs one timestamp and publishes another
|
|
41
|
+
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
40
42
|
|
|
41
43
|
if (expires) {
|
|
42
44
|
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
@@ -55,15 +57,7 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
|
55
57
|
);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
chunks.push(
|
|
59
|
-
Buffer.from(
|
|
60
|
-
formatRelaxedLine(signatureHeaderLine)
|
|
61
|
-
.toString('binary')
|
|
62
|
-
// remove value from b= key
|
|
63
|
-
.replace(/([;:\s]+b=)[^;]+/, '$1'),
|
|
64
|
-
'binary'
|
|
65
|
-
)
|
|
66
|
-
);
|
|
60
|
+
chunks.push(stripSignatureValue(formatRelaxedLine(signatureHeaderLine)));
|
|
67
61
|
|
|
68
62
|
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
|
|
69
63
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const { formatSignatureHeaderLine, getCurTime } = require('../../../lib/tools');
|
|
4
|
+
const { formatSignatureHeaderLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
|
|
5
5
|
|
|
6
6
|
const formatSimpleLine = (line, suffix) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary');
|
|
7
7
|
|
|
@@ -36,9 +36,11 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
|
|
|
36
36
|
opts.i = instance;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// The same opts object formats the line that gets signed here and, once the
|
|
40
|
+
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
41
|
+
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
42
|
+
// boundary falling between the two reads signs one timestamp and publishes another
|
|
43
|
+
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
42
44
|
|
|
43
45
|
if (expires) {
|
|
44
46
|
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
@@ -57,15 +59,7 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
|
|
|
57
59
|
);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
chunks.push(
|
|
61
|
-
Buffer.from(
|
|
62
|
-
formatSimpleLine(signatureHeaderLine)
|
|
63
|
-
.toString('binary')
|
|
64
|
-
// remove value from b= key
|
|
65
|
-
.replace(/([;:\s]+b=)[^;]+/, '$1'),
|
|
66
|
-
'binary'
|
|
67
|
-
)
|
|
68
|
-
);
|
|
62
|
+
chunks.push(stripSignatureValue(formatSimpleLine(signatureHeaderLine)));
|
|
69
63
|
|
|
70
64
|
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
|
|
71
65
|
};
|
|
@@ -37,6 +37,17 @@ class MessageParser extends Writable {
|
|
|
37
37
|
// Override in child class
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Parses everything collected so far as the header block and hands it over. The body
|
|
41
|
+
// hash and the signatures are only ever built after this has run
|
|
42
|
+
async emitHeaders(chunk) {
|
|
43
|
+
this.state = 'body';
|
|
44
|
+
if (chunk) {
|
|
45
|
+
this.headerChunks.push(chunk);
|
|
46
|
+
}
|
|
47
|
+
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
48
|
+
await this.messageHeaders(this.headers);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
async processChunk(chunk) {
|
|
41
52
|
if (!chunk || !chunk.length) {
|
|
42
53
|
return;
|
|
@@ -57,17 +68,12 @@ class MessageParser extends Writable {
|
|
|
57
68
|
|
|
58
69
|
if (b0 === 0x0a && (b1 === 0x0a || (b1 === 0x0d && b2 === 0x0a))) {
|
|
59
70
|
// found header ending
|
|
60
|
-
this.state = 'body';
|
|
61
71
|
if (i === chunk.length - 1) {
|
|
62
72
|
//end of chunk
|
|
63
|
-
this.
|
|
64
|
-
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
65
|
-
await this.messageHeaders(this.headers);
|
|
73
|
+
await this.emitHeaders(chunk);
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
68
|
-
this.
|
|
69
|
-
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
70
|
-
await this.messageHeaders(this.headers);
|
|
76
|
+
await this.emitHeaders(chunk.slice(0, i + 1));
|
|
71
77
|
chunk = chunk.slice(i + 1);
|
|
72
78
|
break;
|
|
73
79
|
}
|
|
@@ -132,13 +138,16 @@ class MessageParser extends Writable {
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
async finish() {
|
|
141
|
+
if (!this.headers) {
|
|
142
|
+
// no body, so no empty line ended the header block. Hand the headers out
|
|
143
|
+
// before the final chunk, which is where signatures are checked or created.
|
|
144
|
+
// An input with no bytes at all goes through here too, so that the rest of
|
|
145
|
+
// the pipeline always has a header set to look at
|
|
146
|
+
await this.emitHeaders();
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
// generate final hash and emit it
|
|
136
150
|
await this.finalChunk();
|
|
137
|
-
|
|
138
|
-
if (!this.headers && this.headerChunks.length) {
|
|
139
|
-
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
140
|
-
await this.messageHeaders(this.headers);
|
|
141
|
-
}
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
_final(callback) {
|
|
@@ -1,58 +1,38 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const dns = require('node:dns').promises;
|
|
4
|
-
const
|
|
5
|
-
const {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
|
|
24
|
-
} catch (err) {
|
|
25
|
-
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
throw err;
|
|
29
|
-
}
|
|
30
|
-
};
|
|
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']);
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (records.length !== 1) {
|
|
50
|
-
// no record, or a multi record set that terminates policy discovery
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
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;
|
|
53
26
|
|
|
54
|
-
|
|
27
|
+
// Whether "_dmarc.<name>" can exist in the DNS: no empty labels, labels of at most 63 octets
|
|
28
|
+
// and a name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a
|
|
29
|
+
// resolver error that looks like a transient failure.
|
|
30
|
+
const isQueryable = name => {
|
|
31
|
+
let fqdn = `_dmarc.${name}`;
|
|
32
|
+
return fqdn.length <= 253 && fqdn.split('.').every(label => label.length && label.length <= 63);
|
|
33
|
+
};
|
|
55
34
|
|
|
35
|
+
const parseDmarcRecord = txt => {
|
|
56
36
|
let parsed = Object.fromEntries(
|
|
57
37
|
txt
|
|
58
38
|
.split(';')
|
|
@@ -78,9 +58,150 @@ const getDmarcRecord = async (domain, resolver) => {
|
|
|
78
58
|
);
|
|
79
59
|
|
|
80
60
|
parsed.rr = txt;
|
|
81
|
-
parsed.isOrgRecord = isOrgRecord;
|
|
82
61
|
|
|
83
62
|
return parsed;
|
|
84
63
|
};
|
|
85
64
|
|
|
65
|
+
// Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
|
|
66
|
+
// records counts as no record, it is discarded (RFC 9989 4.10 steps 2 and 6).
|
|
67
|
+
const fetchRecord = async (name, resolver) => {
|
|
68
|
+
if (!isQueryable(name)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let records;
|
|
73
|
+
try {
|
|
74
|
+
let txt = await resolver(`_dmarc.${name}`, 'TXT');
|
|
75
|
+
records = (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (NO_RECORD_CODES.has(err.code)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return records.length === 1 ? parseDmarcRecord(records[0]) : false;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Tree Walks for one message share most of their names, so each name is queried only once
|
|
87
|
+
const createRecordLookup = resolver => {
|
|
88
|
+
resolver = resolver || dns.resolve;
|
|
89
|
+
let cache = new Map();
|
|
90
|
+
return name => {
|
|
91
|
+
if (!cache.has(name)) {
|
|
92
|
+
cache.set(name, fetchRecord(name, resolver));
|
|
93
|
+
}
|
|
94
|
+
return cache.get(name);
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// DNS Tree Walk (RFC 9989 4.10). Returns the records found, longest name first. The walk
|
|
99
|
+
// stops at a record with a psd=y or psd=n tag, or when no labels are left, so it makes
|
|
100
|
+
// at most eight queries. A DNS error ends the walk and is returned next to the records
|
|
101
|
+
// found before it, because a record found at the start domain is usable without the rest.
|
|
102
|
+
const treeWalk = async (domain, lookup) => {
|
|
103
|
+
let found = [];
|
|
104
|
+
let labels = domain.split('.');
|
|
105
|
+
|
|
106
|
+
while (labels.length) {
|
|
107
|
+
let name = labels.join('.');
|
|
108
|
+
let record;
|
|
109
|
+
try {
|
|
110
|
+
record = await lookup(name);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { found, error: err };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (record) {
|
|
116
|
+
found.push({ domain: name, record });
|
|
117
|
+
if (record.psd === 'y' || record.psd === 'n') {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
labels = labels.length > WALK_MAX_LABELS ? labels.slice(-WALK_MAX_LABELS) : labels.slice(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { found };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Organizational Domain selection for a completed Tree Walk (RFC 9989 4.10.2)
|
|
129
|
+
const selectOrgDomain = (domain, found) => {
|
|
130
|
+
for (let { domain: recordDomain, record } of found) {
|
|
131
|
+
if (record.psd === 'n') {
|
|
132
|
+
return recordDomain;
|
|
133
|
+
}
|
|
134
|
+
if (record.psd === 'y' && recordDomain !== domain) {
|
|
135
|
+
// the domain one label below the PSD
|
|
136
|
+
let labelCount = recordDomain.split('.').length + 1;
|
|
137
|
+
return domain.split('.').slice(-labelCount).join('.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// the record with the fewest labels, or the start domain itself when nothing was found
|
|
142
|
+
return found.length ? found[found.length - 1].domain : domain;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Returns the Organizational Domain of a normalized domain name. Throws on DNS errors.
|
|
146
|
+
const getOrgDomain = async (domain, lookup) => {
|
|
147
|
+
if (isAddress(domain)) {
|
|
148
|
+
return domain;
|
|
149
|
+
}
|
|
150
|
+
let { found, error } = await treeWalk(domain, lookup);
|
|
151
|
+
if (error) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return selectOrgDomain(domain, found);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// DMARC Policy Discovery (RFC 9989 4.10.1) for a normalized author domain. Returns
|
|
158
|
+
// { record, recordDomain, orgDomain, error }, where "record" is false when no policy applies.
|
|
159
|
+
// When the Tree Walk fails after a record was found at the author domain, "orgDomain" is null
|
|
160
|
+
// and "error" says why. Throws on DNS errors that leave the policy unknown.
|
|
161
|
+
const discoverPolicy = async (domain, lookup) => {
|
|
162
|
+
if (isAddress(domain)) {
|
|
163
|
+
return { record: false, recordDomain: null, orgDomain: domain };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let { found, error } = await treeWalk(domain, lookup);
|
|
167
|
+
|
|
168
|
+
if (found[0]?.domain === domain) {
|
|
169
|
+
// the author domain's own record applies regardless of what is above it
|
|
170
|
+
return { record: found[0].record, recordDomain: domain, orgDomain: error ? null : selectOrgDomain(domain, found), error };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (error) {
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let orgDomain = selectOrgDomain(domain, found);
|
|
178
|
+
|
|
179
|
+
// otherwise the record of the Organizational Domain, and failing that the record of the PSD.
|
|
180
|
+
// A walk only stops at a psd tag, so a psd=y record can only be the last one found.
|
|
181
|
+
let last = found[found.length - 1];
|
|
182
|
+
let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
|
|
183
|
+
|
|
184
|
+
return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
|
|
188
|
+
const getDmarcRecord = async (domain, resolver) => {
|
|
189
|
+
domain = formatDomain(domain || '');
|
|
190
|
+
|
|
191
|
+
let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver));
|
|
192
|
+
if (!record) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Object.assign(record, {
|
|
197
|
+
// the record is inherited from the Organizational Domain or the PSD
|
|
198
|
+
isOrgRecord: recordDomain !== domain,
|
|
199
|
+
recordDomain,
|
|
200
|
+
orgDomain
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
86
204
|
module.exports = getDmarcRecord;
|
|
205
|
+
module.exports.createRecordLookup = createRecordLookup;
|
|
206
|
+
module.exports.discoverPolicy = discoverPolicy;
|
|
207
|
+
module.exports.getOrgDomain = getOrgDomain;
|
package/lib/dmarc/index.js
CHANGED
package/lib/dmarc/verify.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const dns = require('node:dns').promises;
|
|
4
3
|
const punycode = require('punycode.js');
|
|
5
|
-
const
|
|
6
|
-
const {
|
|
7
|
-
const getDmarcRecord = require('./get-dmarc-record');
|
|
4
|
+
const { formatAuthHeaderRow, formatDomain } = require('../tools');
|
|
5
|
+
const { createRecordLookup, discoverPolicy, getOrgDomain } = require('./get-dmarc-record');
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// Every Tree Walk is capped at eight queries, but the number of walks is not (RFC 9989 4.10.2),
|
|
8
|
+
// so a message can only make this many identifiers walk for relaxed alignment. The rest do not align.
|
|
9
|
+
const MAX_ALIGNMENT_WALKS = 10;
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
const evaluateDmarc = async opts => {
|
|
12
|
+
let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts;
|
|
13
13
|
|
|
14
14
|
if (Array.isArray(headerFrom)) {
|
|
15
15
|
if (headerFrom.length === 1) {
|
|
16
16
|
headerFrom = headerFrom[0];
|
|
17
17
|
} else {
|
|
18
18
|
// invalid number of FROM addresses found
|
|
19
|
-
return false;
|
|
19
|
+
return { response: false };
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -31,6 +31,9 @@ const verifyDmarc = async opts => {
|
|
|
31
31
|
// ignore punycode conversion errors
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// the name to query and compare, "example.com." and "example.com" are the same domain
|
|
35
|
+
const authorDomain = formatDomain(domain);
|
|
36
|
+
|
|
34
37
|
let formatResponse = response => {
|
|
35
38
|
response.info = formatAuthHeaderRow('dmarc', response.status);
|
|
36
39
|
|
|
@@ -41,8 +44,6 @@ const verifyDmarc = async opts => {
|
|
|
41
44
|
return response;
|
|
42
45
|
};
|
|
43
46
|
|
|
44
|
-
let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
|
|
45
|
-
|
|
46
47
|
let status = {
|
|
47
48
|
result: 'neutral',
|
|
48
49
|
comment: false,
|
|
@@ -54,24 +55,28 @@ const verifyDmarc = async opts => {
|
|
|
54
55
|
}
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
const lookup = createRecordLookup(resolver);
|
|
59
|
+
|
|
60
|
+
let policyDiscovery;
|
|
58
61
|
try {
|
|
59
|
-
|
|
62
|
+
policyDiscovery = await discoverPolicy(authorDomain, lookup);
|
|
60
63
|
} catch (err) {
|
|
61
64
|
// temperror?
|
|
62
65
|
status.result = 'temperror';
|
|
63
|
-
return formatResponse({ status, domain:
|
|
66
|
+
return { response: formatResponse({ status, domain: authorDomain, error: err.message }) };
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
let { record: dmarcRecord, recordDomain, orgDomain } = policyDiscovery;
|
|
70
|
+
|
|
66
71
|
if (!dmarcRecord) {
|
|
67
72
|
// nothing to do here
|
|
68
73
|
// none
|
|
69
74
|
status.result = 'none';
|
|
70
|
-
return formatResponse({ status, domain: orgDomain
|
|
75
|
+
return { response: formatResponse({ status, domain: orgDomain }) };
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
// the domain the applied policy record was published at
|
|
74
|
-
status.header.d =
|
|
79
|
+
status.header.d = recordDomain;
|
|
75
80
|
|
|
76
81
|
status.comment = []
|
|
77
82
|
.concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
|
|
@@ -79,31 +84,84 @@ const verifyDmarc = async opts => {
|
|
|
79
84
|
.concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : [])
|
|
80
85
|
.join(' ');
|
|
81
86
|
|
|
82
|
-
// use "sp" if
|
|
83
|
-
const policy =
|
|
87
|
+
// use "sp" if the record was inherited by a subdomain and "sp" is set, otherwise use "p"
|
|
88
|
+
const policy = recordDomain !== authorDomain && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
|
|
84
89
|
|
|
85
90
|
const adkimStrict = dmarcRecord.adkim === 's';
|
|
86
91
|
const aspfStrict = dmarcRecord.aspf === 's';
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
// Relaxed alignment compares Organizational Domains found by Tree Walks (RFC 9989 4.10.2).
|
|
94
|
+
// An identifier's Organizational Domain is always the identifier itself or one of its
|
|
95
|
+
// parents, so only identifiers at or below the author's Organizational Domain need a walk.
|
|
96
|
+
let orgDomains = new Map();
|
|
97
|
+
// throws when a Tree Walk the answer depends on failed
|
|
98
|
+
const alignsRelaxed = async entryDomain => {
|
|
99
|
+
if (entryDomain === authorDomain) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (!orgDomain) {
|
|
103
|
+
// the author's own Tree Walk failed, so there is nothing to compare against
|
|
104
|
+
throw policyDiscovery.error;
|
|
105
|
+
}
|
|
106
|
+
if (entryDomain !== orgDomain && !entryDomain.endsWith(`.${orgDomain}`)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!orgDomains.has(entryDomain)) {
|
|
110
|
+
if (orgDomains.size >= MAX_ALIGNMENT_WALKS) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
orgDomains.set(entryDomain, getOrgDomain(entryDomain, lookup));
|
|
114
|
+
}
|
|
115
|
+
return (await orgDomains.get(entryDomain)) === orgDomain;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Returns every entry that aligns, most preferred first, and the first walk error seen.
|
|
119
|
+
// Signatures with an l= tag come last, so that a signature covering the whole body is
|
|
120
|
+
// reported when several align.
|
|
121
|
+
const findAligned = async (entries, strict) => {
|
|
122
|
+
let list = []
|
|
123
|
+
.concat(entries || [])
|
|
124
|
+
.map(entry => (typeof entry === 'string' ? { domain: entry } : entry))
|
|
125
|
+
.filter(entry => entry && typeof entry.domain === 'string')
|
|
126
|
+
.sort((a, b) => (a.underSized || 0) - (b.underSized || 0));
|
|
127
|
+
|
|
128
|
+
let aligned = [];
|
|
129
|
+
let error = null;
|
|
130
|
+
for (let entry of list) {
|
|
131
|
+
let entryDomain = formatDomain(entry.domain);
|
|
132
|
+
if (!authorDomain || !entryDomain) {
|
|
133
|
+
// a bare root label normalizes to an empty string, which must not align with anything
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (strict ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
|
|
138
|
+
aligned.push(entry);
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
error = error || err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { aligned, error };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// SPF first, so a flood of DKIM signatures can not use up the walks it needs
|
|
149
|
+
const spf = await findAligned(spfDomains, aspfStrict);
|
|
150
|
+
// every signature is checked, so that each one's own alignment can be reported
|
|
151
|
+
const dkim = await findAligned(dkimDomains, adkimStrict);
|
|
152
|
+
const spfAlignment = spf.aligned[0];
|
|
153
|
+
const dkimAlignment = dkim.aligned[0];
|
|
154
|
+
const walkError = spf.error || dkim.error;
|
|
90
155
|
|
|
91
156
|
// "underSized" warns that a passing DKIM signature covers only part of the body (l= tag).
|
|
92
157
|
// 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
|
-
|
|
95
|
-
|
|
96
|
-
if (dkimAlignment || spfAlignment) {
|
|
97
|
-
// pass
|
|
98
|
-
status.result = 'pass';
|
|
99
|
-
} else {
|
|
100
|
-
// fail
|
|
101
|
-
status.result = 'fail';
|
|
102
|
-
}
|
|
158
|
+
// strict alignment rejected a signature that aligns at the org level. A walk that fails
|
|
159
|
+
// for this warning alone does not affect the result.
|
|
160
|
+
const underSized = adkimStrict && !dkimAlignment ? (await findAligned(dkimDomains, false)).aligned[0]?.underSized : dkimAlignment?.underSized;
|
|
103
161
|
|
|
104
|
-
|
|
162
|
+
let response = {
|
|
105
163
|
status,
|
|
106
|
-
domain: orgDomain ||
|
|
164
|
+
domain: orgDomain || authorDomain,
|
|
107
165
|
policy,
|
|
108
166
|
p: dmarcRecord.p,
|
|
109
167
|
sp: dmarcRecord.sp || dmarcRecord.p,
|
|
@@ -114,7 +172,25 @@ const verifyDmarc = async opts => {
|
|
|
114
172
|
spf: { result: spfAlignment?.domain, strict: aspfStrict },
|
|
115
173
|
dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
|
|
116
174
|
}
|
|
117
|
-
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (dkimAlignment || spfAlignment) {
|
|
178
|
+
// pass
|
|
179
|
+
status.result = 'pass';
|
|
180
|
+
} else if (walkError) {
|
|
181
|
+
// an identifier might have aligned if its Tree Walk had not failed
|
|
182
|
+
status.result = 'temperror';
|
|
183
|
+
response.error = walkError.message;
|
|
184
|
+
} else {
|
|
185
|
+
// fail
|
|
186
|
+
status.result = 'fail';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { response: formatResponse(response), dkimAligned: new Set(dkim.aligned.map(entry => formatDomain(entry.domain))) };
|
|
118
190
|
};
|
|
119
191
|
|
|
192
|
+
const verifyDmarc = async opts => (await evaluateDmarc(opts)).response;
|
|
193
|
+
|
|
120
194
|
module.exports = verifyDmarc;
|
|
195
|
+
// also returns the signing domains that aligned, for the per signature flags of authenticate()
|
|
196
|
+
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
10
|
const { parseReceived } = require('./parse-received');
|
|
11
|
-
const { getAlignment } = require('./tools');
|
|
11
|
+
const { formatDomain, getAlignment } = require('./tools');
|
|
12
12
|
const libmime = require('libmime');
|
|
13
13
|
const os = require('node:os');
|
|
14
14
|
const { isIP } = require('net');
|
|
@@ -117,7 +117,8 @@ const authenticate = async (input, opts) => {
|
|
|
117
117
|
|
|
118
118
|
let dmarcResult;
|
|
119
119
|
if (!opts.disableDmarc && dkimResult?.headerFrom) {
|
|
120
|
-
|
|
120
|
+
let dkimAligned;
|
|
121
|
+
({ response: dmarcResult, dkimAligned } = await evaluateDmarc({
|
|
121
122
|
headerFrom: dkimResult.headerFrom,
|
|
122
123
|
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
|
|
123
124
|
dkimDomains: (dkimResult.results || [])
|
|
@@ -130,16 +131,22 @@ const authenticate = async (input, opts) => {
|
|
|
130
131
|
})),
|
|
131
132
|
arcResult,
|
|
132
133
|
resolver: opts.resolver
|
|
133
|
-
});
|
|
134
|
+
}));
|
|
134
135
|
if (dmarcResult.info) {
|
|
135
136
|
arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
if (
|
|
139
|
-
//
|
|
139
|
+
if (dkimAligned) {
|
|
140
|
+
// The DKIM verifier runs before the DMARC record and the Tree Walks are known, so its
|
|
141
|
+
// per signature flag only guesses relaxed alignment from the Public Suffix List.
|
|
142
|
+
// Passing signatures take the DMARC verdict, failing ones the record's alignment mode.
|
|
140
143
|
let fromDomain = dmarcResult.status.header.from;
|
|
141
144
|
for (let row of dkimResult.results || []) {
|
|
142
|
-
|
|
145
|
+
if (row.status.result === 'pass') {
|
|
146
|
+
row.status.aligned = dkimAligned.has(formatDomain(row.signingDomain)) ? row.signingDomain : false;
|
|
147
|
+
} else if (dmarcResult.alignment.dkim.strict) {
|
|
148
|
+
row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
|
|
149
|
+
}
|
|
143
150
|
}
|
|
144
151
|
}
|
|
145
152
|
}
|
|
@@ -184,6 +191,12 @@ const authenticate = async (input, opts) => {
|
|
|
184
191
|
seal
|
|
185
192
|
});
|
|
186
193
|
|
|
194
|
+
if (sealResult?.errors?.length) {
|
|
195
|
+
// sealing failed, usually an unusable key. The message goes out unsealed, so
|
|
196
|
+
// say why somewhere the caller can find it rather than dropping it silently
|
|
197
|
+
arcResult.sealErrors = sealResult.errors;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
sealResult?.headers?.reverse().forEach(header => headers.unshift(header));
|
|
188
201
|
}
|
|
189
202
|
|