mailauth 6.0.0 → 7.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.
Files changed (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +111 -39
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +80 -33
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +495 -43
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +140 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +75 -36
  37. package/lib/mta-sts.d.ts +77 -11
  38. package/lib/mta-sts.js +497 -182
  39. package/lib/parse-dkim-headers.js +376 -191
  40. package/lib/parse-received.js +104 -32
  41. package/lib/spf/index.d.ts +37 -1
  42. package/lib/spf/index.js +260 -28
  43. package/lib/spf/macro.js +50 -13
  44. package/lib/spf/spf-verify.js +440 -204
  45. package/lib/spf/syntax.js +366 -0
  46. package/lib/tools.js +522 -116
  47. package/package.json +1 -1
@@ -6,28 +6,66 @@ const {
6
6
  defaultDKIMFieldNames,
7
7
  defaultARCFieldNames,
8
8
  validateAlgorithm,
9
- getPrivateKey
9
+ getPrivateKey,
10
+ getCurTime,
11
+ toALabel,
12
+ isSameOrSubdomain,
13
+ addWarning,
14
+ createError,
15
+ splitAlgorithm
10
16
  } = require('../../lib/tools');
17
+ const { DOMAIN_NAME, SELECTOR, MAX_ARC_INSTANCE, ARC_HEADER_KEYS } = require('./syntax');
11
18
  const { MessageParser } = require('./message-parser');
12
19
  const { dkimBody } = require('./body');
13
20
  const { generateCanonicalizedHeader } = require('./header');
14
21
  const crypto = require('node:crypto');
15
22
 
23
+ // Characters that can never be in a tag value (RFC 6376 section 3.2): the semicolon ends the
24
+ // tag, and whitespace, CR and LF would change the header itself. Anything that is not
25
+ // printable ASCII once converted to an A-label is not a DNS name either
26
+ const UNSAFE_TAG_VALUE = /[^\x21-\x3a\x3c-\x7e]/;
27
+ // t= and x= are 1*12DIGIT (RFC 6376 section 3.5)
28
+ const MAX_TIMESTAMP = 999999999999;
29
+
30
+ // RFC 8617 section 4.1.2: header fields an ARC-Message-Signature never covers
31
+ const ARC_EXCLUDED_FIELDS = [...ARC_HEADER_KEYS, 'authentication-results'];
32
+
33
+ // RFC 8301 section 3.2: signers MUST use RSA keys of at least 1024 bits
34
+ const MIN_RSA_KEY_SIZE = 1024;
35
+
36
+ // Turns a header list option (a colon separated string or an array of names) into the
37
+ // colon separated string getSigningHeaderLines expects, or false if there is none
38
+ const normalizeHeaderList = headerList => {
39
+ if (Array.isArray(headerList)) {
40
+ headerList = headerList
41
+ .map(entry => (entry || '').toString().trim())
42
+ .filter(entry => entry)
43
+ .join(':');
44
+ }
45
+ return typeof headerList === 'string' && headerList.trim() ? headerList : false;
46
+ };
47
+
16
48
  class DkimSigner extends MessageParser {
17
49
  constructor(options) {
18
50
  super();
19
51
 
20
- let { canonicalization, algorithm, signTime, headerList, signatureData, arc, bodyHash, headers, getARChain, expires } = options || {};
52
+ let { canonicalization, algorithm, signTime, headerList, signatureData, arc, bodyHash, headers, getARChain, getArcInstance, expires, strict } =
53
+ options || {};
21
54
 
22
55
  this.algorithm = algorithm || false;
23
56
  this.canonicalization = canonicalization || 'relaxed/relaxed';
24
57
 
58
+ // follow RFC 6376 and RFC 8301 exactly instead of the lenient defaults
59
+ this.strict = !!strict;
60
+
25
61
  this.errors = [];
62
+ // what was signed although the strict mode would have refused it, as short codes
63
+ this.warnings = [];
26
64
 
27
65
  this.expires = expires;
28
66
  this.signTime = signTime;
29
67
 
30
- this.headerList = headerList;
68
+ this.headerList = normalizeHeaderList(headerList);
31
69
 
32
70
  this.signatureData = [].concat(signatureData || []).map(entry => {
33
71
  entry.type = 'DKIM';
@@ -38,6 +76,8 @@ class DkimSigner extends MessageParser {
38
76
 
39
77
  this.arc = Object.assign({ chain: false }, arc);
40
78
  this.getARChain = getARChain;
79
+ // decides the instance of an ARC set that has none set, from the message headers
80
+ this.getArcInstance = getArcInstance;
41
81
  if (this.arc.signingDomain && this.arc.selector && this.arc.privateKey) {
42
82
  this.signatureData.push({
43
83
  type: 'ARC',
@@ -45,7 +85,8 @@ class DkimSigner extends MessageParser {
45
85
  selector: this.arc.selector, // s=
46
86
  privateKey: this.arc.privateKey,
47
87
  canonicalization: 'relaxed/relaxed',
48
- algorithm: 'rsa-sha256' // fixed for now, throws if non-rsa key is used
88
+ // rsa-sha256 or ed25519-sha256, follows the key type when not set
89
+ algorithm: this.arc.algorithm || false
49
90
  });
50
91
  }
51
92
 
@@ -58,6 +99,18 @@ class DkimSigner extends MessageParser {
58
99
  this.setupHashes();
59
100
  }
60
101
 
102
+ warn(warning) {
103
+ addWarning(this.warnings, warning);
104
+ }
105
+
106
+ // A rule the strict mode enforces: throws in the strict mode, notes the warning otherwise
107
+ violation(message, code, warning) {
108
+ if (this.strict) {
109
+ throw createError(message, code);
110
+ }
111
+ this.warn(warning);
112
+ }
113
+
61
114
  getCanonicalization(signatureData) {
62
115
  let canonicalization = signatureData?.canonicalization || this.canonicalization;
63
116
  let headerCanon = canonicalization.split('/').shift().toLowerCase().trim();
@@ -66,11 +119,12 @@ class DkimSigner extends MessageParser {
66
119
  }
67
120
 
68
121
  getAlgorithm(signatureData) {
69
- let algorithm = (signatureData?.algorithm || this.algorithm || '').toLowerCase().trim();
70
- let signAlgo = algorithm.split('-').shift().toLowerCase().trim() || false; // default is derived from key
71
- let hashAlgo = algorithm.split('-').pop().toLowerCase().trim() || 'sha256';
122
+ // the shared algorithm option is for DKIM signatures, an ARC-Message-Signature has its own
123
+ let algorithm = (signatureData?.algorithm || (signatureData?.type === 'ARC' ? '' : this.algorithm) || '').toLowerCase().trim();
124
+ let { signAlgo, hashAlgo } = splitAlgorithm(algorithm);
72
125
 
73
- return { algorithm, signAlgo, hashAlgo };
126
+ // the signing algorithm defaults to the key type
127
+ return { algorithm, signAlgo: signAlgo || false, hashAlgo: hashAlgo || 'sha256' };
74
128
  }
75
129
 
76
130
  setupHashes() {
@@ -119,15 +173,89 @@ class DkimSigner extends MessageParser {
119
173
  }
120
174
  }
121
175
 
176
+ // Validates the d= and s= values (RFC 6376 section 3.5) and returns them as A-labels.
177
+ // A value that would break out of its tag or out of the header is refused in every mode
178
+ validateSigningIdentifiers(signatureData) {
179
+ let checks = [
180
+ ['signingDomain', 'd=', DOMAIN_NAME, 'EINVALIDDOMAIN'],
181
+ ['selector', 's=', SELECTOR, 'EINVALIDSELECTOR']
182
+ ];
183
+
184
+ for (let [key, tag, format, code] of checks) {
185
+ let value = typeof signatureData[key] === 'string' ? signatureData[key].trim() : '';
186
+ let aLabel = toALabel(value);
187
+
188
+ if (!value || UNSAFE_TAG_VALUE.test(aLabel)) {
189
+ let err = new Error(value ? `Invalid ${tag} value ${JSON.stringify(signatureData[key])}` : `Missing ${tag} value`);
190
+ err.code = code;
191
+ throw err;
192
+ }
193
+
194
+ if (!format.test(aLabel)) {
195
+ this.violation(`Invalid ${tag} value ${JSON.stringify(signatureData[key])}`, code, key === 'signingDomain' ? 'd-syntax' : 's-syntax');
196
+ }
197
+ }
198
+ }
199
+
200
+ // The i= value of a DKIM-Signature, from the `identity` option (RFC 6376 section 3.5)
201
+ getIdentity(signatureData) {
202
+ if (signatureData.type !== 'DKIM' || !signatureData.identity) {
203
+ return false;
204
+ }
205
+
206
+ let identity = signatureData.identity.toString().trim();
207
+ let atPos = identity.lastIndexOf('@');
208
+ let domain = atPos >= 0 ? toALabel(identity.substring(atPos + 1)) : '';
209
+
210
+ if (atPos < 0 || !domain || /[\s;\x00-\x1f\x7f]/.test(identity)) {
211
+ let err = new Error(`Invalid identity ${JSON.stringify(signatureData.identity)}`);
212
+ err.code = 'EINVALIDIDENTITY';
213
+ throw err;
214
+ }
215
+
216
+ if (!isSameOrSubdomain(domain, signatureData.signingDomain)) {
217
+ // "The domain part of the address MUST be the same as, or a subdomain of, the value
218
+ // of the d= tag", or every verifier ignores the signature
219
+ this.violation(`Identity domain is not the same as or a subdomain of ${signatureData.signingDomain}`, 'EINVALIDIDENTITY', 'identity-domain');
220
+ }
221
+
222
+ return identity;
223
+ }
224
+
225
+ // The t= and x= values in seconds (RFC 6376 section 3.5), false for a tag that is left out
226
+ getTimestamps() {
227
+ let timestamp = Math.floor(getCurTime(this.signTime).getTime() / 1000);
228
+ let expiration = this.expires ? Math.floor(getCurTime(this.expires).getTime() / 1000) : false;
229
+
230
+ let validTimestamp = value => Number.isInteger(value) && value >= 0 && value <= MAX_TIMESTAMP;
231
+
232
+ if (!validTimestamp(timestamp)) {
233
+ // 1*12DIGIT can not hold a time before 1970 or after the year 33658
234
+ this.violation('Signing time can not be represented in a t= tag', 'EINVALIDTIME', 'invalid-signtime');
235
+ timestamp = false;
236
+ }
237
+
238
+ if (expiration !== false && !validTimestamp(expiration)) {
239
+ this.violation('Expiration time can not be represented in an x= tag', 'EINVALIDTIME', 'invalid-expiration');
240
+ expiration = false;
241
+ }
242
+
243
+ if (expiration !== false && timestamp !== false && expiration <= timestamp) {
244
+ // "The value of the x= tag MUST be greater than the value of the t= tag"
245
+ this.violation('Expiration time must be later than the signing time', 'EINVALIDTIME', 'invalid-expiration');
246
+ }
247
+
248
+ return { timestamp, expiration };
249
+ }
250
+
122
251
  async messageHeaders(headers) {
123
252
  this.headers = headers;
124
253
 
125
254
  if (this.getARChain) {
126
255
  try {
127
- this.arc.chain = this?.getARChain(headers);
256
+ this.arc.chain = this.getARChain(headers);
128
257
  if (this.arc.chain?.length) {
129
258
  this.arc.lastEntry = this.arc.chain[this.arc.chain.length - 1];
130
- this.arc.instance = this.arc.instance ? this.arc.instance : this.arc.lastEntry.i + 1;
131
259
  }
132
260
  } catch (err) {
133
261
  this.arc.error = err;
@@ -162,27 +290,44 @@ class DkimSigner extends MessageParser {
162
290
  return this.finalize();
163
291
  }
164
292
 
293
+ // The header fields to sign for a signature: its own headerList, the shared one, or the
294
+ // default list of its type. An ARC-Message-Signature never covers ARC header fields or
295
+ // Authentication-Results (RFC 8617 section 4.1.2)
296
+ getFieldNames(signatureData) {
297
+ let fieldNames =
298
+ normalizeHeaderList(signatureData.headerList) || this.headerList || (signatureData.type === 'ARC' ? defaultARCFieldNames : defaultDKIMFieldNames);
299
+
300
+ if (signatureData.type === 'ARC') {
301
+ fieldNames = fieldNames
302
+ .split(':')
303
+ .filter(name => !ARC_EXCLUDED_FIELDS.includes(name.trim().toLowerCase()))
304
+ .join(':');
305
+ }
306
+
307
+ return fieldNames;
308
+ }
309
+
165
310
  async finalize() {
166
311
  for (let signatureData of this.signatureData || []) {
167
312
  if (!signatureData.privateKey) {
168
313
  continue;
169
314
  }
170
315
 
171
- let fieldNames = this.headerList && this.headerList.length ? this.headerList : false;
172
- if (!fieldNames) {
173
- switch (signatureData.type) {
174
- case 'ARC':
175
- fieldNames = defaultARCFieldNames;
176
- break;
177
-
178
- case 'DKIM':
179
- default:
180
- fieldNames = defaultDKIMFieldNames;
181
- break;
182
- }
183
- }
316
+ const pushError = (err, extra) => {
317
+ this.errors.push(
318
+ Object.assign(
319
+ {
320
+ type: signatureData.type,
321
+ selector: signatureData.selector,
322
+ signingDomain: signatureData.signingDomain
323
+ },
324
+ extra || {},
325
+ { err }
326
+ )
327
+ );
328
+ };
184
329
 
185
- let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.headerList);
330
+ let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.getFieldNames(signatureData));
186
331
 
187
332
  if (!signingHeaderLines.headers.some(header => header.key === 'from')) {
188
333
  // RFC 6376 section 5.4: the From header field MUST be signed, and section
@@ -191,12 +336,29 @@ class DkimSigner extends MessageParser {
191
336
  // empty h= tag, which is not even valid sig-h-tag syntax
192
337
  let err = new Error('Can not sign a message that has no From header');
193
338
  err.code = 'ENOFROM';
194
- this.errors.push({
195
- type: signatureData.type,
196
- selector: signatureData.selector,
197
- signingDomain: signatureData.signingDomain,
198
- err
199
- });
339
+ pushError(err);
340
+ continue;
341
+ }
342
+
343
+ let identity, timestamps, instance;
344
+ try {
345
+ // throws on a value that is not safe to put into the header
346
+ this.validateSigningIdentifiers(signatureData);
347
+ identity = this.getIdentity(signatureData);
348
+ timestamps = this.getTimestamps();
349
+
350
+ if (signatureData.type === 'ARC') {
351
+ // createSeal decides where a set without an explicit instance goes
352
+ instance = this.arc.instance || (this.getArcInstance ? this.getArcInstance(this.headers) : 1);
353
+ if (!Number.isInteger(instance) || instance < 1 || instance > MAX_ARC_INSTANCE) {
354
+ // RFC 8617 section 4.2.1, an ARC chain holds at most 50 sets
355
+ let err = new Error(`ARC instance ${instance} is out of range (1-${MAX_ARC_INSTANCE})`);
356
+ err.code = 'EINVALIDINSTANCE';
357
+ throw err;
358
+ }
359
+ }
360
+ } catch (err) {
361
+ pushError(err);
200
362
  continue;
201
363
  }
202
364
 
@@ -252,6 +414,15 @@ class DkimSigner extends MessageParser {
252
414
  }
253
415
 
254
416
  algorithm = `${signAlgo}-${hashAlgo}`;
417
+
418
+ if (keyType === 'rsa' && privateKeyObj.asymmetricKeyDetails?.modulusLength < MIN_RSA_KEY_SIZE) {
419
+ // RFC 8301 section 3.2, verifiers MUST NOT accept such a signature
420
+ this.violation(
421
+ `RSA key too short (${privateKeyObj.asymmetricKeyDetails.modulusLength} bits, at least ${MIN_RSA_KEY_SIZE} required)`,
422
+ 'ESHORTKEY',
423
+ 'weak-key'
424
+ );
425
+ }
255
426
  } catch (err) {
256
427
  this.errors.push({
257
428
  selector: signatureData.selector,
@@ -262,8 +433,20 @@ class DkimSigner extends MessageParser {
262
433
  }
263
434
 
264
435
  try {
265
- // throws if invalid
266
- validateAlgorithm(algorithm);
436
+ if (this.strict && hashAlgo === 'sha1') {
437
+ // RFC 8301 section 3.1, "rsa-sha1 MUST NOT be used for signing"
438
+ let err = new Error('rsa-sha1 is not allowed for signing');
439
+ err.code = 'EINVALIDALGO';
440
+ throw err;
441
+ }
442
+
443
+ // throws if invalid, also for ed25519-sha1, which is not an algorithm. ARC
444
+ // signatures are never made with sha1, no ARC validator accepts them
445
+ validateAlgorithm(algorithm, signatureData.type === 'ARC');
446
+
447
+ if (hashAlgo === 'sha1') {
448
+ this.warn('rsa-sha1');
449
+ }
267
450
  } catch (err) {
268
451
  this.errors.push({
269
452
  algorithm,
@@ -282,12 +465,15 @@ class DkimSigner extends MessageParser {
282
465
  {},
283
466
  signatureData,
284
467
  {
285
- instance: 'instance' in this.arc && (this.arc.instance || 1), // ARC only
468
+ instance: signatureData.type === 'ARC' ? instance : false, // ARC only
469
+ identity, // DKIM only
286
470
  algorithm,
287
471
  canonicalization: this.getCanonicalization(signatureData).canonicalization,
288
472
 
289
473
  signTime: this.signTime,
290
474
  expires: this.expires,
475
+ timestamp: timestamps.timestamp,
476
+ expiration: timestamps.expiration,
291
477
 
292
478
  bodyHash: this.bodyHashes.has(hashKey) ? this.bodyHashes.get(hashKey).hash : null
293
479
  },