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
package/lib/tools.js CHANGED
@@ -10,8 +10,10 @@ 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 { parseTagList } = parseDkimHeaders;
13
14
  const tldts = require('tldts');
14
15
  const Joi = require('joi');
16
+ const { DOMAIN_NAME } = require('./dkim/syntax');
15
17
  const base64Schema = Joi.string().base64({ paddingRequired: false });
16
18
 
17
19
  const defaultDKIMFieldNames =
@@ -34,6 +36,51 @@ const TLDTS_OPTS = {
34
36
  allowPrivateDomains: true
35
37
  };
36
38
 
39
+ /**
40
+ * Converts the U-labels of a domain name to A-labels (RFC 5890 section 2.3.2.1). Only the
41
+ * labels that have non-ASCII characters in them are touched: they are normalized to NFC and
42
+ * lower cased first, because the Punycode encoding of an upper case letter is a different
43
+ * label than the encoding of its lower case form, and IDNA2008 has no upper case U-labels.
44
+ * ASCII labels are returned as they are, so a value that is already an A-label (or plain
45
+ * ASCII) never changes.
46
+ *
47
+ * @param {String} domain Domain name
48
+ * @returns {String} Domain name with A-labels, or the input if it can not be converted
49
+ */
50
+ const toALabel = domain => {
51
+ domain = (domain || '').toString();
52
+ if (!NON_ASCII.test(domain)) {
53
+ return domain;
54
+ }
55
+
56
+ return domain
57
+ .split('.')
58
+ .map(label => {
59
+ if (!NON_ASCII.test(label)) {
60
+ return label;
61
+ }
62
+ try {
63
+ return punycode.toASCII(label.normalize('NFC').toLowerCase());
64
+ } catch (err) {
65
+ return label;
66
+ }
67
+ })
68
+ .join('.');
69
+ };
70
+
71
+ // lower case A-label form of a domain without the root dot, for comparing domains
72
+ const normalizeDomainName = domain =>
73
+ toALabel((domain || '').toString().trim())
74
+ .toLowerCase()
75
+ .replace(/\.$/, '');
76
+
77
+ // Whether `child` is `parent` or one of its subdomains
78
+ const isSameOrSubdomain = (child, parent) => {
79
+ child = normalizeDomainName(child);
80
+ parent = normalizeDomainName(parent);
81
+ return !!child && !!parent && (child === parent || child.endsWith(`.${parent}`));
82
+ };
83
+
37
84
  const writeToStream = async (stream, input, chunkSize) => {
38
85
  chunkSize = chunkSize || 64 * 1024;
39
86
 
@@ -253,26 +300,17 @@ const formatSignatureHeaderLine = (type, values, folded) => {
253
300
  }
254
301
 
255
302
  if (['d', 's'].includes(key)) {
256
- try {
257
- // convert to A-label if needed
258
- val = punycode.toASCII(val);
259
- } catch (err) {
260
- // ignore
261
- }
303
+ // convert to A-label if needed
304
+ val = toALabel(val);
262
305
  }
263
306
 
264
307
  if (key === 'i' && type === 'DKIM') {
265
308
  val = val.toString();
266
- let atPos = val.indexOf('@');
309
+ // the domain is what follows the last "@", a quoted local-part may hold one
310
+ let atPos = val.lastIndexOf('@');
267
311
  if (atPos >= 0) {
268
- let domainPart = val.substr(atPos + 1);
269
- try {
270
- // convert to A-label if needed
271
- domainPart = punycode.toASCII(domainPart);
272
- } catch (err) {
273
- // ignore
274
- }
275
- val = val.substr(0, atPos + 1) + domainPart;
312
+ // convert to A-label if needed
313
+ val = val.substr(0, atPos + 1) + toALabel(val.substr(atPos + 1));
276
314
  }
277
315
  }
278
316
 
@@ -287,11 +325,116 @@ const formatSignatureHeaderLine = (type, values, folded) => {
287
325
  return header;
288
326
  };
289
327
 
290
- const getPublicKey = async (type, name, minBitLength, resolver) => {
328
+ /**
329
+ * Creates an Error with a code, and any extra properties copied onto it
330
+ *
331
+ * @param {String} message Error message
332
+ * @param {String} code Value for err.code
333
+ * @param {Object} [extra] Properties to add to the error
334
+ * @returns {Error}
335
+ */
336
+ const createError = (message, code, extra) => {
337
+ let err = new Error(message);
338
+ err.code = code;
339
+ return extra ? Object.assign(err, extra) : err;
340
+ };
341
+
342
+ /**
343
+ * Appends every item that is not in the list yet, keeping the list free of duplicates
344
+ *
345
+ * @param {Array} list List to add to
346
+ * @param {...*} items Items to add
347
+ * @returns {Array} The same list
348
+ */
349
+ const addWarning = (list, ...items) => {
350
+ for (let item of items) {
351
+ if (!list.includes(item)) {
352
+ list.push(item);
353
+ }
354
+ }
355
+ return list;
356
+ };
357
+
358
+ // The result comment for a key lookup that failed with one of these codes. The DKIM verifier
359
+ // reports them as they are, the ARC validator adds "for <query domain>"
360
+ const KEY_ERROR_COMMENTS = new Map([
361
+ ['ENOTFOUND', 'no key'],
362
+ ['ENODATA', 'no key'],
363
+ ['EINVALIDVER', 'unknown key version'],
364
+ ['EINVALIDTYPE', 'unknown key type'],
365
+ ['EINVALIDVAL', 'invalid public key'],
366
+ // the key record h= does not list the hash algorithm (RFC 6376 section 6.1.2)
367
+ ['EINVALIDHASH', 'inappropriate hash algorithm'],
368
+ // the key record s= does not list email (RFC 6376 section 3.6.1)
369
+ ['EINVALIDSERVICE', 'key not for email']
370
+ ]);
371
+
372
+ // an error about a key record, with the record itself as err.rr when there is one
373
+ const keyError = (message, code, rr, extra) => createError(message, code, Object.assign(rr ? { rr } : {}, extra));
374
+
375
+ // splits a colon separated key record list (h=, s=, t=) into lower case entries
376
+ const splitKeyList = value =>
377
+ (typeof value === 'string' ? value : '')
378
+ .split(':')
379
+ .map(entry => entry.trim().toLowerCase())
380
+ .filter(entry => entry);
381
+
382
+ /**
383
+ * Exports a public key as PEM. RSA keys have always been returned in the PKCS#1 form, but some
384
+ * OpenSSL versions (Node.js 24.21) can not write a key that was read from a bare RSAPublicKey in
385
+ * that form, so SubjectPublicKeyInfo is used then. Both forms verify the same signatures
386
+ *
387
+ * @param {KeyObject} keyObj Public key
388
+ * @returns {String} PEM encoded key
389
+ */
390
+ const exportPublicKey = keyObj => {
391
+ if (keyObj.asymmetricKeyType !== 'ed25519') {
392
+ try {
393
+ return keyObj.export({ type: 'pkcs1', format: 'pem' });
394
+ } catch (err) {
395
+ // fall back to SubjectPublicKeyInfo
396
+ }
397
+ }
398
+ return keyObj.export({ type: 'spki', format: 'pem' });
399
+ };
400
+
401
+ /**
402
+ * Tells a bare RSAPublicKey (RFC 3447 A.1.1, SEQUENCE of two INTEGERs) apart from a
403
+ * SubjectPublicKeyInfo (a SEQUENCE that starts with the AlgorithmIdentifier SEQUENCE) by the
404
+ * tag of the first element of the outer DER SEQUENCE
405
+ *
406
+ * @param {Buffer} der DER encoded key
407
+ * @returns {Boolean} true for a bare RSAPublicKey
408
+ */
409
+ const isBareRsaPublicKey = der => {
410
+ if (der.length < 3 || der[0] !== 0x30) {
411
+ return false;
412
+ }
413
+ let headerLength = der[1] < 0x80 ? 2 : 2 + (der[1] & 0x7f);
414
+ return der[headerLength] === 0x02;
415
+ };
416
+
417
+ /**
418
+ * Fetches and validates a DKIM public key record (RFC 6376 section 3.6.1, RFC 8463)
419
+ *
420
+ * @param {String} type 'DKIM', 'ARC' (for an ARC-Message-Signature) or 'AS' (for an ARC-Seal)
421
+ * @param {String} name DNS name of the key record, "selector._domainkey.domain"
422
+ * @param {Number} [minBitLength=1024] Minimum RSA key size, smaller keys throw ESHORTKEY
423
+ * @param {Function} [resolver] DNS resolver
424
+ * @param {Object} [options]
425
+ * @param {Boolean} [options.strict=false] Apply the RFC 6376 key record syntax rules exactly
426
+ * @param {String} [options.hashAlgo] Hash algorithm of the signature ('sha256', 'sha1'). When set,
427
+ * a key record h= tag that does not list it throws EINVALIDHASH (RFC 6376 section 6.1.2)
428
+ * @returns {Object} { publicKey, rr, modulusLength, keyType, flags, testing, warnings }
429
+ */
430
+ const getPublicKey = async (type, name, minBitLength, resolver, options) => {
291
431
  minBitLength = minBitLength || 1024;
292
432
  resolver = resolver || dns.resolve;
433
+ options = options || {};
434
+ const strict = !!options.strict;
293
435
 
294
- let list = await resolver(name, 'TXT');
436
+ // RFC 8616 section 3: a U-label is converted to an A-label before it is looked up
437
+ let list = await resolver(toALabel(name), 'TXT');
295
438
  let rr =
296
439
  list &&
297
440
  []
@@ -299,97 +442,177 @@ const getPublicKey = async (type, name, minBitLength, resolver) => {
299
442
  .join('')
300
443
  .replace(/\s+/g, '');
301
444
 
302
- if (rr) {
303
- // prefix value for parsing as there is no default value
304
- let entry = parseDkimHeaders(`DNS: TXT;${rr}`);
445
+ if (!rr) {
446
+ throw keyError('Missing key value', 'EINVALIDVAL');
447
+ }
305
448
 
306
- const publicKeyValue = entry?.parsed?.p?.value;
307
- if (!publicKeyValue) {
308
- let err = new Error('Missing key value');
309
- err.code = 'EINVALIDVAL';
310
- err.rr = rr;
311
- throw err;
449
+ const warnings = [];
450
+ // A rule the strict mode enforces: throws in the strict mode, notes the warning otherwise
451
+ const violation = (message, code, warning) => {
452
+ if (strict) {
453
+ throw keyError(message, code, rr);
312
454
  }
455
+ addWarning(warnings, warning);
456
+ };
313
457
 
314
- let validation = base64Schema.validate(publicKeyValue);
315
- if (validation.error) {
316
- let err = new Error('Invalid base64 format for public key');
317
- err.code = 'EINVALIDVAL';
318
- err.rr = rr;
319
- err.details = validation.error;
320
- throw err;
321
- }
458
+ // tag names are case-sensitive, so strict mode reads them as they are
459
+ let entry = parseTagList(rr, { strict });
460
+ const tags = entry.parsed;
322
461
 
323
- if (type === 'DKIM' && entry?.parsed?.v && (entry?.parsed?.v?.value || '').toString().toLowerCase().trim() !== 'dkim1') {
324
- let err = new Error('Unknown key version');
325
- err.code = 'EINVALIDVER';
326
- err.rr = rr;
327
- throw err;
462
+ if (entry.syntaxErrors.length) {
463
+ // RFC 6376 section 6.1.2 step 5: a key record that does not adhere to the format is ignored
464
+ violation(`Invalid key record syntax: ${entry.syntaxErrors[0]}`, 'EINVALIDVAL', 'key-syntax');
465
+ }
466
+
467
+ const publicKeyValue = tags.p?.value;
468
+ if (!publicKeyValue) {
469
+ // an empty p= means the key has been revoked
470
+ throw keyError('Missing key value', 'EINVALIDVAL', rr);
471
+ }
472
+
473
+ let validation = base64Schema.validate(publicKeyValue);
474
+ if (validation.error) {
475
+ throw keyError('Invalid base64 format for public key', 'EINVALIDVAL', rr, { details: validation.error });
476
+ }
477
+
478
+ // ARC signatures use the same DKIM key records (RFC 8617 section 4.1), so the version
479
+ // rules apply to them as well
480
+ if (tags.v) {
481
+ let version = (tags.v.value || '').toString();
482
+ if (version.toLowerCase().trim() !== 'dkim1') {
483
+ throw keyError('Unknown key version', 'EINVALIDVER', rr);
328
484
  }
329
485
 
330
- let paddingNeeded = publicKeyValue.length % 4 ? 4 - (publicKeyValue.length % 4) : 0;
331
- let paddedPublicKey = publicKeyValue + '='.repeat(paddingNeeded);
486
+ // key-v-tag is %x44.4B.49.4D.31, an exact string, and "MUST be the first tag"
487
+ if (version !== 'DKIM1' || entry.tags[0]?.name !== 'v') {
488
+ violation(version !== 'DKIM1' ? 'Unknown key version' : 'Key version tag is not the first tag', 'EINVALIDVER', 'key-v-syntax');
489
+ }
490
+ }
332
491
 
333
- let rawPublicKey = Buffer.from(publicKeyValue, 'base64');
334
- let publicKeyObj;
335
- let publicKeyOpts;
492
+ // RFC 6376 section 3.6.1 s=: "Verifiers for a given service type MUST ignore this record
493
+ // if the appropriate type is not listed", applied in every mode, it is the key owner's choice
494
+ if (tags.s) {
495
+ let services = splitKeyList(tags.s.value);
496
+ if (!services.includes('email') && !services.includes('*')) {
497
+ throw keyError('Key is not meant for email', 'EINVALIDSERVICE', rr);
498
+ }
499
+ }
336
500
 
337
- if (rawPublicKey.length === 32) {
338
- // seems like an ed25519 key
339
- rawPublicKey = Buffer.concat([Buffer.from('302A300506032B6570032100', 'hex'), rawPublicKey]);
340
- publicKeyOpts = {
341
- key: rawPublicKey,
342
- format: 'der',
343
- type: 'spki'
344
- };
345
- } else {
346
- const publicKeyPem = Buffer.from(`-----BEGIN PUBLIC KEY-----\n${paddedPublicKey.replace(/.{64}/g, '$&\n').trim()}\n-----END PUBLIC KEY-----`);
347
- publicKeyOpts = {
348
- key: publicKeyPem,
349
- format: 'pem'
350
- };
501
+ // RFC 6376 section 6.1.2 step 6: the key owner can limit the hash algorithms the key
502
+ // is used with, which is also how a domain opts out of rsa-sha1 in the lenient mode
503
+ if (tags.h && options.hashAlgo) {
504
+ let hashes = splitKeyList(tags.h.value);
505
+ if (!hashes.includes(options.hashAlgo.toLowerCase())) {
506
+ throw keyError(`Key does not allow ${options.hashAlgo}`, 'EINVALIDHASH', rr);
351
507
  }
508
+ }
509
+
510
+ let declaredKeyType = tags.k ? (tags.k.value || '').toString().toLowerCase().trim() : false;
511
+
512
+ let rawPublicKey = Buffer.from(publicKeyValue, 'base64');
513
+ let publicKeyObj;
514
+
515
+ let candidates = [];
516
+ if (rawPublicKey.length === 32 && (!strict || declaredKeyType === 'ed25519')) {
517
+ // RFC 8463 section 4: the raw 32 byte ed25519 key
518
+ candidates.push({
519
+ key: Buffer.concat([Buffer.from('302A300506032B6570032100', 'hex'), rawPublicKey]),
520
+ format: 'der',
521
+ type: 'spki'
522
+ });
523
+ } else if (isBareRsaPublicKey(rawPublicKey)) {
524
+ // the bare RSAPublicKey of RFC 6376 section 3.6.1 (k=rsa), picked by its structure
525
+ candidates.push({ key: rawPublicKey, format: 'der', type: 'pkcs1' });
526
+ } else {
527
+ // a SubjectPublicKeyInfo structure is what is published in practice
528
+ candidates.push({ key: rawPublicKey, format: 'der', type: 'spki' });
529
+ }
352
530
 
531
+ let lastError;
532
+ let exportedKey;
533
+ for (let candidate of candidates) {
353
534
  try {
354
- publicKeyObj = crypto.createPublicKey(publicKeyOpts);
535
+ let keyObj = crypto.createPublicKey(candidate);
536
+ exportedKey = exportPublicKey(keyObj);
537
+ publicKeyObj = keyObj;
538
+ break;
355
539
  } catch (err) {
356
- let error = new Error('Unknown key type (${keyType})', { cause: err });
357
- error.code = 'EINVALIDTYPE';
358
- error.rr = rr;
359
- throw error;
540
+ lastError = err;
360
541
  }
542
+ }
361
543
 
362
- let keyType = publicKeyObj.asymmetricKeyType;
544
+ if (!publicKeyObj) {
545
+ let error = new Error('Unknown key type', { cause: lastError });
546
+ error.code = 'EINVALIDTYPE';
547
+ error.rr = rr;
548
+ throw error;
549
+ }
363
550
 
364
- if (!['rsa', 'ed25519'].includes(keyType) || (entry?.parsed?.k && entry?.parsed?.k?.value?.toLowerCase() !== keyType)) {
365
- let err = new Error('Unknown key type (${keyType})');
366
- err.code = 'EINVALIDTYPE';
367
- err.rr = rr;
368
- throw err;
369
- }
551
+ let keyType = publicKeyObj.asymmetricKeyType;
370
552
 
371
- let modulusLength = publicKeyObj.asymmetricKeyDetails.modulusLength;
553
+ if (!['rsa', 'ed25519'].includes(keyType) || (declaredKeyType && declaredKeyType !== keyType)) {
554
+ throw keyError(`Unknown key type (${keyType})`, 'EINVALIDTYPE', rr);
555
+ }
372
556
 
373
- if (keyType === 'rsa' && modulusLength < minBitLength) {
374
- let err = new Error('RSA key too short');
375
- err.code = 'ESHORTKEY';
376
- err.rr = rr;
377
- throw err;
378
- }
557
+ if (!declaredKeyType && keyType !== 'rsa') {
558
+ // k= defaults to "rsa", an ed25519 key is only found by looking at the key data
559
+ violation(`Unknown key type (${keyType})`, 'EINVALIDTYPE', 'key-type-inferred');
560
+ }
379
561
 
380
- return {
381
- publicKey: publicKeyObj.export({
382
- type: publicKeyObj.asymmetricKeyType === 'ed25519' ? 'spki' : 'pkcs1',
383
- format: 'pem'
384
- }),
385
- rr,
386
- modulusLength
387
- };
562
+ if (strict && keyType === 'ed25519' && rawPublicKey.length !== 32) {
563
+ // RFC 8463 section 4 publishes the bare key, not a DER structure
564
+ throw keyError('Invalid ed25519 public key', 'EINVALIDVAL', rr);
565
+ }
566
+
567
+ let modulusLength = publicKeyObj.asymmetricKeyDetails.modulusLength;
568
+
569
+ if (keyType === 'rsa' && modulusLength < minBitLength) {
570
+ throw keyError('RSA key too short', 'ESHORTKEY', rr);
388
571
  }
389
572
 
390
- let err = new Error('Missing key value');
391
- err.code = 'EINVALIDVAL';
392
- throw err;
573
+ // t= flags. "y" is the testing mode, "s" forbids an i= domain that is a subdomain of d=
574
+ let flags = splitKeyList(tags.t?.value);
575
+
576
+ return {
577
+ publicKey: exportedKey,
578
+ rr,
579
+ modulusLength,
580
+ keyType,
581
+ flags,
582
+ testing: flags.includes('y'),
583
+ warnings
584
+ };
585
+ };
586
+
587
+ /**
588
+ * Creates a key lookup cache for one message. DKIM-Signatures, the ARC-Message-Signature and the
589
+ * ARC-Seals that name the same key record share one getPublicKey() call, and so one DNS query.
590
+ * Everything that changes the outcome of the lookup is part of the cache key, so a result is
591
+ * never used for a different question
592
+ *
593
+ * @param {Function} [resolver] DNS resolver, used for every lookup of the cache
594
+ * @returns {Object} { resolver, get(type, name, minBitLength, options) }, `get` takes the
595
+ * arguments of getPublicKey() without the resolver and returns the same promise for
596
+ * the same question
597
+ */
598
+ const createKeyCache = resolver => {
599
+ const cache = new Map();
600
+ return {
601
+ resolver,
602
+ get(type, name, minBitLength, options) {
603
+ options = options || {};
604
+ let key = [!!options.strict, minBitLength || '', options.hashAlgo || '', name].join(':');
605
+ let lookup = cache.get(key);
606
+ if (!lookup) {
607
+ lookup = getPublicKey(type, name, minBitLength, resolver, options);
608
+ // a prefetched lookup can fail before anything waits for it. Every caller that
609
+ // awaits the promise still gets the error
610
+ lookup.catch(() => false);
611
+ cache.set(key, lookup);
612
+ }
613
+ return lookup;
614
+ }
615
+ };
393
616
  };
394
617
 
395
618
  const getPrivateKey = privateKeyBuf => {
@@ -447,37 +670,158 @@ const fetch = url =>
447
670
  .on('error', reject);
448
671
  });
449
672
 
450
- const escapePropValue = value => {
451
- value = (value || '')
673
+ // RFC 2045 section 5.1 token: printable US-ASCII except SPACE and the tspecials ()<>@,;:\"/[]?=
674
+ const RFC2045_TOKEN = /^[!#$%&'*+\-.0-9A-Z^_`a-z{|}~]+$/;
675
+ // What the older formatter left unquoted: a token, or a token with "@" signs in it
676
+ const LEGACY_UNQUOTED = /^[!#$%&'*+\-.0-9A-Z^_`a-z{|}~@]+$/;
677
+ // RFC 5322 section 3.2.3 dot-atom-text
678
+ const DOT_ATOM_TEXT = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+(?:\.[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
679
+ // RFC 5322 section 3.2.4 quoted-string, without CFWS around it
680
+ const QUOTED_STRING = /^"(?:[^"\\\r\n]|\\[\x20-\x7e\t])*"$/;
681
+ // DOMAIN_NAME is the RFC 6376 section 3.5 domain-name, which RFC 8601 uses for pvalue
682
+
683
+ // A generated header line has to stay under the 998 character limit of RFC 5322 section
684
+ // 2.1.1, and a value with no whitespace in it can not be folded. A property value longer than
685
+ // this is left out (RFC 8601 section 2.2 lets a propspec be omitted) rather than truncated
686
+ // into a different identity, and a word of a comment is cut short, which only loses text
687
+ const MAX_HEADER_VALUE_LENGTH = 900;
688
+ const MAX_COMMENT_WORD_LENGTH = 400;
689
+
690
+ const cleanHeaderText = value =>
691
+ (value || '')
452
692
  .toString()
453
- .replace(/[\x00-\x1F]+/g, ' ')
693
+ // CTLs and DEL are not allowed anywhere in a structured field body, not even escaped
694
+ .replace(/[\x00-\x1F\x7F]+/g, ' ')
454
695
  .replace(/\s+/g, ' ')
455
696
  .trim();
456
697
 
457
- if (!/[\s\x00-\x1F\x7F-\uFFFF()<>,;:\\"/[\]?=]/.test(value)) {
698
+ const quoteString = value => `"${value.replace(/["\\]/g, c => `\\${c}`)}"`;
699
+
700
+ // A header value that is either an RFC 5322 dot-atom-text or, for anything else, a
701
+ // quoted-string, such as the key-value-pair values of Received-SPF (RFC 7208 section 9.1)
702
+ const formatDotAtomOrQuoted = value => {
703
+ value = cleanHeaderText(value);
704
+ return DOT_ATOM_TEXT.test(value) ? value : quoteString(value);
705
+ };
706
+
707
+ /**
708
+ * Formats a property value for an Authentication-Results header (RFC 8601 section 2.2):
709
+ * pvalue = value / [ [ local-part ] "@" ] domain-name, where value is a token or a
710
+ * quoted-string. Returns an empty string when there is nothing to report.
711
+ *
712
+ * @param {String} value Property value
713
+ * @param {Boolean} [strict] If true, an email identity is always written in the
714
+ * local-part@domain form that RFC 8601 section 2.2 requires, with the local-part quoted
715
+ * when it is not a dot-atom. Otherwise the output is kept as it was in older versions
716
+ * wherever that output was already valid, which quotes such identities as a whole
717
+ */
718
+ const escapePropValue = (value, strict) => {
719
+ value = cleanHeaderText(value);
720
+
721
+ if (!value) {
722
+ return '';
723
+ }
724
+
725
+ if (RFC2045_TOKEN.test(value)) {
458
726
  // return token value
459
727
  return value;
460
728
  }
461
729
 
730
+ let atPos = value.lastIndexOf('@');
731
+ if (atPos >= 0) {
732
+ let localPart = value.substring(0, atPos);
733
+ let domain = value.substring(atPos + 1);
734
+
735
+ if (DOMAIN_NAME.test(domain)) {
736
+ if (!localPart || DOT_ATOM_TEXT.test(localPart)) {
737
+ // a valid [ [ local-part ] "@" ] domain-name as it is
738
+ if (strict || LEGACY_UNQUOTED.test(value)) {
739
+ return value;
740
+ }
741
+ } else if (strict) {
742
+ return `${QUOTED_STRING.test(localPart) ? localPart : quoteString(localPart)}@${domain}`;
743
+ }
744
+ }
745
+ }
746
+
462
747
  // return quoted string with escaped quotes
463
- return `"${value.replace(/["\\]/g, c => `\\${c}`)}"`;
748
+ return quoteString(value);
464
749
  };
465
750
 
751
+ /**
752
+ * Makes a value safe to use as the content of an RFC 5322 comment. Both parentheses and the
753
+ * backslash are escaped, so the comment always stays balanced and ends where it should.
754
+ *
755
+ * @param {String} value Comment text
756
+ * @returns {String} Escaped comment text, without the parentheses around it
757
+ */
466
758
  const escapeCommentValue = value => {
467
- value = (value || '')
468
- .toString()
469
- .replace(/[\x00-\x1F]+/g, ' ')
470
- .replace(/\s+/g, ' ')
471
- .trim();
759
+ value = cleanHeaderText(value)
760
+ .split(' ')
761
+ .map(word => {
762
+ if (Buffer.byteLength(word) <= MAX_COMMENT_WORD_LENGTH) {
763
+ return word;
764
+ }
765
+ // the limit is in octets, a line of UTF-8 text is measured in octets on the wire
766
+ let shortened = '';
767
+ for (let char of word) {
768
+ if (Buffer.byteLength(shortened + char) > MAX_COMMENT_WORD_LENGTH) {
769
+ break;
770
+ }
771
+ shortened += char;
772
+ }
773
+ return shortened + '...';
774
+ })
775
+ .join(' ');
472
776
 
473
- return `${value.replace(/[\\)]/g, c => `\\${c}`)}`;
777
+ return value.replace(/[\\()]/g, c => `\\${c}`);
474
778
  };
475
779
 
476
- const formatAuthHeaderRow = (method, status) => {
780
+ /**
781
+ * Formats the authserv-id of an Authentication-Results header (RFC 8601 section 2.5). It is
782
+ * usually the host name of the MTA, which is a token, and an internationalized host name is
783
+ * converted to its A-label form. Any other value is written as a quoted-string, which RFC 8601
784
+ * allows (authserv-id = value). With strict set such a value throws instead, as a reader that
785
+ * splits the header on semicolons would still take a value such as "mx.example; dkim=pass"
786
+ * for a result
787
+ *
788
+ * @param {String} authservId Usually the host name of the MTA
789
+ * @param {Boolean} [strict] If true, throws for a value that is not a token
790
+ * @returns {String} The authserv-id
791
+ */
792
+ const formatAuthservId = (authservId, strict) => {
793
+ let value = toALabel(cleanHeaderText(authservId));
794
+
795
+ if (value && value.length <= MAX_HEADER_VALUE_LENGTH && RFC2045_TOKEN.test(value)) {
796
+ return value;
797
+ }
798
+
799
+ if (!value || value.length > MAX_HEADER_VALUE_LENGTH || strict) {
800
+ let err = new TypeError(`Invalid authserv-id ${JSON.stringify((authservId || '').toString())}, expecting a host name`);
801
+ err.code = 'EINVALIDAUTHSERVID';
802
+ throw err;
803
+ }
804
+
805
+ return quoteString(value);
806
+ };
807
+
808
+ /**
809
+ * Formats a single resinfo of an Authentication-Results header (RFC 8601 section 2.2)
810
+ *
811
+ * @param {String} method Authentication method, eg. "dkim"
812
+ * @param {Object} status Result status: result, comment, and the policy/smtp/body/header ptypes
813
+ * @param {Object} [options]
814
+ * @param {Boolean} [options.strict] Format email identities in the RFC 8601 section 2.2 form
815
+ * @returns {String} Formatted resinfo, without the leading semicolon
816
+ */
817
+ const formatAuthHeaderRow = (method, status, options) => {
477
818
  status = status || {};
819
+ options = options || {};
478
820
  let parts = [];
479
821
 
480
- parts.push(`${method}=${status.result || 'none'}`);
822
+ // the result is a Keyword (RFC 8601 section 2.2)
823
+ let result = (status.result || '').toString().replace(/[^A-Za-z0-9-]+/g, '') || 'none';
824
+ parts.push(`${method}=${result}`);
481
825
 
482
826
  if (status.underSized) {
483
827
  parts.push(`(${escapeCommentValue(`undersized signature: ${status.underSized} bytes unsigned`)})`);
@@ -493,9 +837,17 @@ const formatAuthHeaderRow = (method, status) => {
493
837
  }
494
838
 
495
839
  for (let prop of Object.keys(status[ptype])) {
496
- if (status[ptype][prop]) {
497
- parts.push(`${ptype}.${prop}=${escapePropValue(status[ptype][prop])}`);
840
+ let propValue = status[ptype][prop];
841
+ if (!propValue || !['string', 'number', 'boolean'].includes(typeof propValue)) {
842
+ continue;
498
843
  }
844
+
845
+ let escaped = escapePropValue(propValue, options.strict);
846
+ if (!escaped || Buffer.byteLength(escaped) > MAX_HEADER_VALUE_LENGTH) {
847
+ continue;
848
+ }
849
+
850
+ parts.push(`${ptype}.${prop}=${escaped}`);
499
851
  }
500
852
  }
501
853
 
@@ -524,20 +876,43 @@ const formatRelaxedLine = (line, suffix) => {
524
876
  return Buffer.from(result, 'binary');
525
877
  };
526
878
 
527
- // RFC 6376 section 3.7: when the signature header is hashed, its own b= value is treated
528
- // as empty. The tag can be preceded by WSP and, in a header that has not been unfolded
529
- // yet, by the CRLF of a fold. Nothing else is whitespace here, for the same reason it is
530
- // not in formatRelaxedLine: a byte such as 0xA0 in front of b= is content, and matching it
531
- // would strip a decoy tag and leave the real signature inside the hashed header
532
- const stripSignatureValue = line => Buffer.from(line.toString('binary').replace(/([;: \t\r\n]+b=)[^;]+/, '$1'), 'binary');
879
+ // RFC 6376 section 3.7: when the signature header is hashed, its own b= value "(including
880
+ // all surrounding whitespace)" is treated as empty. The segment emptied here has to be the
881
+ // exact one the tag-list parser (lib/parse-dkim-headers.js) takes the signature from, so the
882
+ // line is read the same way: split on the semicolons, which can not occur inside a tag value,
883
+ // and each tag name decoded as UTF-8 with the whitespace around it removed, as buildTagMap
884
+ // does. Matching a "b=" anywhere in the line instead would strip the wrong value when it
885
+ // appears inside another tag, such as a z= copy of a header or an unknown tag.
886
+ //
887
+ // The parser keeps the last value of a repeated tag. The lenient parser folds tag names to
888
+ // lower case, so there the last tag named "b" or "B" is the signature, the strict parser reads
889
+ // names case-sensitively (RFC 6376 section 3.2), so there it is the last tag named exactly "b".
890
+ const stripSignatureValue = (line, strict) => {
891
+ let str = line.toString('binary');
892
+
893
+ let colonPos = str.indexOf(':');
894
+ let prefix = colonPos >= 0 ? str.substring(0, colonPos + 1) : '';
895
+ let segments = (colonPos >= 0 ? str.substring(colonPos + 1) : str).split(';');
896
+
897
+ for (let i = segments.length - 1; i >= 0; i--) {
898
+ let eqPos = segments[i].indexOf('=');
899
+ if (eqPos < 0) {
900
+ continue;
901
+ }
902
+ let name = Buffer.from(segments[i].substring(0, eqPos), 'binary').toString().replace(/\s+/g, ' ').trim();
903
+ if ((strict ? name : name.toLowerCase()) === 'b') {
904
+ segments[i] = segments[i].substring(0, eqPos + 1);
905
+ break;
906
+ }
907
+ }
908
+
909
+ return Buffer.from(prefix + segments.join(';'), 'binary');
910
+ };
533
911
 
534
912
  const formatDomain = domain => {
535
- domain = domain.toLowerCase().trim();
536
- try {
537
- domain = punycode.toASCII(domain).toLowerCase().trim();
538
- } catch (err) {
539
- // ignore punycode errors
540
- }
913
+ // toALabel normalizes a U-label to NFC first, so this matches the d= and s= values the
914
+ // signer writes and the verifier reports for the same name
915
+ domain = toALabel(domain.toLowerCase().trim()).toLowerCase().trim();
541
916
  // the root label is not part of the name, so "example.com." and "example.com" are the same domain
542
917
  return domain.replace(/\.+$/, '');
543
918
  };
@@ -589,6 +964,22 @@ const getAlignment = (fromDomain, domainList, strict) => {
589
964
  return false;
590
965
  };
591
966
 
967
+ // The signing and the hashing algorithm of an a= value such as "rsa-sha256", in lower case.
968
+ // A value without a dash gives the same string for both
969
+ const splitAlgorithm = algorithm => {
970
+ let parts = (algorithm || '').toString().split('-');
971
+ return {
972
+ signAlgo: parts[0].toLowerCase().trim(),
973
+ hashAlgo: parts[parts.length - 1].toLowerCase().trim()
974
+ };
975
+ };
976
+
977
+ // Whether a signing and hashing algorithm pair is one this library signs and verifies with:
978
+ // rsa-sha256 and ed25519-sha256, and rsa-sha1 when `allowSha1` is set. RFC 8463 defines
979
+ // ed25519-sha256 only, "ed25519-sha1" is not an algorithm
980
+ const isSupportedAlgorithm = (signAlgo, hashAlgo, allowSha1) =>
981
+ ['rsa', 'ed25519'].includes(signAlgo) && (hashAlgo === 'sha256' || (!!allowSha1 && signAlgo === 'rsa' && hashAlgo === 'sha1'));
982
+
592
983
  const validateAlgorithm = (algorithm, strict) => {
593
984
  try {
594
985
  if (!algorithm || !/^[^-]+-[^-]+$/.test(algorithm)) {
@@ -603,7 +994,7 @@ const validateAlgorithm = (algorithm, strict) => {
603
994
  throw error;
604
995
  }
605
996
 
606
- if (!['sha256'].concat(!strict ? 'sha1' : []).includes(hashAlgo)) {
997
+ if (!isSupportedAlgorithm(signAlgo, hashAlgo, !strict)) {
607
998
  let error = new Error('Unknown hashing algorithm');
608
999
  error.hashAlgo = hashAlgo;
609
1000
  throw error;
@@ -827,6 +1218,10 @@ module.exports = {
827
1218
  writeToStream,
828
1219
  parseHeaders,
829
1220
 
1221
+ createError,
1222
+ addWarning,
1223
+ KEY_ERROR_COMMENTS,
1224
+
830
1225
  defaultDKIMFieldNames,
831
1226
  defaultARCFieldNames,
832
1227
  defaultASFieldNames,
@@ -834,19 +1229,30 @@ module.exports = {
834
1229
  getSigningHeaderLines,
835
1230
  formatSignatureHeaderLine,
836
1231
  parseDkimHeaders,
1232
+ parseTagList,
837
1233
  getPublicKey,
838
1234
  getPrivateKey,
839
1235
  formatAuthHeaderRow,
840
1236
  escapeCommentValue,
1237
+ escapePropValue,
1238
+ formatAuthservId,
1239
+ MAX_HEADER_VALUE_LENGTH,
1240
+ formatDotAtomOrQuoted,
841
1241
  fetch,
842
1242
 
843
1243
  validateAlgorithm,
1244
+ splitAlgorithm,
1245
+ isSupportedAlgorithm,
1246
+ createKeyCache,
844
1247
 
845
1248
  getAlignment,
846
1249
 
847
1250
  formatRelaxedLine,
848
1251
  stripSignatureValue,
849
1252
  formatDomain,
1253
+ toALabel,
1254
+ normalizeDomainName,
1255
+ isSameOrSubdomain,
850
1256
 
851
1257
  getPtrHostname,
852
1258