mailauth 7.1.1 → 7.3.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 (58) hide show
  1. package/.c8rc.json +11 -0
  2. package/.prettierignore +3 -0
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +68 -0
  5. package/README.md +131 -31
  6. package/bin/mailauth.js +62 -0
  7. package/cli.md +150 -8
  8. package/docs/arc.md +62 -54
  9. package/docs/bimi.md +153 -54
  10. package/docs/dkim.md +37 -36
  11. package/docs/dkim2.md +154 -0
  12. package/docs/dmarc.md +4 -1
  13. package/docs/mta-sts.md +12 -9
  14. package/docs/spf.md +14 -13
  15. package/index.d.ts +583 -42
  16. package/lib/arc/index.d.ts +6 -2
  17. package/lib/arc/index.js +45 -15
  18. package/lib/bimi/index.js +362 -102
  19. package/lib/bimi/validate-svg.js +493 -60
  20. package/lib/commands/dkim2-hash.js +25 -0
  21. package/lib/commands/dkim2-sign.js +77 -0
  22. package/lib/commands/dkim2-verify.js +59 -0
  23. package/lib/commands/report.js +10 -35
  24. package/lib/commands/resolver.js +51 -0
  25. package/lib/commands/seal.js +10 -38
  26. package/lib/commands/spf.js +4 -35
  27. package/lib/dkim/dkim-signer.js +50 -30
  28. package/lib/dkim/dkim-verifier.js +79 -27
  29. package/lib/dkim/header/simple.js +2 -2
  30. package/lib/dkim/sign.js +7 -2
  31. package/lib/dkim/syntax.js +11 -0
  32. package/lib/dkim/verify.js +1 -0
  33. package/lib/dkim2/dkim2-parser.js +52 -0
  34. package/lib/dkim2/dkim2-signer.js +334 -0
  35. package/lib/dkim2/dkim2-verifier.js +572 -0
  36. package/lib/dkim2/fields.js +570 -0
  37. package/lib/dkim2/hash.d.ts +3 -0
  38. package/lib/dkim2/hash.js +51 -0
  39. package/lib/dkim2/key.js +203 -0
  40. package/lib/dkim2/message-state.js +181 -0
  41. package/lib/dkim2/recipe.js +134 -0
  42. package/lib/dkim2/sign.d.ts +3 -0
  43. package/lib/dkim2/sign.js +38 -0
  44. package/lib/dkim2/verify.d.ts +3 -0
  45. package/lib/dkim2/verify.js +19 -0
  46. package/lib/dmarc/author-domain.js +270 -5
  47. package/lib/dmarc/get-dmarc-record.js +24 -11
  48. package/lib/dmarc/verify.js +7 -3
  49. package/lib/mailauth.js +57 -14
  50. package/lib/mta-sts.js +37 -11
  51. package/lib/parse-received.js +214 -25
  52. package/lib/spf/index.js +21 -4
  53. package/lib/spf/macro.js +5 -3
  54. package/lib/spf/spf-verify.js +14 -5
  55. package/lib/spf/syntax.js +5 -0
  56. package/lib/tools.js +160 -21
  57. package/package.json +15 -6
  58. package/stryker.config.json +16 -0
@@ -3,8 +3,11 @@
3
3
  const { formatDomain, toALabel } = require('../tools');
4
4
 
5
5
  // Characters that can not appear in a domain name taken from an address. Anything like this
6
- // means the address was malformed and the text after the "@" is not a domain.
7
- const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]]/;
6
+ // means the address was malformed and the text after the "@" is not a domain. Control characters
7
+ // (Cc, including DEL) and invisible format characters (Cf, such as U+200B or the soft hyphen) are
8
+ // not valid in a U-label (RFC 5890 2.3.2.1, RFC 5892), so such a domain has no A-label. U+200C and
9
+ // U+200D are left out, IDNA2008 allows them in some scripts (CONTEXTJ).
10
+ const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]\p{Cc}]|[^\P{Cf}\u200c\u200d]/u;
8
11
 
9
12
  // A domain literal (RFC 5322 3.4.1). It is kept as is, it can not publish a DMARC record.
10
13
  const DOMAIN_LITERAL = /^\[[^[\]\\]*\]$/;
@@ -137,17 +140,31 @@ const getAddressDomains = address => {
137
140
  // "fromFields" is the number of From header fields the addresses came from, if known. RFC 5322
138
141
  // allows only one, and with several the DKIM signature can cover a different field than the one
139
142
  // a mail client shows, so their addresses are not merged into one Author Domain.
140
- const getAuthorDomain = (headerFrom, fromFields) => {
143
+ // "opts.fromSyntax" is the syntax of the From header field (see parseFromHeader). An "invalid"
144
+ // field, or a "lax" one with "opts.strict", gives no Author Domain, as the addresses may not be
145
+ // the ones a reader sees. For backward compatibility "opts" can also be a boolean that is true
146
+ // for a From header field that could not be parsed.
147
+ const getAuthorDomain = (headerFrom, fromFields, opts) => {
148
+ let fromInvalid = opts;
149
+ if (opts && typeof opts === 'object') {
150
+ fromInvalid = opts.fromSyntax === 'invalid' || (!!opts.strict && opts.fromSyntax === 'lax');
151
+ }
152
+
141
153
  let addresses = [].concat(headerFrom === undefined || headerFrom === null ? [] : headerFrom);
142
154
 
143
155
  let found = new Map();
144
156
  let invalid = false;
157
+ // a mailbox has a domain part, but it is not a domain name
158
+ let malformed = !!fromInvalid;
145
159
  for (let address of addresses) {
146
160
  let entries = getAddressDomains(address);
147
161
  if (!entries.length) {
148
162
  // a mailbox without a usable domain is not skipped, the domain a reader sees for it
149
163
  // is unknown, so the Author Domain can not be determined
150
164
  invalid = true;
165
+ if (typeof address === 'string' && address.indexOf('@') >= 0 && address.slice(address.lastIndexOf('@') + 1).replace(/^\s*\.?\s*$/, '')) {
166
+ malformed = true;
167
+ }
151
168
  }
152
169
  for (let entry of entries) {
153
170
  if (!found.has(entry.authorDomain)) {
@@ -162,7 +179,7 @@ const getAuthorDomain = (headerFrom, fromFields) => {
162
179
  return { reason: 'multiple-from-fields', domains: Array.from(found.keys()), mailboxes };
163
180
  }
164
181
 
165
- if (invalid && found.size) {
182
+ if (malformed || (invalid && found.size)) {
166
183
  return { reason: 'invalid-author-domain', domains: Array.from(found.keys()), mailboxes };
167
184
  }
168
185
 
@@ -178,4 +195,252 @@ const getAuthorDomain = (headerFrom, fromFields) => {
178
195
  };
179
196
  };
180
197
 
181
- module.exports = { getAuthorDomain, getAddressDomains };
198
+ // RFC 5322 3.2.3 atext, with the UTF-8 of RFC 6532 3.2
199
+ const ATEXT = /[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~\u0080-￿]/;
200
+
201
+ // ASCII control characters other than whitespace. RFC 5322 4.1 only allows them (obs-NO-WS-CTL)
202
+ // in comments, quoted strings and domain literals
203
+ const ASCII_CTL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
204
+
205
+ class FromSyntaxError extends Error {}
206
+
207
+ // Splits a From header field body into atoms, quoted strings, domain literals and single special
208
+ // characters. Whitespace and comments (CFWS) are dropped: where RFC 5322 allows CFWS, including
209
+ // between the atoms of an obs-domain (4.4), it has no meaning, and where it is not allowed, the
210
+ // tokens around it are not valid next to each other anyway.
211
+ const tokenizeFrom = value => {
212
+ let tokens = [];
213
+ let i = 0;
214
+
215
+ // reads a quoted string, a comment or a domain literal that starts at "i"
216
+ let readEnclosed = (close, nests) => {
217
+ let start = i;
218
+ let depth = 0;
219
+ for (; i < value.length; i++) {
220
+ let c = value.charAt(i);
221
+ if (c === '\\' && close !== ']') {
222
+ i++;
223
+ } else if (nests && c === '(') {
224
+ depth++;
225
+ } else if (c === close && (!nests || !--depth)) {
226
+ i++;
227
+ return value.slice(start, i);
228
+ } else if (close === ']' && i > start && (c === '[' || c === '\\')) {
229
+ break;
230
+ }
231
+ }
232
+ throw new FromSyntaxError('unterminated');
233
+ };
234
+
235
+ while (i < value.length) {
236
+ let c = value.charAt(i);
237
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
238
+ i++;
239
+ } else if (c === '(') {
240
+ readEnclosed(')', true);
241
+ } else if (c === '"') {
242
+ i++;
243
+ tokens.push({ type: 'quoted', value: '"' + readEnclosed('"') });
244
+ } else if (c === '[') {
245
+ tokens.push({ type: 'literal', value: readEnclosed(']') });
246
+ } else if (ATEXT.test(c)) {
247
+ let start = i;
248
+ while (i < value.length && ATEXT.test(value.charAt(i))) {
249
+ i++;
250
+ }
251
+ tokens.push({ type: 'atom', value: value.slice(start, i) });
252
+ } else if (ASCII_CTL.test(c)) {
253
+ throw new FromSyntaxError('control character');
254
+ } else {
255
+ // one of the specials ) < > ] : ; @ \ , .
256
+ tokens.push({ type: c });
257
+ i++;
258
+ }
259
+ }
260
+
261
+ return tokens;
262
+ };
263
+
264
+ // Parses the body of a From header field (RFC 5322 3.6.2 and 4.5.2, a mailbox-list or, by
265
+ // RFC 6854, an address-list) and returns the addr-spec of every mailbox, with CFWS removed and
266
+ // without a source route. "syntax" is "valid", "lax" for a field that only the lenient parsing
267
+ // accepts, or "invalid". An invalid field gives no reliable Author Domain (RFC 9989 4.4), so a
268
+ // domain that is not the one a reader sees can not be evaluated in its place.
269
+ //
270
+ // The lenient parsing accepts:
271
+ // - a display name without an address, as in "Doe, John <john@example.com>"
272
+ // - a group that is not closed with ";" at the end of the field
273
+ // - a domain literal or a leading "." in a display name
274
+ // - a domain with a trailing dot
275
+ // - an addr-spec in place of a display name, as in "a@example.com <a@example.com>". Its address
276
+ // is returned too when it differs from the angle-addr, so that a reader who sees it as the
277
+ // author does not get the other domain evaluated
278
+ // Anything else that is not RFC 5322 syntax, such as text after an angle-addr or two addresses
279
+ // without a comma between them, makes the field invalid.
280
+ const parseFromHeader = value => {
281
+ let addresses = [];
282
+ let lax = false;
283
+ let tokens;
284
+ let pos = 0;
285
+
286
+ let peek = () => tokens[pos]?.type;
287
+ let expect = type => {
288
+ if (peek() !== type) {
289
+ throw new FromSyntaxError(`expected "${type}"`);
290
+ }
291
+ return tokens[pos++];
292
+ };
293
+
294
+ // domain = dot-atom / domain-literal / obs-domain
295
+ let parseDomain = () => {
296
+ if (peek() === 'literal') {
297
+ return tokens[pos++].value;
298
+ }
299
+ let domain = expect('atom').value;
300
+ while (peek() === '.') {
301
+ pos++;
302
+ if (peek() !== 'atom') {
303
+ // a trailing dot, as in "user@example.com."
304
+ lax = true;
305
+ return domain + '.';
306
+ }
307
+ domain += '.' + tokens[pos++].value;
308
+ }
309
+ return domain;
310
+ };
311
+
312
+ // local-part = dot-atom / quoted-string / obs-local-part (word *("." word))
313
+ let toLocalPart = words => {
314
+ if (!words.length || words.length % 2 === 0) {
315
+ throw new FromSyntaxError('invalid local-part');
316
+ }
317
+ for (let i = 0; i < words.length; i++) {
318
+ // words and dots alternate, anything else between two words (such as a domain
319
+ // literal) is not a local-part
320
+ let valid = i % 2 === 0 ? words[i].type === 'atom' || words[i].type === 'quoted' : words[i].type === '.';
321
+ if (!valid) {
322
+ throw new FromSyntaxError('invalid local-part');
323
+ }
324
+ }
325
+ return words.map(word => (word.type === '.' ? '.' : word.value)).join('');
326
+ };
327
+
328
+ let parseAddrSpec = () => {
329
+ let words = [];
330
+ while (['atom', 'quoted', '.'].includes(peek())) {
331
+ words.push(tokens[pos++]);
332
+ }
333
+ let localPart = toLocalPart(words);
334
+ expect('@');
335
+ return `${localPart}@${parseDomain()}`;
336
+ };
337
+
338
+ // angle-addr, or obs-angle-addr with a source route that is dropped
339
+ let parseAngleAddr = () => {
340
+ expect('<');
341
+ if (peek() === '@') {
342
+ // obs-route = obs-domain-list ":"
343
+ while (peek() === '@' || peek() === ',') {
344
+ if (tokens[pos++].type === '@') {
345
+ parseDomain();
346
+ }
347
+ }
348
+ expect(':');
349
+ }
350
+ let address = parseAddrSpec();
351
+ expect('>');
352
+ return address;
353
+ };
354
+
355
+ let parseMailbox = inGroup => {
356
+ // display-name (phrase or obs-phrase), or the local-part of an addr-spec
357
+ let words = [];
358
+ while (['atom', 'quoted', '.', 'literal'].includes(peek())) {
359
+ words.push(tokens[pos++]);
360
+ }
361
+
362
+ let next = peek();
363
+
364
+ if (next === '@') {
365
+ let localPart = toLocalPart(words);
366
+ pos++;
367
+ let address = `${localPart}@${parseDomain()}`;
368
+ if (peek() !== '<') {
369
+ addresses.push(address);
370
+ return;
371
+ }
372
+ // an addr-spec used as a display name
373
+ lax = true;
374
+ let angleAddress = parseAngleAddr();
375
+ if (address.toLowerCase() !== angleAddress.toLowerCase()) {
376
+ addresses.push(address);
377
+ }
378
+ addresses.push(angleAddress);
379
+ return;
380
+ }
381
+
382
+ if (words.some(word => word.type === 'literal') || words[0]?.type === '.') {
383
+ lax = true;
384
+ }
385
+
386
+ if (next === '<') {
387
+ addresses.push(parseAngleAddr());
388
+ return;
389
+ }
390
+
391
+ if (next === ':' && !inGroup && words.length) {
392
+ // group = display-name ":" [group-list] ";" [CFWS]
393
+ pos++;
394
+ for (;;) {
395
+ while (peek() === ',') {
396
+ pos++;
397
+ }
398
+ if (peek() === ';') {
399
+ pos++;
400
+ return;
401
+ }
402
+ if (!peek()) {
403
+ lax = true;
404
+ return;
405
+ }
406
+ parseMailbox(true);
407
+ if (peek() !== ',' && peek() !== ';' && peek()) {
408
+ throw new FromSyntaxError('unexpected text in group');
409
+ }
410
+ }
411
+ }
412
+
413
+ if (!words.length) {
414
+ throw new FromSyntaxError('unexpected character');
415
+ }
416
+
417
+ // a display name without an address
418
+ lax = true;
419
+ };
420
+
421
+ try {
422
+ tokens = tokenizeFrom((value || '').toString());
423
+ for (;;) {
424
+ // obs-mbox-list allows empty list elements
425
+ while (peek() === ',') {
426
+ pos++;
427
+ }
428
+ if (!peek()) {
429
+ break;
430
+ }
431
+ parseMailbox(false);
432
+ if (peek() && peek() !== ',') {
433
+ throw new FromSyntaxError('unexpected text after address');
434
+ }
435
+ }
436
+ } catch (err) {
437
+ if (!(err instanceof FromSyntaxError)) {
438
+ throw err;
439
+ }
440
+ return { addresses, syntax: 'invalid' };
441
+ }
442
+
443
+ return { addresses, syntax: lax ? 'lax' : 'valid' };
444
+ };
445
+
446
+ module.exports = { getAuthorDomain, getAddressDomains, parseFromHeader };
@@ -102,15 +102,12 @@ const parseDmarcRecord = (txt, opts) => {
102
102
  let entries = txt
103
103
  .split(';')
104
104
  .map(e => e.trim())
105
- .filter(e => e)
105
+ // a fragment without "=" or without a tag name is not a tag, and syntax errors are discarded
106
+ // (RFC 9989 4.8), so it can not override a valid tag of the same name. The version tag
107
+ // always has a name and "=", the record matched DMARC_VERSION_RE.
108
+ .filter(e => e.indexOf('=') > 0)
106
109
  .map((e, i) => {
107
110
  let splitPos = e.indexOf('=');
108
- if (splitPos < 0) {
109
- // not a tag, a syntax error that is ignored
110
- return [e.toLowerCase().trim(), false];
111
- } else if (splitPos === 0) {
112
- return [false, e];
113
- }
114
111
  // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
115
112
  let name = e.substr(0, splitPos).trim();
116
113
  let key = name.toLowerCase();
@@ -156,11 +153,23 @@ const fetchRecord = async (name, resolver, opts) => {
156
153
  return false;
157
154
  }
158
155
 
159
- let records;
156
+ let records = [];
157
+ // set when a record only starts with the version tag after leading whitespace is removed
158
+ let trimmed = false;
160
159
  try {
161
160
  let txt = await resolver(`_dmarc.${name}`, 'TXT');
162
- // a custom resolver might return plain strings instead of lists of character-strings
163
- records = (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trim()).filter(row => DMARC_VERSION_RE.test(row));
161
+ // a custom resolver might return plain strings instead of lists of character-strings.
162
+ // Trailing whitespace belongs to the last tag value, which is trimmed anyway.
163
+ for (let row of (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trimEnd())) {
164
+ if (DMARC_VERSION_RE.test(row)) {
165
+ records.push(row);
166
+ } else if (!opts?.strict && DMARC_VERSION_RE.test(row.trimStart())) {
167
+ // a record must start with the "v" tag (RFC 9989 4.10), the default mode
168
+ // also accepts one with whitespace before it
169
+ records.push(row.trimStart());
170
+ trimmed = true;
171
+ }
172
+ }
164
173
  } catch (err) {
165
174
  if (NO_RECORD_CODES.has(err.code)) {
166
175
  return false;
@@ -169,7 +178,11 @@ const fetchRecord = async (name, resolver, opts) => {
169
178
  }
170
179
 
171
180
  // a record that is not a valid tag-list is discarded like one that is not a DMARC record
172
- return (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
181
+ let record = (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
182
+ if (record && trimmed) {
183
+ opts?.warnings?.add('record-whitespace');
184
+ }
185
+ return record;
173
186
  };
174
187
 
175
188
  // Tree Walks for one message share most of their names, so each name is queried only once.
@@ -22,7 +22,7 @@ const shareParent = (a, b) => a.split('.').pop() === b.split('.').pop();
22
22
  const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
23
23
 
24
24
  const evaluateDmarc = async opts => {
25
- let { headerFrom, fromFields, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
25
+ let { headerFrom, fromFields, fromSyntax, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
26
26
 
27
27
  strict = !!strict;
28
28
  resolver = resolver || dns.resolve;
@@ -30,7 +30,8 @@ const evaluateDmarc = async opts => {
30
30
  // RFC 9989 5.3.1: several mailboxes are fine if they share one domain, but with zero or more
31
31
  // than one Author Domain DMARC validation is not possible. The response stays false, as it
32
32
  // always was for these messages, and "reason" says why.
33
- const author = getAuthorDomain(headerFrom, fromFields);
33
+ // a From header field that only the lenient parsing accepts is invalid in strict mode
34
+ const author = getAuthorDomain(headerFrom, fromFields, { fromSyntax, strict });
34
35
  if (!author.authorDomain) {
35
36
  return { response: false, reason: author.reason, authorDomains: author.domains };
36
37
  }
@@ -41,6 +42,9 @@ const evaluateDmarc = async opts => {
41
42
 
42
43
  // what the default mode accepted that strict mode would not
43
44
  const warnings = new Set();
45
+ if (fromSyntax === 'lax') {
46
+ warnings.add('from-syntax');
47
+ }
44
48
 
45
49
  let formatResponse = response => {
46
50
  if (warnings.size) {
@@ -64,7 +68,7 @@ const evaluateDmarc = async opts => {
64
68
  comment: false,
65
69
  // ptype properties
66
70
  header: {
67
- // RFC 8601 2.7.2: header.from is the domain of the RFC5322.From address,
71
+ // RFC 9989 9.1: header.from is the domain of the RFC5322.From address,
68
72
  // not the organizational domain the policy may have been inherited from
69
73
  from: domain
70
74
  }
package/lib/mailauth.js CHANGED
@@ -2,13 +2,17 @@
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');
8
11
  const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
9
12
  const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
10
- const { parseReceived, getClientAddress } = require('./parse-received');
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
@@ -60,17 +72,20 @@ const authenticate = async (input, opts) => {
60
72
  if (opts.trustReceived) {
61
73
  let rcvd = receivedChain?.find(row => row.from?.value);
62
74
  if (rcvd) {
63
- let helo = rcvd.from.value;
75
+ let helo = getClientHelo(rcvd);
64
76
  // the address of the connection from the TCP-info part of the from clause
65
77
  // (RFC 5321 section 4.4), never an address the client claimed in its HELO
66
- let ip = getClientAddress(rcvd.from);
78
+ let ip = getClientAddress(rcvd);
79
+
80
+ // the HELO name of the header belongs to its connection, it is not combined with
81
+ // an address the caller provided
82
+ let callerIp = !!opts.ip;
67
83
 
68
- if (ip && !opts.ip) {
84
+ if (ip && !callerIp) {
69
85
  opts.ip = ip;
70
86
  }
71
87
 
72
- if (helo && !opts.helo && !opts.ip) {
73
- // if IP was provided then do not use helo even if it is missing
88
+ if (helo && !opts.helo && !callerIp) {
74
89
  opts.helo = helo;
75
90
  }
76
91
 
@@ -94,7 +109,24 @@ const authenticate = async (input, opts) => {
94
109
  opts.helo = `[${opts.helo}]`;
95
110
  }
96
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
+
97
128
  const spfResult = await spf(opts);
129
+ const dkim2Result = await dkim2Verification;
98
130
 
99
131
  let arcResult;
100
132
  if (!opts.disableArc) {
@@ -115,6 +147,12 @@ const authenticate = async (input, opts) => {
115
147
  arHeader.push(`${libmime.foldLines(row.info, 160)}`);
116
148
  });
117
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
+
118
156
  if (spfResult) {
119
157
  arHeader.push(libmime.foldLines(spfResult.info, 160));
120
158
  receivedSpfHeader = spfResult.header;
@@ -135,6 +173,7 @@ const authenticate = async (input, opts) => {
135
173
  } = await evaluateDmarc({
136
174
  headerFrom: dkimResult.headerFrom,
137
175
  fromFields: dkimResult.fromFields,
176
+ fromSyntax: dkimResult.fromSyntax,
138
177
  spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
139
178
  dkimDomains: (dkimResult.results || [])
140
179
  .filter(r => r.status.result === 'pass')
@@ -210,14 +249,13 @@ const authenticate = async (input, opts) => {
210
249
  let sealResult = await createSeal(false, {
211
250
  headers: dkimResult.headers,
212
251
  arc: dkimResult.arc,
213
- seal: Object.assign(
214
- {
215
- cv: arcResult.status.result,
216
- authResults: arcResult.authResults,
217
- signTime: new Date()
218
- },
219
- dkimResult.seal
220
- ),
252
+ // the chain status, the AAR and the instance always come from this message, never
253
+ // from the caller's seal options (RFC 8617 section 5.1 step 4)
254
+ seal: Object.assign({ signTime: new Date() }, dkimResult.seal, {
255
+ cv: arcResult.status.result,
256
+ authResults: arcResult.authResults,
257
+ i: undefined
258
+ }),
221
259
  strict: opts.strict
222
260
  });
223
261
 
@@ -234,6 +272,7 @@ const authenticate = async (input, opts) => {
234
272
 
235
273
  const result = {
236
274
  dkim: dkimResult,
275
+ dkim2: dkim2Result || false,
237
276
  spf: spfResult,
238
277
  dmarc: dmarcResult || false,
239
278
  arc: arcResult || false,
@@ -257,6 +296,10 @@ module.exports = {
257
296
  dkimSign,
258
297
  DkimSignStream,
259
298
  dkimVerify,
299
+ dkim2Sign,
300
+ Dkim2SignStream,
301
+ dkim2Verify,
302
+ dkim2Hash,
260
303
  spf,
261
304
  dmarc,
262
305
  arc,
package/lib/mta-sts.js CHANGED
@@ -633,11 +633,45 @@ const getPolicy = async (domain, knownPolicy, opts) => {
633
633
  return formatResult(Object.assign({}, knownPolicy), 'renewed', warnings);
634
634
  }
635
635
 
636
+ // The cached policy that stays in use when the policy for the new ID can not be fetched. A valid
637
+ // cached "none" policy (or an earlier retry placeholder) enforces nothing, so it is replaced with
638
+ // a retry placeholder for the new policy ID instead of being reused
639
+ const canUseCached = () => {
640
+ if (cacheValid && knownPolicy.mode !== 'none') {
641
+ return true;
642
+ }
643
+ if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
644
+ // lax mode keeps applying an expired policy instead of falling back to no policy
645
+ warnings.push('expired-cache');
646
+ return true;
647
+ }
648
+ return false;
649
+ };
650
+
651
+ // RFC 8461 3.3: after a failed fetch, further attempts for the same policy ID are limited. The
652
+ // cached policy that is returned instead records the ID and when it can be fetched again
653
+ const useCachedAfterFailure = err =>
654
+ formatResult(
655
+ Object.assign({}, knownPolicy, {
656
+ error: err,
657
+ retryId: policyId,
658
+ retryAfter: new Date(Date.now() + ERROR_RETRY_DELAY).toISOString()
659
+ }),
660
+ 'errored',
661
+ warnings
662
+ );
663
+
664
+ let retryAfter = knownPolicy?.retryId === policyId ? new Date(knownPolicy.retryAfter).getTime() : NaN;
665
+ if (retryAfter > Date.now() && canUseCached()) {
666
+ // fetching this policy ID failed recently, keep using the cached policy without a new attempt
667
+ return formatResult(Object.assign({}, knownPolicy), 'errored', warnings);
668
+ }
669
+
636
670
  try {
637
671
  let policy = await fetchPolicyFile(domain, opts, warnings);
638
672
  if (!policy) {
639
673
  if (cacheValid) {
640
- return useCached(createError(`Policy host mta-sts.${domain} has no address records`, 'policy_host_not_found'));
674
+ return useCachedAfterFailure(createError(`Policy host mta-sts.${domain} has no address records`, 'policy_host_not_found'));
641
675
  }
642
676
  return formatResult({ id: false, mode: 'none' }, 'not_found', warnings);
643
677
  }
@@ -650,17 +684,9 @@ const getPolicy = async (domain, knownPolicy, opts) => {
650
684
  warnings
651
685
  );
652
686
  } catch (err) {
653
- // A valid cached "none" policy (or an earlier retry placeholder) enforces nothing, so it is
654
- // replaced with a retry placeholder for the new policy ID below instead of being reused
655
- if (cacheValid && knownPolicy.mode !== 'none') {
687
+ if (canUseCached()) {
656
688
  // re-use existing policy on error
657
- return useCached(err);
658
- }
659
-
660
- if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
661
- // lax mode keeps applying an expired policy instead of falling back to no policy
662
- warnings.push('expired-cache');
663
- return useCached(err);
689
+ return useCachedAfterFailure(err);
664
690
  }
665
691
 
666
692
  // continue as if the domain had no policy, and do not retry fetching this policy ID for a while