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
@@ -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) => {
package/lib/dkim/sign.js CHANGED
@@ -25,7 +25,7 @@ const dkimSign = async (input, options) => {
25
25
  class DkimSignStream extends Transform {
26
26
  constructor(options) {
27
27
  super(options);
28
- this.signer = new DkimSigner(options);
28
+ this.signer = this.createSigner(options);
29
29
 
30
30
  this.chunks = [];
31
31
  this.chunklen = 0;
@@ -43,6 +43,11 @@ class DkimSignStream extends Transform {
43
43
  });
44
44
  }
45
45
 
46
+ // the signer that collects the signature headers, a subclass can sign with a different protocol
47
+ createSigner(options) {
48
+ return new DkimSigner(options);
49
+ }
50
+
46
51
  finishStream() {
47
52
  if (this.finished || !this.finishCb) {
48
53
  return;
@@ -90,4 +95,4 @@ class DkimSignStream extends Transform {
90
95
  }
91
96
  }
92
97
 
93
- module.exports = { dkimSign, DkimSignStream };
98
+ module.exports = { dkimSign, DkimSignStream, formatSignatures };
@@ -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
  };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // The part of message parsing the DKIM2 signer and verifier share: the body is hashed with every
4
+ // algorithm asked for, and kept when a body Recipe has to copy lines from it
5
+
6
+ const { Buffer } = require('node:buffer');
7
+ const { MessageParser } = require('../dkim/message-parser');
8
+ const { SimpleHash } = require('../dkim/body/simple');
9
+ const { MessageState, splitBodyLines } = require('./message-state');
10
+
11
+ class Dkim2Parser extends MessageParser {
12
+ constructor() {
13
+ super();
14
+ this.bodyHashers = new Map();
15
+ this.bodyChunks = null;
16
+ }
17
+
18
+ // hashes the body with `algorithm` too, called from messageHeaders()
19
+ hashBodyWith(algorithm) {
20
+ if (!this.bodyHashers.has(algorithm)) {
21
+ this.bodyHashers.set(algorithm, new SimpleHash(algorithm));
22
+ }
23
+ }
24
+
25
+ // keeps the body in memory, called from messageHeaders()
26
+ keepBody() {
27
+ this.bodyChunks = this.bodyChunks || [];
28
+ }
29
+
30
+ async nextChunk(chunk) {
31
+ for (let hasher of this.bodyHashers.values()) {
32
+ hasher.update(chunk);
33
+ }
34
+ if (this.bodyChunks) {
35
+ this.bodyChunks.push(chunk);
36
+ }
37
+ }
38
+
39
+ // The message as it is, once the whole body has been read
40
+ currentState() {
41
+ let hashes = new Map();
42
+ for (let [algorithm, hasher] of this.bodyHashers) {
43
+ hashes.set(algorithm, hasher.digest('base64'));
44
+ }
45
+ let lines = this.bodyChunks ? splitBodyLines(Buffer.concat(this.bodyChunks)) : null;
46
+ // the lines hold the body from now on
47
+ this.bodyChunks = null;
48
+ return MessageState.fromMessage(this.headers.parsed, { lines, hashes });
49
+ }
50
+ }
51
+
52
+ module.exports = { Dkim2Parser };
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+
3
+ // DKIM2 signer (draft-ietf-dkim-dkim2-spec-06 section 9)
4
+
5
+ const crypto = require('node:crypto');
6
+ const { getPrivateKey, getCurTime, isSameOrSubdomain } = require('../tools');
7
+ const {
8
+ MI_KEY,
9
+ SIG_KEY,
10
+ HASH_ALGORITHMS,
11
+ NONCE,
12
+ TEXTSTRING,
13
+ isDomain,
14
+ parseMessageInstance,
15
+ parseSignature,
16
+ canonicalSignatureField,
17
+ formatMessageInstance,
18
+ formatSignature,
19
+ parsePath,
20
+ continuesCustody,
21
+ buildSignatureInput
22
+ } = require('./fields');
23
+ const { parseRecipe } = require('./recipe');
24
+ const { Dkim2Parser } = require('./dkim2-parser');
25
+ const { ALGORITHM_KEY_TYPES, rsaKeyProblem, signatureData } = require('./key');
26
+
27
+ // the signing algorithm of each key type
28
+ const KEY_ALGORITHMS = new Map(Array.from(ALGORITHM_KEY_TYPES, ([algorithm, keyType]) => [keyType, algorithm]));
29
+
30
+ const signError = (message, code) => {
31
+ let err = new Error(message);
32
+ err.code = code || 'EDKIM2SIGN';
33
+ return err;
34
+ };
35
+
36
+ // Converts an address to an RFC 5321 path with the angle brackets (sections 8.5 and 8.6)
37
+ const toPath = (value, allowNull) => {
38
+ let path = String(value ?? '').trim();
39
+ if (path && !/^<.*>$/.test(path)) {
40
+ path = `<${path}>`;
41
+ }
42
+ if (!path || path === '<>') {
43
+ if (!allowNull) {
44
+ throw signError('A RCPT TO address can not be empty', 'EINVALIDPATH');
45
+ }
46
+ return '<>';
47
+ }
48
+ if (!/^<[^<>\r\n]+>$/.test(path)) {
49
+ throw signError(`Invalid SMTP path ${JSON.stringify(path)}`, 'EINVALIDPATH');
50
+ }
51
+ return path;
52
+ };
53
+
54
+ // Section 3: the signing algorithm follows the key type
55
+ const loadKey = ({ selector, privateKey, algorithm }) => {
56
+ if (!isDomain(selector)) {
57
+ throw signError(`Invalid selector ${JSON.stringify(selector)}`, 'EINVALIDSELECTOR');
58
+ }
59
+
60
+ let keyObject = getPrivateKey(privateKey);
61
+ let keyAlgorithm = KEY_ALGORITHMS.get(keyObject.asymmetricKeyType);
62
+ if (!keyAlgorithm) {
63
+ throw signError(`Unsupported key type ${keyObject.asymmetricKeyType}`, 'EINVALIDALGO');
64
+ }
65
+ if (algorithm && algorithm.toLowerCase() !== keyAlgorithm) {
66
+ throw signError(`Algorithm ${algorithm} does not match the ${keyObject.asymmetricKeyType} key of selector ${selector}`, 'EINVALIDALGO');
67
+ }
68
+
69
+ // section 3.2: RSA keys of at least 1024 bits with the public exponent 65537
70
+ let problem = keyObject.asymmetricKeyType === 'rsa' && rsaKeyProblem(keyObject);
71
+ if (problem) {
72
+ throw signError(`The RSA key of selector ${selector} ${problem}`, problem === 'is too short' ? 'ESHORTKEY' : 'EINVALIDKEY');
73
+ }
74
+
75
+ return { selector: selector.toLowerCase(), algorithm: keyAlgorithm, keyObject };
76
+ };
77
+
78
+ const createSignature = (algorithm, input, keyObject) => crypto.sign(...signatureData(algorithm, input), keyObject);
79
+
80
+ // Validates the signing options and puts them into the form the signer uses
81
+ const normalizeOptions = options => {
82
+ options = options || {};
83
+
84
+ let signingDomain = (options.signingDomain || '').toString().trim().toLowerCase();
85
+ if (!isDomain(signingDomain)) {
86
+ throw signError(`Invalid signing domain ${JSON.stringify(options.signingDomain)}`, 'EINVALIDDOMAIN');
87
+ }
88
+
89
+ let keyList =
90
+ options.signatureData || (options.privateKey ? [{ selector: options.selector, privateKey: options.privateKey, algorithm: options.algorithm }] : []);
91
+ if (!keyList.length) {
92
+ throw signError('No signing keys provided', 'ENOKEY');
93
+ }
94
+ let keys = keyList.map(loadKey);
95
+
96
+ // section 8.9: no selector twice, at most two signatures with the same algorithm
97
+ let selectors = new Set();
98
+ let algorithmCounts = new Map();
99
+ for (let { selector, algorithm } of keys) {
100
+ if (selectors.has(selector)) {
101
+ throw signError(`Selector ${selector} is listed more than once`, 'EINVALIDSELECTOR');
102
+ }
103
+ selectors.add(selector);
104
+ algorithmCounts.set(algorithm, (algorithmCounts.get(algorithm) || 0) + 1);
105
+ if (algorithmCounts.get(algorithm) > 2) {
106
+ throw signError(`More than two signatures use ${algorithm}`, 'EINVALIDALGO');
107
+ }
108
+ }
109
+
110
+ let result = { signingDomain, keys };
111
+
112
+ if (options.nextDomain) {
113
+ // section 8.7: nd= replaces both mf= and rt=
114
+ if (options.mailFrom !== undefined || options.rcptTo !== undefined) {
115
+ throw signError('nextDomain can not be used together with mailFrom or rcptTo', 'EINVALIDOPTS');
116
+ }
117
+ result.nextDomain = options.nextDomain.toString().trim().toLowerCase();
118
+ if (!isDomain(result.nextDomain)) {
119
+ throw signError(`Invalid next domain ${JSON.stringify(options.nextDomain)}`, 'EINVALIDDOMAIN');
120
+ }
121
+ } else {
122
+ // section 9.2: the envelope MUST be available to the signer
123
+ if (options.mailFrom === undefined || options.rcptTo === undefined) {
124
+ throw signError('mailFrom and rcptTo are required, unless nextDomain is set', 'EINVALIDOPTS');
125
+ }
126
+ result.mailFrom = toPath(options.mailFrom, true);
127
+ result.rcptTo = [].concat(options.rcptTo).map(value => toPath(value, false));
128
+ if (!result.rcptTo.length) {
129
+ throw signError('rcptTo needs at least one address', 'EINVALIDOPTS');
130
+ }
131
+
132
+ // section 8.8: the d= domain is the MAIL FROM domain or a parent of it
133
+ if (result.mailFrom !== '<>' && !isSameOrSubdomain(parsePath(result.mailFrom).domain, signingDomain)) {
134
+ throw signError(`The MAIL FROM domain does not match the signing domain ${signingDomain}`, 'EINVALIDDOMAIN');
135
+ }
136
+ }
137
+
138
+ if (options.nonce !== undefined) {
139
+ result.nonce = options.nonce.toString();
140
+ if (!NONCE.test(result.nonce)) {
141
+ throw signError('A nonce is at most 64 printable ASCII characters with no semicolon', 'EINVALIDOPTS');
142
+ }
143
+ }
144
+
145
+ result.flags = [].concat(options.flags || []).map(flag => flag.toString());
146
+ for (let flag of result.flags) {
147
+ if (!TEXTSTRING.test(flag)) {
148
+ throw signError(`Invalid flag ${JSON.stringify(flag)}`, 'EINVALIDOPTS');
149
+ }
150
+ }
151
+
152
+ result.hashAlgorithms = Array.from(new Set([].concat(options.hashAlgorithms || ['sha256']).map(algorithm => algorithm.toString().toLowerCase())));
153
+ for (let algorithm of result.hashAlgorithms) {
154
+ if (!HASH_ALGORITHMS.has(algorithm)) {
155
+ throw signError(`Unsupported hash algorithm ${algorithm}`, 'EINVALIDALGO');
156
+ }
157
+ }
158
+
159
+ if (options.recipe !== undefined && options.recipe !== null) {
160
+ let parsed = parseRecipe(JSON.stringify(options.recipe));
161
+ if (parsed.error) {
162
+ throw signError(`Invalid recipe: ${parsed.error}`, 'EINVALIDRECIPE');
163
+ }
164
+ result.recipe = options.recipe;
165
+ result.parsedRecipe = parsed.value;
166
+ }
167
+
168
+ // section 8.4, the time is read once so that the signed and the emitted t= are the same
169
+ result.t = Math.floor(getCurTime(options.signTime).getTime() / 1000);
170
+
171
+ return result;
172
+ };
173
+
174
+ // Reads the DKIM2 header fields that are already on the message. They have to be valid and
175
+ // numbered without gaps, otherwise the next numbers are not known
176
+ const readChain = (rows, key, parser, label, field) => {
177
+ let map = new Map();
178
+ for (let row of rows) {
179
+ if (row.key !== key) {
180
+ continue;
181
+ }
182
+ let parsed = parser(row.line);
183
+ if (parsed.error || map.has(parsed[field])) {
184
+ throw signError(`Can not sign a message with an invalid ${label} header field (${parsed.error || 'duplicate'})`, 'EINVALIDCHAIN');
185
+ }
186
+ map.set(parsed[field], Object.assign({ line: row.line }, parsed));
187
+ }
188
+ for (let pos = 1; pos <= map.size; pos++) {
189
+ if (!map.has(pos)) {
190
+ throw signError(`Can not sign a message where ${label} ${field}=${pos} is missing`, 'EINVALIDCHAIN');
191
+ }
192
+ }
193
+ return map;
194
+ };
195
+
196
+ class Dkim2Signer extends Dkim2Parser {
197
+ constructor(options) {
198
+ super();
199
+ this.options = normalizeOptions(options);
200
+
201
+ // the header fields to put on top of the message, DKIM2-Signature first
202
+ this.signatureHeaders = [];
203
+ }
204
+
205
+ async messageHeaders(headers) {
206
+ this.instances = readChain(headers.parsed, MI_KEY, parseMessageInstance, 'Message-Instance', 'm');
207
+ this.signatures = readChain(headers.parsed, SIG_KEY, parseSignature, 'DKIM2-Signature', 'i');
208
+
209
+ this.options.hashAlgorithms.forEach(algorithm => this.hashBodyWith(algorithm));
210
+ this.topInstance = this.instances.get(this.instances.size);
211
+ if (this.topInstance) {
212
+ // the hashes of the top instance are compared with the message as it is now
213
+ for (let { algorithm } of this.topInstance.hashes) {
214
+ if (HASH_ALGORITHMS.has(algorithm)) {
215
+ this.hashBodyWith(algorithm);
216
+ }
217
+ }
218
+ if (Array.isArray(this.options.parsedRecipe?.b)) {
219
+ // the recipe is checked against the previous instance, which needs the body lines
220
+ this.keepBody();
221
+ }
222
+ }
223
+ }
224
+
225
+ // Section 9.1: a new Message-Instance when the message has no instance yet, or when it has
226
+ // changed since the top one, with a Recipe that recreates the top one
227
+ buildInstance(current) {
228
+ let options = this.options;
229
+ let hashes = options.hashAlgorithms.map(algorithm => ({ algorithm, headerHash: current.headerHash(algorithm), bodyHash: current.bodyHash(algorithm) }));
230
+
231
+ if (!this.topInstance) {
232
+ return { m: 1, hashes, recipe: options.recipe };
233
+ }
234
+
235
+ let top = this.topInstance;
236
+ let comparable = top.hashes.filter(hash => HASH_ALGORITHMS.has(hash.algorithm));
237
+ if (!comparable.length) {
238
+ throw signError(`Message-Instance m=${top.m} has no supported hash algorithm`, 'EINVALIDCHAIN');
239
+ }
240
+
241
+ let changed = comparable.some(hash => current.headerHash(hash.algorithm) !== hash.headerHash || current.bodyHash(hash.algorithm) !== hash.bodyHash);
242
+ if (!changed) {
243
+ return null;
244
+ }
245
+
246
+ if (!options.parsedRecipe) {
247
+ throw signError(`The message has changed since Message-Instance m=${top.m}, a recipe is required`, 'ENORECIPE');
248
+ }
249
+
250
+ let previous;
251
+ try {
252
+ previous = current.previous(options.parsedRecipe);
253
+ } catch (err) {
254
+ throw signError(`Invalid recipe: ${err.message}`, 'EINVALIDRECIPE');
255
+ }
256
+
257
+ for (let { algorithm, headerHash, bodyHash } of comparable) {
258
+ let previousBodyHash = previous.bodyHash(algorithm);
259
+ // a null body Recipe declares that the previous body can not be recreated
260
+ if (previous.headerHash(algorithm) !== headerHash || (previousBodyHash !== null && previousBodyHash !== bodyHash)) {
261
+ throw signError(`The recipe does not recreate Message-Instance m=${top.m}`, 'EINVALIDRECIPE');
262
+ }
263
+ }
264
+
265
+ return { m: top.m + 1, hashes, recipe: options.recipe };
266
+ }
267
+
268
+ // Section 9.3: the new signature continues the chain of custody of the previous one
269
+ checkCustody() {
270
+ let options = this.options;
271
+ let previous = this.signatures.get(this.signatures.size);
272
+ if (!previous) {
273
+ return;
274
+ }
275
+
276
+ if (previous.nextDomain) {
277
+ if (previous.nextDomain !== options.signingDomain) {
278
+ throw signError(`DKIM2-Signature i=${previous.i} requires the next signing domain to be ${previous.nextDomain}`, 'ECUSTODY');
279
+ }
280
+ return;
281
+ }
282
+
283
+ if (!continuesCustody(previous, options.mailFrom, options.signingDomain)) {
284
+ throw signError(`The signature would break the chain of custody, it does not match a RCPT TO of DKIM2-Signature i=${previous.i}`, 'ECUSTODY');
285
+ }
286
+ }
287
+
288
+ async finalChunk() {
289
+ let options = this.options;
290
+
291
+ let current = this.currentState();
292
+
293
+ let instance = this.buildInstance(current);
294
+ this.checkCustody();
295
+
296
+ let instanceHeader = instance ? formatMessageInstance(instance) : null;
297
+ let signature = {
298
+ i: this.signatures.size + 1,
299
+ m: instance ? instance.m : this.instances.size,
300
+ t: options.t,
301
+ signingDomain: options.signingDomain,
302
+ mailFrom: options.mailFrom,
303
+ rcptTo: options.rcptTo,
304
+ nextDomain: options.nextDomain,
305
+ nonce: options.nonce,
306
+ flags: options.flags,
307
+ signatures: options.keys.map(({ selector, algorithm }) => ({ selector, algorithm, signature: '' }))
308
+ };
309
+
310
+ // section 9.6: every Message-Instance by m=, every DKIM2-Signature by i=, and the new
311
+ // signature with empty signature values
312
+ let inOrder = map => Array.from({ length: map.size }, (v, index) => map.get(index + 1).line);
313
+ let instanceLines = inOrder(this.instances).concat(instanceHeader || []);
314
+ let signatureLines = inOrder(this.signatures);
315
+ let unsignedHeader = formatSignature(signature);
316
+ let input = buildSignatureInput(instanceLines, signatureLines, unsignedHeader);
317
+
318
+ options.keys.forEach(({ algorithm, keyObject }, index) => {
319
+ signature.signatures[index].signature = createSignature(algorithm, input, keyObject).toString('base64');
320
+ });
321
+
322
+ let signatureHeader = formatSignature(signature);
323
+ if (!canonicalSignatureField(signatureHeader, true).equals(canonicalSignatureField(unsignedHeader, true))) {
324
+ // the emitted header has to be the one that was signed
325
+ throw signError('The signed and the emitted DKIM2-Signature differ', 'EINTERNAL');
326
+ }
327
+
328
+ this.signatureHeaders = [signatureHeader].concat(instanceHeader || []);
329
+ this.i = signature.i;
330
+ this.m = signature.m;
331
+ }
332
+ }
333
+
334
+ module.exports = { Dkim2Signer };