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/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.replace(/[ \t]+$/, '');
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).replace(/^[ \t]+|[ \t]+$/g, '');
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 FWS_EDGES = /^[ \t\r\n]+|[ \t\r\n]+$/g;
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.replace(FWS_EDGES, '');
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).replace(FWS_EDGES, '');
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 = row =>
156
- // only the start of the row decides this, and a fold can push the colon no further
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(row.length > 1 ? `${row[0]}\r\n${row[1]}` : row[0]);
159
+ FIELD_START.test(line) || (next !== undefined && FIELD_START.test(`${line}\r\n${next}`));
159
160
 
160
161
  const parseHeaders = buf => {
161
- let rows = buf
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 (rows.length === 1 && !rows[0][0]) {
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
- for (let i = rows.length - 1; i > 0; i--) {
173
- if (!startsField(rows[i])) {
174
- rows[i - 1] = rows[i - 1].concat(rows[i]);
175
- rows.splice(i, 1);
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.replace(/^[ \t]+|[ \t]+$/g, '');
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.replace(/^[ \t]+|[ \t]+$/g, '')))
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.replace(/^[ \t]+|[ \t]+$/g, '');
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).replace(/^[ \t]+|[ \t]+$/g, '') + ':')
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.replace(/\.+$/, '');
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.2.0",
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.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.16",
61
+ "nodemailer": "10.1.0",
62
62
  "punycode.js": "2.3.1",
63
63
  "tldts": "7.4.18",
64
64
  "undici": "8.11.2"
@@ -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" },