mailauth 7.1.1 → 7.2.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.
@@ -14,7 +14,7 @@ const {
14
14
  createError,
15
15
  splitAlgorithm
16
16
  } = require('../../lib/tools');
17
- const { DOMAIN_NAME, SELECTOR, MAX_ARC_INSTANCE, ARC_HEADER_KEYS } = require('./syntax');
17
+ const { DOMAIN_NAME, SELECTOR, LOCAL_PART, MAX_ARC_INSTANCE, ARC_HEADER_KEYS } = require('./syntax');
18
18
  const { MessageParser } = require('./message-parser');
19
19
  const { dkimBody } = require('./body');
20
20
  const { generateCanonicalizedHeader } = require('./header');
@@ -30,6 +30,9 @@ const MAX_TIMESTAMP = 999999999999;
30
30
  // RFC 8617 section 4.1.2: header fields an ARC-Message-Signature never covers
31
31
  const ARC_EXCLUDED_FIELDS = [...ARC_HEADER_KEYS, 'authentication-results'];
32
32
 
33
+ // the header and body canonicalization algorithms of RFC 6376 section 3.4
34
+ const CANONICALIZATIONS = ['simple', 'relaxed'];
35
+
33
36
  // RFC 8301 section 3.2: signers MUST use RSA keys of at least 1024 bits
34
37
  const MIN_RSA_KEY_SIZE = 1024;
35
38
 
@@ -78,7 +81,8 @@ class DkimSigner extends MessageParser {
78
81
  this.getARChain = getARChain;
79
82
  // decides the instance of an ARC set that has none set, from the message headers
80
83
  this.getArcInstance = getArcInstance;
81
- if (this.arc.signingDomain && this.arc.selector && this.arc.privateKey) {
84
+ if (this.arc.signingDomain || this.arc.selector || this.arc.privateKey) {
85
+ // an incomplete set of values is reported by finalize() instead of being skipped
82
86
  this.signatureData.push({
83
87
  type: 'ARC',
84
88
  signingDomain: this.arc.signingDomain, // d=
@@ -111,11 +115,14 @@ class DkimSigner extends MessageParser {
111
115
  this.warn(warning);
112
116
  }
113
117
 
118
+ // The header and body canonicalization, and the c= value written for them. A value without
119
+ // a body part uses "simple" for the body (RFC 6376 section 3.5)
114
120
  getCanonicalization(signatureData) {
115
- let canonicalization = signatureData?.canonicalization || this.canonicalization;
116
- let headerCanon = canonicalization.split('/').shift().toLowerCase().trim();
117
- let bodyCanon = (canonicalization.split('/')[1] || 'simple').toLowerCase().trim();
118
- return { canonicalization, headerCanon, bodyCanon };
121
+ let [headerCanon, ...bodyParts] = (signatureData?.canonicalization || this.canonicalization).toString().split('/');
122
+ headerCanon = headerCanon.toLowerCase().trim();
123
+ // anything after a second slash stays in the body part, which then is not valid
124
+ let bodyCanon = (bodyParts.join('/') || 'simple').toLowerCase().trim();
125
+ return { canonicalization: `${headerCanon}/${bodyCanon}`, headerCanon, bodyCanon };
119
126
  }
120
127
 
121
128
  getAlgorithm(signatureData) {
@@ -139,6 +146,11 @@ class DkimSigner extends MessageParser {
139
146
  let { hashAlgo } = this.getAlgorithm(signatureData);
140
147
  let { bodyCanon } = this.getCanonicalization(signatureData);
141
148
 
149
+ if (!CANONICALIZATIONS.includes(bodyCanon)) {
150
+ // there is no body hash to calculate, finalize() reports the error
151
+ continue;
152
+ }
153
+
142
154
  let hashKey = `${bodyCanon}:${hashAlgo}:${signatureData.maxBodyLength}`;
143
155
 
144
156
  if (!this.bodyHashes.has(hashKey)) {
@@ -152,24 +164,15 @@ class DkimSigner extends MessageParser {
152
164
  }
153
165
  }
154
166
 
155
- validateCanonicalization(canonicalization) {
156
- try {
157
- let [header, body] = canonicalization.split('/');
158
-
159
- if (!['relaxed', 'simple'].includes(header)) {
160
- let error = new Error('Unknown header canonicalization');
161
- error.canonicalization = header;
162
- throw error;
163
- }
167
+ // Throws for a header or body canonicalization that is not known, takes the values
168
+ // getCanonicalization returns
169
+ validateCanonicalization({ headerCanon, bodyCanon }) {
170
+ if (!CANONICALIZATIONS.includes(headerCanon)) {
171
+ throw createError('Unknown header canonicalization', 'EINVALIDCANON', { canonicalization: headerCanon });
172
+ }
164
173
 
165
- if (!['relaxed', 'simple'].includes(body)) {
166
- let error = new Error('Unknown body canonicalization');
167
- error.canonicalization = body;
168
- throw error;
169
- }
170
- } catch (err) {
171
- err.code = 'EINVALIDCANON';
172
- throw err;
174
+ if (!CANONICALIZATIONS.includes(bodyCanon)) {
175
+ throw createError('Unknown body canonicalization', 'EINVALIDCANON', { canonicalization: bodyCanon });
173
176
  }
174
177
  }
175
178
 
@@ -213,6 +216,12 @@ class DkimSigner extends MessageParser {
213
216
  throw err;
214
217
  }
215
218
 
219
+ let localPart = identity.substring(0, atPos);
220
+ if ((localPart && !LOCAL_PART.test(localPart)) || !DOMAIN_NAME.test(domain)) {
221
+ // sig-i-tag = [ Local-part ] "@" domain-name
222
+ this.violation(`Invalid identity ${JSON.stringify(signatureData.identity)}`, 'EINVALIDIDENTITY', 'identity-syntax');
223
+ }
224
+
216
225
  if (!isSameOrSubdomain(domain, signatureData.signingDomain)) {
217
226
  // "The domain part of the address MUST be the same as, or a subdomain of, the value
218
227
  // of the d= tag", or every verifier ignores the signature
@@ -308,11 +317,15 @@ class DkimSigner extends MessageParser {
308
317
  }
309
318
 
310
319
  async finalize() {
311
- for (let signatureData of this.signatureData || []) {
312
- if (!signatureData.privateKey) {
313
- continue;
314
- }
320
+ if (!this.signatureData.length) {
321
+ // the signing domain, selector and key of a DKIM signature are read from the
322
+ // entries of signatureData only, so there is nothing to sign
323
+ this.errors.push({
324
+ err: createError('No signature configured, set signingDomain, selector and privateKey in signatureData', 'ENOSIGNATURE')
325
+ });
326
+ }
315
327
 
328
+ for (let signatureData of this.signatureData) {
316
329
  const pushError = (err, extra) => {
317
330
  this.errors.push(
318
331
  Object.assign(
@@ -327,6 +340,12 @@ class DkimSigner extends MessageParser {
327
340
  );
328
341
  };
329
342
 
343
+ if (!signatureData.privateKey) {
344
+ // a key that was not loaded, for example from an unset environment variable
345
+ pushError(createError('Missing private key', 'ENOKEY'));
346
+ continue;
347
+ }
348
+
330
349
  let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.getFieldNames(signatureData));
331
350
 
332
351
  if (!signingHeaderLines.headers.some(header => header.key === 'from')) {
@@ -363,11 +382,12 @@ class DkimSigner extends MessageParser {
363
382
  }
364
383
 
365
384
  let { algorithm, signAlgo, hashAlgo } = this.getAlgorithm(signatureData);
366
- let { canonicalization, bodyCanon } = this.getCanonicalization(signatureData);
385
+ let canonParts = this.getCanonicalization(signatureData);
386
+ let { canonicalization, bodyCanon } = canonParts;
367
387
 
368
388
  try {
369
389
  // throws if invalid
370
- this.validateCanonicalization(canonicalization);
390
+ this.validateCanonicalization(canonParts);
371
391
  } catch (err) {
372
392
  this.errors.push({
373
393
  algorithm,
@@ -468,7 +488,7 @@ class DkimSigner extends MessageParser {
468
488
  instance: signatureData.type === 'ARC' ? instance : false, // ARC only
469
489
  identity, // DKIM only
470
490
  algorithm,
471
- canonicalization: this.getCanonicalization(signatureData).canonicalization,
491
+ canonicalization,
472
492
 
473
493
  signTime: this.signTime,
474
494
  expires: this.expires,
@@ -8,28 +8,34 @@ const {
8
8
  getAlignment,
9
9
  getCurTime,
10
10
  toALabel,
11
+ isHostNameDomain,
11
12
  normalizeDomainName,
12
13
  isSameOrSubdomain,
13
14
  addWarning,
14
15
  KEY_ERROR_COMMENTS,
16
+ isValidDnsName,
15
17
  splitAlgorithm,
16
18
  isSupportedAlgorithm,
17
19
  createKeyCache
18
20
  } = require('../../lib/tools');
19
- const { DOMAIN_NAME, SELECTOR, TIMESTAMP, BODY_LENGTH } = require('./syntax');
21
+ const { DOMAIN_NAME, SELECTOR, LOCAL_PART, TIMESTAMP, BODY_LENGTH } = require('./syntax');
20
22
  const { MessageParser } = require('./message-parser');
21
23
  const { dkimBody } = require('./body');
22
24
  const { generateCanonicalizedHeader } = require('./header');
23
- const { getARChain } = require('../arc');
25
+ const { getARChain, getHighestArcInstance } = require('../arc');
24
26
  const addressparser = require('nodemailer/lib/addressparser');
27
+ const { parseFromHeader, getAuthorDomain } = require('../dmarc/author-domain');
25
28
  const crypto = require('node:crypto');
26
29
  const libmime = require('libmime');
27
30
 
28
31
  // RFC 6376 section 3.5 syntax, used by the strict mode
29
32
  // sig-a-tag-alg = sig-a-tag-k "-" sig-a-tag-h, both ALPHA *(ALPHA / DIGIT)
30
33
  const ALGORITHM = /^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$/;
31
- // RFC 5322 dot-atom-text with the UTF-8 of RFC 6531, or a quoted-string, for the i= local-part
32
- const LOCAL_PART = /^(?:(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+(?:\.(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+)*|"(?:[^"\\\r\n]|\\.)*")$/;
34
+
35
+ // sig-c-tag-alg is "simple", "relaxed" or a hyphenated-word, sig-c-tag one or two of them
36
+ // separated by "/" (RFC 6376 section 3.5). An empty c= is not an omitted one (section 3.2)
37
+ const C_TAG_ALG = '[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
38
+ const CANONICALIZATION = new RegExp(`^${C_TAG_ALG}(?:/${C_TAG_ALG})?$`);
33
39
 
34
40
  // Required tags of a DKIM-Signature (RFC 6376 section 3.5)
35
41
  const REQUIRED_DKIM_TAGS = ['v', 'a', 'b', 'bh', 'd', 'h', 's'];
@@ -73,6 +79,10 @@ class DkimVerifier extends MessageParser {
73
79
 
74
80
  this.headerFrom = [];
75
81
  this.fromFields = 0;
82
+ // "valid", "lax" or "invalid", see parseFromHeader
83
+ this.fromSyntax = 'valid';
84
+ // the Author Domain result of getAuthorDomain, set when there is a From address
85
+ this.author = false;
76
86
  this.envelopeFrom = false;
77
87
 
78
88
  // ARC verification info, `warnings` lists what the lenient mode accepted in the chain
@@ -83,8 +93,9 @@ class DkimVerifier extends MessageParser {
83
93
  this.keyCache = createKeyCache(this.resolver);
84
94
  Object.defineProperty(this.arc, 'keyCache', { value: this.keyCache, enumerable: false, configurable: true, writable: true });
85
95
 
86
- // should we also seal this message using ARC
87
- this.seal = this.options.seal;
96
+ // should we also seal this message using ARC. A copy, so the body hash stored on it
97
+ // below never ends up on the caller's object, which may be shared by concurrent calls
98
+ this.seal = this.options.seal ? Object.assign({}, this.options.seal) : false;
88
99
 
89
100
  if (this.seal) {
90
101
  // calculate body hash for the seal
@@ -106,6 +117,9 @@ class DkimVerifier extends MessageParser {
106
117
  } catch (err) {
107
118
  this.arc.error = err;
108
119
  }
120
+ // the cv= of the newest ARC-Seal in the header fields, also when the chain could not be
121
+ // parsed. arc() uses it to decide whether a failed chain is sealed with cv=fail
122
+ this.arc.latestCv = getHighestArcInstance(headers).latestCv;
109
123
 
110
124
  this.signatureHeaders = headers.parsed
111
125
  .filter(h => h.key === 'dkim-signature')
@@ -125,19 +139,22 @@ class DkimVerifier extends MessageParser {
125
139
  if (splitterPos >= 0) {
126
140
  fromHeader = fromHeader.substr(splitterPos + 1);
127
141
  }
128
- let from = addressparser(fromHeader.trim());
129
- for (let addr of from) {
130
- // RFC 6854 allows a group in From, and the mailboxes of the group are authors
131
- // like any other. Leaving them out would leave DMARC without an Author Domain
132
- for (let mailbox of [].concat(addr?.group || addr || [])) {
133
- if (mailbox && mailbox.address) {
134
- // the full addr-spec, the domain is what follows its last "@"
135
- this.headerFrom.push(mailbox.address);
136
- }
137
- }
142
+ // the full addr-specs, including the mailboxes of a group (RFC 6854). A general
143
+ // purpose address parser guesses at malformed input, which could hand DMARC a domain
144
+ // that is not the one a reader sees, so the RFC 5322 grammar is followed instead
145
+ let { addresses, syntax } = parseFromHeader(fromHeader);
146
+ this.headerFrom.push(...addresses);
147
+ if (syntax === 'invalid' || this.fromSyntax === 'valid') {
148
+ this.fromSyntax = syntax;
138
149
  }
139
150
  }
140
151
 
152
+ if (this.headerFrom.length) {
153
+ // the same Author Domain rules as the DMARC check: a From header field that does
154
+ // not yield a single Author Domain aligns with no signature
155
+ this.author = getAuthorDomain(this.headerFrom, this.fromFields, { fromSyntax: this.fromSyntax, strict: this.strict });
156
+ }
157
+
141
158
  if (this.options.sender) {
142
159
  let returnPath = addressparser(this.options.sender);
143
160
  this.envelopeFrom = returnPath.length && returnPath[0].address ? returnPath[0].address : false;
@@ -258,9 +275,12 @@ class DkimVerifier extends MessageParser {
258
275
  signatureHeader.algorithm = (parsed.a?.value || '').toString();
259
276
  Object.assign(signatureHeader, splitAlgorithm(signatureHeader.algorithm));
260
277
 
261
- signatureHeader.canonicalization = (parsed.c?.value || '').toString();
262
- signatureHeader.headerCanon = signatureHeader.canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
263
- signatureHeader.bodyCanon = getBodyCanon(signatureHeader.canonicalization);
278
+ let canonicalization = (parsed.c?.value || '').toString();
279
+ signatureHeader.headerCanon = canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
280
+ signatureHeader.bodyCanon = getBodyCanon(canonicalization);
281
+ // the canonicalization as read here, for the header canonicalizer. The c= text itself
282
+ // can be malformed, "/relaxed" has no header part
283
+ signatureHeader.canonicalization = `${signatureHeader.headerCanon}/${signatureHeader.bodyCanon}`;
264
284
 
265
285
  signatureHeader.signingDomain = (parsed.d?.value || '').toString();
266
286
  signatureHeader.selector = (parsed.s?.value || '').toString();
@@ -331,12 +351,31 @@ class DkimVerifier extends MessageParser {
331
351
  return;
332
352
  }
333
353
 
354
+ // the lenient mode reads a missing part as "simple" and ignores a third one
355
+ let canonError = parsed.c && !CANONICALIZATION.test((rawTags.get('c') || '').replace(/[ \t\r\n]+/g, ''));
356
+ if (canonError && violation('signature syntax error', 'tag-syntax')) {
357
+ return;
358
+ }
359
+
334
360
  // "This list MUST NOT be empty", the From check below covers the lenient mode
335
361
  if (parsed.h && !normalizeFieldList(parsed.h.value).length && violation('signature syntax error')) {
336
362
  return;
337
363
  }
338
364
 
339
- let invalidNames = !DOMAIN_NAME.test(toALabel(signatureHeader.signingDomain)) || !SELECTOR.test(toALabel(signatureHeader.selector));
365
+ // toALabel converts each label on its own, so these also make up the key record name
366
+ let signingDomainALabel = toALabel(signatureHeader.signingDomain);
367
+ let selectorALabel = toALabel(signatureHeader.selector);
368
+ if (!isHostNameDomain(signingDomainALabel)) {
369
+ signatureHeader.invalid = 'signature syntax error';
370
+ return;
371
+ }
372
+
373
+ // DOMAIN_NAME limits the length of each label, the key record name has to fit in
374
+ // the 253 octets of a DNS name as well
375
+ let invalidNames =
376
+ !DOMAIN_NAME.test(signingDomainALabel) ||
377
+ !SELECTOR.test(selectorALabel) ||
378
+ !isValidDnsName(`${selectorALabel}._domainkey.${signingDomainALabel}`);
340
379
  if (invalidNames && violation('signature syntax error', 'tag-syntax')) {
341
380
  return;
342
381
  }
@@ -467,6 +506,7 @@ class DkimVerifier extends MessageParser {
467
506
  * @returns {Object} Signature result
468
507
  */
469
508
  async verifySignature(signatureHeader, fallback) {
509
+ // the canonicalization as prepareSignature() read it
470
510
  const canonicalization = fallback ? signatureHeader.fallbackCanonicalization : signatureHeader.canonicalization;
471
511
  const bodyHashKey = fallback ? signatureHeader.fallbackBodyHashKey : signatureHeader.bodyHashKey;
472
512
 
@@ -493,8 +533,8 @@ class DkimVerifier extends MessageParser {
493
533
  header: this.getStatusHeader(signatureHeader)
494
534
  };
495
535
 
496
- if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) {
497
- status.aligned = getAlignment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain])?.domain || false;
536
+ if (signatureHeader.type === 'DKIM' && this.author) {
537
+ status.aligned = (this.author.authorDomain && getAlignment(this.author.authorDomain, [signatureHeader.signingDomain])?.domain) || false;
498
538
  }
499
539
 
500
540
  const bodyHasher = this.bodyHashes.get(bodyHashKey);
@@ -503,11 +543,22 @@ class DkimVerifier extends MessageParser {
503
543
 
504
544
  const warnings = [].concat(signatureHeader.warnings || []);
505
545
 
506
- if (signatureHeader.invalid) {
546
+ // RFC 6376 section 3.5 l=: "This value MUST NOT be larger than the actual number of
547
+ // octets in the canonicalized message body"
548
+ let invalidBodyLength =
549
+ signatureHeader.type === 'DKIM' &&
550
+ typeof signatureHeader.maxBodyLength === 'number' &&
551
+ typeof bodyHasher?.canonicalizedLength === 'number' &&
552
+ signatureHeader.maxBodyLength > bodyHasher.canonicalizedLength;
553
+ if (invalidBodyLength && !this.strict) {
554
+ addWarning(warnings, 'tag-syntax');
555
+ }
556
+
557
+ if (signatureHeader.invalid || (invalidBodyLength && this.strict)) {
507
558
  // RFC 6376 section 6.1.1 PERMFAIL, reported as neutral, which RFC 8601 section
508
559
  // 2.7.1 uses for a signature with syntax errors. The key is not looked up
509
560
  status.result = 'neutral';
510
- status.comment = signatureHeader.invalid;
561
+ status.comment = signatureHeader.invalid || 'signature syntax error';
511
562
  } else if (signatureHeader.parsed?.bh?.value !== bodyHash) {
512
563
  // RFC 6376 section 6.1.3 step 3 is a PERMFAIL, which RFC 8601 reports as "fail".
513
564
  // The lenient mode keeps reporting neutral, as it always has
@@ -542,9 +593,10 @@ class DkimVerifier extends MessageParser {
542
593
  }
543
594
 
544
595
  let keyError = false;
545
- if (this.strict && res?.keyType && res.keyType !== signatureHeader.signAlgo) {
546
- // RFC 6376 section 6.1.2 step 8. The lenient mode lets the crypto library
547
- // report the mismatch, as it always has
596
+ if (res?.keyType && res.keyType !== signatureHeader.signAlgo) {
597
+ // RFC 6376 section 6.1.2 step 8, in every mode. The crypto library does not
598
+ // catch every mismatch: an RSA key verifies a PKCS#1 v1.5 signature over the
599
+ // digest that an ed25519-sha256 signature is made over
548
600
  keyError = 'inappropriate key algorithm';
549
601
  } else if (
550
602
  res?.flags?.includes('s') &&
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { stripSignatureValue } = require('../../../lib/tools');
4
+ const { stripSignatureValue, toLineBytes } = require('../../../lib/tools');
5
5
  const { buildSignatureOpts } = require('./signature-opts');
6
6
 
7
- const formatSimpleLine = (line, suffix) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary');
7
+ const formatSimpleLine = (line, suffix) => Buffer.concat([toLineBytes(line), Buffer.from(suffix ? suffix : '', 'binary')]);
8
8
 
9
9
  // generate headers for signing
10
10
  const simpleHeaders = (type, signingHeaderLines, options) => {
@@ -13,6 +13,15 @@ const SELECTOR = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
13
13
  // sig-t-tag and sig-x-tag are 1*12DIGIT, sig-l-tag is 1*76DIGIT
14
14
  const TIMESTAMP = /^[0-9]{1,12}$/;
15
15
  const BODY_LENGTH = /^[0-9]{1,76}$/;
16
+ // the Local-part of an i= value (RFC 6376 section 3.5): RFC 5322 dot-atom-text with the UTF-8
17
+ // of RFC 6531, or a quoted-string
18
+ const LOCAL_PART = /^(?:(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+(?:\.(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+)*|"(?:[^"\\\r\n]|\\.)*")$/;
19
+
20
+ // The characters of a host name, after U-labels are converted to A-labels. A d= with anything
21
+ // else (such as "/", "?", "#", "@" or ":") can not name the zone of its key record, so a
22
+ // DKIM-Signature, ARC-Seal or ARC-Message-Signature with it is a syntax error in every mode
23
+ // (RFC 6376 section 3.5)
24
+ const HOST_NAME_CHARS = /^[A-Za-z0-9_.-]+$/;
16
25
 
17
26
  // RFC 8617 section 4.2.1: instance values range from 1 to 50
18
27
  const MAX_ARC_INSTANCE = 50;
@@ -24,6 +33,8 @@ module.exports = {
24
33
  DOMAIN_LABEL,
25
34
  DOMAIN_NAME,
26
35
  SELECTOR,
36
+ LOCAL_PART,
37
+ HOST_NAME_CHARS,
27
38
  TIMESTAMP,
28
39
  BODY_LENGTH,
29
40
  MAX_ARC_INSTANCE,
@@ -11,6 +11,7 @@ const dkimVerify = async (input, options) => {
11
11
  //headers: dkimVerifier.headers,
12
12
  headerFrom: dkimVerifier.headerFrom,
13
13
  fromFields: dkimVerifier.fromFields,
14
+ fromSyntax: dkimVerifier.fromSyntax,
14
15
  envelopeFrom: dkimVerifier.envelopeFrom,
15
16
  results: dkimVerifier.results
16
17
  };