mailauth 7.2.0 → 7.3.1
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/.prettierignore +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +99 -0
- package/bin/mailauth.js +62 -0
- package/cli.md +149 -7
- package/docs/dkim2.md +154 -0
- package/index.d.ts +359 -0
- package/lib/commands/dkim2-hash.js +25 -0
- package/lib/commands/dkim2-sign.js +77 -0
- package/lib/commands/dkim2-verify.js +59 -0
- package/lib/commands/report.js +8 -33
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +2 -32
- package/lib/commands/spf.js +2 -33
- package/lib/dkim/dkim-verifier.js +2 -1
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim2/dkim2-parser.js +52 -0
- package/lib/dkim2/dkim2-signer.js +334 -0
- package/lib/dkim2/dkim2-verifier.js +572 -0
- package/lib/dkim2/fields.js +571 -0
- package/lib/dkim2/hash.d.ts +3 -0
- package/lib/dkim2/hash.js +51 -0
- package/lib/dkim2/key.js +203 -0
- package/lib/dkim2/message-state.js +181 -0
- package/lib/dkim2/recipe.js +134 -0
- package/lib/dkim2/sign.d.ts +3 -0
- package/lib/dkim2/sign.js +38 -0
- package/lib/dkim2/verify.d.ts +3 -0
- package/lib/dkim2/verify.js +19 -0
- package/lib/dmarc/author-domain.js +6 -2
- package/lib/mailauth.js +40 -0
- package/lib/mta-sts.js +3 -2
- package/lib/parse-dkim-headers.js +4 -3
- package/lib/tools.js +20 -18
- package/lib/trim.js +27 -0
- package/package.json +3 -3
- package/stryker.config.json +2 -2
package/lib/mailauth.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
const { dkimSign, DkimSignStream } = require('./dkim/sign');
|
|
4
4
|
const { dkimVerify } = require('./dkim/verify');
|
|
5
|
+
const { dkim2Sign, Dkim2SignStream } = require('./dkim2/sign');
|
|
6
|
+
const { dkim2Verify } = require('./dkim2/verify');
|
|
7
|
+
const { dkim2Hash } = require('./dkim2/hash');
|
|
5
8
|
const { spf } = require('./spf');
|
|
6
9
|
const { dmarc, evaluateDmarc } = require('./dmarc');
|
|
7
10
|
const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
|
|
@@ -9,6 +12,7 @@ const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
|
|
|
9
12
|
const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
|
|
10
13
|
const { parseReceived, getClientAddress, getClientHelo } = require('./parse-received');
|
|
11
14
|
const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
|
|
15
|
+
const { buffer: readStream } = require('node:stream/consumers');
|
|
12
16
|
const libmime = require('libmime');
|
|
13
17
|
const os = require('node:os');
|
|
14
18
|
const { isIP } = require('net');
|
|
@@ -33,6 +37,9 @@ const { isIP } = require('net');
|
|
|
33
37
|
* @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
|
|
34
38
|
* @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
|
|
35
39
|
* @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
|
|
40
|
+
* @param {Boolean} [opts.dkim2=false] If true then DKIM2 header fields are verified as well (draft-ietf-dkim-dkim2-spec-06)
|
|
41
|
+
* @param {String|String[]} [opts.rcptTo] RCPT TO addresses of the delivery, checked against the DKIM2 chain of custody
|
|
42
|
+
* @param {Object} [opts.dkim2Options] Extra options for the DKIM2 verifier (maxSignatureAge, maxFutureTime, maxInstances, checkReplay, curTime)
|
|
36
43
|
* @returns Authentication result
|
|
37
44
|
*/
|
|
38
45
|
const authenticate = async (input, opts) => {
|
|
@@ -45,6 +52,11 @@ const authenticate = async (input, opts) => {
|
|
|
45
52
|
// quoted, or throws under strict, before any work is done
|
|
46
53
|
const authservId = formatAuthservId(opts.mta, opts.strict);
|
|
47
54
|
|
|
55
|
+
if (opts.dkim2 && typeof input?.on === 'function') {
|
|
56
|
+
// DKIM2 reads the message a second time, so a stream is read into memory first
|
|
57
|
+
input = await readStream(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
const dkimResult = await dkimVerify(input, {
|
|
49
61
|
resolver: opts.resolver,
|
|
50
62
|
sender: opts.sender, // defaults to Return-Path header
|
|
@@ -97,7 +109,24 @@ const authenticate = async (input, opts) => {
|
|
|
97
109
|
opts.helo = `[${opts.helo}]`;
|
|
98
110
|
}
|
|
99
111
|
|
|
112
|
+
// DKIM2 runs alongside SPF. The delivery is compared with the highest DKIM2-Signature only
|
|
113
|
+
// when it is known
|
|
114
|
+
const dkim2Verification =
|
|
115
|
+
opts.dkim2 &&
|
|
116
|
+
dkim2Verify(
|
|
117
|
+
input,
|
|
118
|
+
Object.assign({}, opts.dkim2Options, {
|
|
119
|
+
resolver: opts.resolver,
|
|
120
|
+
mailFrom: typeof opts.sender === 'string' ? opts.sender : undefined,
|
|
121
|
+
rcptTo: opts.rcptTo
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
// a rejection is handled by the await below, this keeps it from being reported as unhandled
|
|
125
|
+
// while SPF runs
|
|
126
|
+
dkim2Verification?.catch?.(() => false);
|
|
127
|
+
|
|
100
128
|
const spfResult = await spf(opts);
|
|
129
|
+
const dkim2Result = await dkim2Verification;
|
|
101
130
|
|
|
102
131
|
let arcResult;
|
|
103
132
|
if (!opts.disableArc) {
|
|
@@ -118,6 +147,12 @@ const authenticate = async (input, opts) => {
|
|
|
118
147
|
arHeader.push(`${libmime.foldLines(row.info, 160)}`);
|
|
119
148
|
});
|
|
120
149
|
|
|
150
|
+
if (dkim2Result?.info) {
|
|
151
|
+
// draft-gondwana-dkim2-authres-00: the method is not registered yet. DKIM2 results do not
|
|
152
|
+
// take part in DMARC, that is an open question (draft-ietf-dkim-dkim2-bcp-01 section 7.2)
|
|
153
|
+
arHeader.push(libmime.foldLines(dkim2Result.info, 160));
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
if (spfResult) {
|
|
122
157
|
arHeader.push(libmime.foldLines(spfResult.info, 160));
|
|
123
158
|
receivedSpfHeader = spfResult.header;
|
|
@@ -237,6 +272,7 @@ const authenticate = async (input, opts) => {
|
|
|
237
272
|
|
|
238
273
|
const result = {
|
|
239
274
|
dkim: dkimResult,
|
|
275
|
+
dkim2: dkim2Result || false,
|
|
240
276
|
spf: spfResult,
|
|
241
277
|
dmarc: dmarcResult || false,
|
|
242
278
|
arc: arcResult || false,
|
|
@@ -260,6 +296,10 @@ module.exports = {
|
|
|
260
296
|
dkimSign,
|
|
261
297
|
DkimSignStream,
|
|
262
298
|
dkimVerify,
|
|
299
|
+
dkim2Sign,
|
|
300
|
+
Dkim2SignStream,
|
|
301
|
+
dkim2Verify,
|
|
302
|
+
dkim2Hash,
|
|
263
303
|
spf,
|
|
264
304
|
dmarc,
|
|
265
305
|
arc,
|
package/lib/mta-sts.js
CHANGED
|
@@ -6,6 +6,7 @@ const https = require('node:https');
|
|
|
6
6
|
const tls = require('node:tls');
|
|
7
7
|
const { X509Certificate } = require('node:crypto');
|
|
8
8
|
const { toALabel, createError } = require('./tools');
|
|
9
|
+
const { trimChars, trimEndChars } = require('./trim');
|
|
9
10
|
|
|
10
11
|
// socket idle timeout for the policy request
|
|
11
12
|
const HTTP_IDLE_TIMEOUT = 15 * 1000;
|
|
@@ -65,7 +66,7 @@ const parseTxtRecordStrict = record => {
|
|
|
65
66
|
// WSP around a delimiter belongs to the delimiter
|
|
66
67
|
let field = parts[i].replace(/^[ \t]+/, '');
|
|
67
68
|
if (!isLast) {
|
|
68
|
-
field = field
|
|
69
|
+
field = trimEndChars(field, ' \t');
|
|
69
70
|
} else if (!field) {
|
|
70
71
|
// optional trailing delimiter
|
|
71
72
|
continue;
|
|
@@ -217,7 +218,7 @@ const parsePolicyFile = (file, strict, warnings) => {
|
|
|
217
218
|
if (!EXT_NAME.test(key)) {
|
|
218
219
|
continue;
|
|
219
220
|
}
|
|
220
|
-
value = line.substr(colonPos + 1)
|
|
221
|
+
value = trimChars(line.substr(colonPos + 1), ' \t');
|
|
221
222
|
} else {
|
|
222
223
|
key = line.substr(0, colonPos).toLowerCase().trim();
|
|
223
224
|
value = line.substr(colonPos + 1).trim();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { DANGEROUS_KEYS } = require('./safe-keys');
|
|
4
|
+
const { trimChars } = require('./trim');
|
|
4
5
|
|
|
5
6
|
// Keys the headerParser result pre-seeds: "header" holds the header name and "value" the
|
|
6
7
|
// authserv-id picked from the first key-only part. A crafted part must not overwrite them,
|
|
@@ -24,7 +25,7 @@ const NUMERIC_TAGS = new Set(['l', 'v', 't', 'x']);
|
|
|
24
25
|
// RFC 6376 section 3.2: tag-name = ALPHA *ALNUMPUNC
|
|
25
26
|
const TAG_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
26
27
|
// FWS around a tag-spec, only SP, HTAB and the CRLF of a fold
|
|
27
|
-
const
|
|
28
|
+
const FWS_CHARS = ' \t\r\n';
|
|
28
29
|
|
|
29
30
|
const normalizeString = value => value.replace(/\s+/g, ' ').trim();
|
|
30
31
|
|
|
@@ -104,7 +105,7 @@ const tagListParser = (line, headerKey, strict) => {
|
|
|
104
105
|
|
|
105
106
|
for (let i = 0; i < segments.length; i++) {
|
|
106
107
|
let segment = segments[i];
|
|
107
|
-
let trimmed = segment
|
|
108
|
+
let trimmed = trimChars(segment, FWS_CHARS);
|
|
108
109
|
|
|
109
110
|
if (!trimmed) {
|
|
110
111
|
// one trailing semicolon is allowed, an empty tag-spec anywhere else is not
|
|
@@ -127,7 +128,7 @@ const tagListParser = (line, headerKey, strict) => {
|
|
|
127
128
|
continue;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
let name = segment.substring(0, eqPos)
|
|
131
|
+
let name = trimChars(segment.substring(0, eqPos), FWS_CHARS);
|
|
131
132
|
let value = normalizeString(segment.substring(eqPos + 1));
|
|
132
133
|
|
|
133
134
|
if (!TAG_NAME.test(name)) {
|
package/lib/tools.js
CHANGED
|
@@ -10,6 +10,7 @@ const crypto = require('node:crypto');
|
|
|
10
10
|
const https = require('node:https');
|
|
11
11
|
const packageData = require('../package');
|
|
12
12
|
const parseDkimHeaders = require('./parse-dkim-headers');
|
|
13
|
+
const { trimChars, trimEndChars } = require('./trim');
|
|
13
14
|
const { parseTagList } = parseDkimHeaders;
|
|
14
15
|
const tldts = require('tldts');
|
|
15
16
|
const Joi = require('joi');
|
|
@@ -152,27 +153,27 @@ const FIELD_START = /^[\x21-\x39\x3b-\x7e]+[ \t\r\n]*:/;
|
|
|
152
153
|
// only add bytes to a signed value, so it can only ever turn a pass into a fail; reading
|
|
153
154
|
// the line the other way round is what cannot be undone. formatRelaxedLine is not lenient
|
|
154
155
|
// in the same way, what a signature covers is RFC 6376 section 3.4.2 alone
|
|
155
|
-
const startsField =
|
|
156
|
-
// only the start of
|
|
156
|
+
const startsField = (line, next) =>
|
|
157
|
+
// only the start of a line decides this, and a fold can push the colon no further
|
|
157
158
|
// than onto the line after the name
|
|
158
|
-
FIELD_START.test(
|
|
159
|
+
FIELD_START.test(line) || (next !== undefined && FIELD_START.test(`${line}\r\n${next}`));
|
|
159
160
|
|
|
160
161
|
const parseHeaders = buf => {
|
|
161
|
-
let
|
|
162
|
-
.toString('binary')
|
|
163
|
-
.replace(/[\r\n]+$/, '')
|
|
164
|
-
.split(/\r?\n/)
|
|
165
|
-
.map(row => [row]);
|
|
162
|
+
let lines = trimEndChars(buf.toString('binary'), '\r\n').split(/\r?\n/);
|
|
166
163
|
|
|
167
|
-
if (
|
|
164
|
+
if (lines.length === 1 && !lines[0]) {
|
|
168
165
|
// nothing to parse, rather than one row that is not a header
|
|
169
166
|
return { parsed: [], original: buf };
|
|
170
167
|
}
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
// the first line always opens a row, every later line either opens one or continues
|
|
170
|
+
// the row before it
|
|
171
|
+
let rows = [[lines[0]]];
|
|
172
|
+
for (let i = 1; i < lines.length; i++) {
|
|
173
|
+
if (startsField(lines[i], lines[i + 1])) {
|
|
174
|
+
rows.push([lines[i]]);
|
|
175
|
+
} else {
|
|
176
|
+
rows[rows.length - 1].push(lines[i]);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -188,7 +189,7 @@ const parseHeaders = buf => {
|
|
|
188
189
|
if (casedKey.indexOf('\n') >= 0) {
|
|
189
190
|
casedKey = casedKey.replace(/\r?\n/g, '');
|
|
190
191
|
}
|
|
191
|
-
casedKey = casedKey
|
|
192
|
+
casedKey = trimChars(casedKey, ' \t');
|
|
192
193
|
key = lowerCaseASCII(casedKey);
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -203,7 +204,7 @@ const parseHeaders = buf => {
|
|
|
203
204
|
const normalizeFieldNames = fieldNames =>
|
|
204
205
|
fieldNames
|
|
205
206
|
.split(':')
|
|
206
|
-
.map(key => lowerCaseASCII(key
|
|
207
|
+
.map(key => lowerCaseASCII(trimChars(key, ' \t')))
|
|
207
208
|
.filter(key => key);
|
|
208
209
|
|
|
209
210
|
const defaultDKIMFieldNamesNormalized = normalizeFieldNames(defaultDKIMFieldNames);
|
|
@@ -219,7 +220,7 @@ const OVERSIGN_FIELD_NAME = /^[\x21-\x39\x3c-\x7e]+$/;
|
|
|
219
220
|
const getOversignedNames = (fieldNames, signingList) => {
|
|
220
221
|
let listed = new Map();
|
|
221
222
|
for (let casedKey of fieldNames.split(':')) {
|
|
222
|
-
casedKey = casedKey
|
|
223
|
+
casedKey = trimChars(casedKey, ' \t');
|
|
223
224
|
let key = lowerCaseASCII(casedKey);
|
|
224
225
|
if (!key) {
|
|
225
226
|
continue;
|
|
@@ -987,7 +988,7 @@ const formatRelaxedLine = (line, suffix) => {
|
|
|
987
988
|
// unfold
|
|
988
989
|
.replace(/\r?\n/g, '')
|
|
989
990
|
// key to lowercase, trim around :
|
|
990
|
-
.replace(/^([^:]*):[ \t]*/, (m, k) => lowerCaseASCII(k)
|
|
991
|
+
.replace(/^([^:]*):[ \t]*/, (m, k) => trimChars(lowerCaseASCII(k), ' \t') + ':')
|
|
991
992
|
// single WSP
|
|
992
993
|
.replace(/[ \t]+/g, ' ')
|
|
993
994
|
// no WSP around the value
|
|
@@ -1034,7 +1035,7 @@ const formatDomain = domain => {
|
|
|
1034
1035
|
// signer writes and the verifier reports for the same name
|
|
1035
1036
|
domain = toALabel(domain.toLowerCase().trim()).toLowerCase().trim();
|
|
1036
1037
|
// the root label is not part of the name, so "example.com." and "example.com" are the same domain
|
|
1037
|
-
return domain
|
|
1038
|
+
return trimEndChars(domain, '.');
|
|
1038
1039
|
};
|
|
1039
1040
|
|
|
1040
1041
|
// A domain as getAlignment() compares it, after formatDomain(): labels of letters, digits, "-"
|
|
@@ -1357,6 +1358,7 @@ module.exports = {
|
|
|
1357
1358
|
addWarning,
|
|
1358
1359
|
KEY_ERROR_COMMENTS,
|
|
1359
1360
|
isValidDnsName,
|
|
1361
|
+
isBareRsaPublicKey,
|
|
1360
1362
|
|
|
1361
1363
|
defaultDKIMFieldNames,
|
|
1362
1364
|
defaultARCFieldNames,
|
package/lib/trim.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Trims the characters of `chars` off the end of a string with an index scan. A regex such
|
|
4
|
+
// as /[ \t]+$/ retries the trailing run from every position inside it whenever the run is
|
|
5
|
+
// not at the end of the string, quadratic in the length of a run of those characters
|
|
6
|
+
const trimEndChars = (str, chars) => {
|
|
7
|
+
let end = str.length;
|
|
8
|
+
while (end > 0 && chars.includes(str[end - 1])) {
|
|
9
|
+
end--;
|
|
10
|
+
}
|
|
11
|
+
return str.slice(0, end);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Trims the characters of `chars` off both ends of a string, see trimEndChars
|
|
15
|
+
const trimChars = (str, chars) => {
|
|
16
|
+
let start = 0;
|
|
17
|
+
let end = str.length;
|
|
18
|
+
while (start < end && chars.includes(str[start])) {
|
|
19
|
+
start++;
|
|
20
|
+
}
|
|
21
|
+
while (end > start && chars.includes(str[end - 1])) {
|
|
22
|
+
end--;
|
|
23
|
+
}
|
|
24
|
+
return str.slice(start, end);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
module.exports = { trimChars, trimEndChars };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailauth",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.1",
|
|
4
4
|
"description": "Email authentication library for Node.js",
|
|
5
5
|
"main": "lib/mailauth.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"license-report": "6.8.6",
|
|
49
49
|
"mbox-reader": "1.2.0",
|
|
50
50
|
"mocha": "12.0.3",
|
|
51
|
-
"prettier": "3.9.
|
|
51
|
+
"prettier": "3.9.10",
|
|
52
52
|
"resedit": "3.1.0"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"ipaddr.js": "2.5.0",
|
|
59
59
|
"joi": "18.2.9",
|
|
60
60
|
"libmime": "5.4.7",
|
|
61
|
-
"nodemailer": "10.0
|
|
61
|
+
"nodemailer": "10.1.0",
|
|
62
62
|
"punycode.js": "2.3.1",
|
|
63
63
|
"tldts": "7.4.18",
|
|
64
64
|
"undici": "8.11.2"
|
package/stryker.config.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"_comment": "Opt-in mutation testing, run with `npm run mutation`. The mocha runner plugin does not support mocha 12, so every mutant runs the tests of the mutated modules through the command runner. The long mocha timeout keeps the slower instrumented code from failing tests by timeout, which would count as a killed mutant",
|
|
4
4
|
"testRunner": "command",
|
|
5
5
|
"commandRunner": {
|
|
6
|
-
"command": "npx mocha --bail --timeout 60000 --reporter dot \"test/dkim/**/*-test.js\" \"test/spf/**/*-test.js\" \"test/dmarc/**/*-test.js\" \"test/property/*-test.js\" test/mailauth-test.js"
|
|
6
|
+
"command": "npx mocha --bail --timeout 60000 --reporter dot \"test/dkim/**/*-test.js\" \"test/spf/**/*-test.js\" \"test/dmarc/**/*-test.js\" \"test/dkim2/**/*-test.js\" \"test/property/*-test.js\" test/mailauth-test.js"
|
|
7
7
|
},
|
|
8
8
|
"coverageAnalysis": "off",
|
|
9
|
-
"mutate": ["lib/dkim/body/*.js", "lib/spf/macro.js", "lib/dmarc/author-domain.js"],
|
|
9
|
+
"mutate": ["lib/dkim/body/*.js", "lib/spf/macro.js", "lib/dmarc/author-domain.js", "lib/dkim2/*.js"],
|
|
10
10
|
"reporters": ["clear-text", "progress", "html", "json"],
|
|
11
11
|
"htmlReporter": { "fileName": "reports/mutation/mutation.html" },
|
|
12
12
|
"jsonReporter": { "fileName": "reports/mutation/mutation.json" },
|