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
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const { dkim2Sign } = require('../dkim2/sign');
4
+ const { GathererStream } = require('../gatherer-stream');
5
+ const fs = require('node:fs');
6
+
7
+ const cmd = async argv => {
8
+ let source = argv.email;
9
+ let stream;
10
+
11
+ if (argv.verbose) {
12
+ console.error(`Reading email message from ${source || 'standard input'}`);
13
+ }
14
+
15
+ stream = source ? fs.createReadStream(source) : process.stdin;
16
+
17
+ let privateKeys = argv.privateKey || [];
18
+ let selectors = argv.selector || [];
19
+ if (privateKeys.length !== selectors.length) {
20
+ let err = new Error('Every --private-key needs a --selector, in the same order');
21
+ throw err;
22
+ }
23
+
24
+ let gatherer = new GathererStream({ gather: !argv.headersOnly });
25
+ stream.pipe(gatherer);
26
+ stream.on('error', err => gatherer.emit('error', err));
27
+
28
+ let signatureData = [];
29
+ for (let i = 0; i < privateKeys.length; i++) {
30
+ signatureData.push({ selector: selectors[i], privateKey: await fs.promises.readFile(privateKeys[i], 'utf-8') });
31
+ }
32
+
33
+ let options = {
34
+ signingDomain: argv.domain,
35
+ signatureData,
36
+ signTime: argv.time ? new Date(argv.time * 1000) : new Date(),
37
+ flags: argv.flag,
38
+ nonce: argv.nonce
39
+ };
40
+
41
+ if (argv.hash?.length) {
42
+ options.hashAlgorithms = argv.hash;
43
+ }
44
+
45
+ if (argv.nextDomain) {
46
+ options.nextDomain = argv.nextDomain;
47
+ } else {
48
+ options.mailFrom = argv.mailFrom;
49
+ options.rcptTo = argv.rcptTo;
50
+ }
51
+
52
+ if (argv.recipe) {
53
+ options.recipe = JSON.parse(await fs.promises.readFile(argv.recipe, 'utf-8'));
54
+ }
55
+
56
+ if (argv.verbose) {
57
+ console.error(`Signing domain: ${options.signingDomain}`);
58
+ console.error(`Selectors: ${selectors.join(', ')}`);
59
+ console.error(`Signing time: ${options.signTime.toISOString()}`);
60
+ console.error('--------');
61
+ }
62
+
63
+ let signResult = await dkim2Sign(gatherer, options);
64
+
65
+ process.stdout.write(signResult.signatures);
66
+ if (!argv.headersOnly) {
67
+ // print the full message as well
68
+ await new Promise((resolve, reject) => {
69
+ let msgStream = gatherer.replay();
70
+ msgStream.pipe(process.stdout, { end: false });
71
+ msgStream.on('end', resolve);
72
+ msgStream.on('error', reject);
73
+ });
74
+ }
75
+ };
76
+
77
+ module.exports = cmd;
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const { dkim2Verify } = require('../dkim2/verify');
4
+ const { createResolver } = require('./resolver');
5
+ const fs = require('node:fs');
6
+
7
+ const cmd = async argv => {
8
+ let source = argv.email;
9
+
10
+ if (argv.verbose) {
11
+ console.error(`Reading email message from ${source || 'standard input'}`);
12
+ }
13
+
14
+ let stream = source ? fs.createReadStream(source) : process.stdin;
15
+
16
+ let opts = {
17
+ resolver: await createResolver(argv),
18
+ rcptTo: argv.rcptTo
19
+ };
20
+
21
+ if (typeof argv.mailFrom === 'string') {
22
+ opts.mailFrom = argv.mailFrom;
23
+ }
24
+
25
+ if (typeof argv.time === 'number') {
26
+ opts.curTime = new Date(argv.time * 1000);
27
+ }
28
+
29
+ if (typeof argv.maxAge === 'number') {
30
+ // 0 turns the expiration check off
31
+ opts.maxSignatureAge = argv.maxAge || false;
32
+ }
33
+
34
+ if (typeof argv.maxFuture === 'number') {
35
+ // 0 turns the check off, like --max-age
36
+ opts.maxFutureTime = argv.maxFuture || false;
37
+ }
38
+
39
+ if (typeof argv.maxInstances === 'number') {
40
+ opts.maxInstances = argv.maxInstances;
41
+ }
42
+
43
+ let result = await dkim2Verify(stream, opts);
44
+
45
+ if (argv.verbose) {
46
+ for (let warning of result.status.warnings || []) {
47
+ console.error(`Warning: ${warning}`);
48
+ }
49
+ }
50
+
51
+ if (argv.headersOnly) {
52
+ process.stdout.write(result.info + '\n');
53
+ return;
54
+ }
55
+
56
+ process.stdout.write(JSON.stringify(result, false, 2) + '\n');
57
+ };
58
+
59
+ module.exports = cmd;
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { authenticate } = require('../mailauth');
4
4
  const fs = require('node:fs');
5
- const { resolve } = require('node:dns').promises;
5
+ const { createResolver } = require('./resolver');
6
6
 
7
7
  const cmd = async argv => {
8
8
  let source = argv.email;
@@ -27,18 +27,23 @@ const cmd = async argv => {
27
27
  const opts = {
28
28
  trustReceived: true,
29
29
  strict: !!argv.strict,
30
- rejectRsaSha1: !!argv.rejectRsaSha1
30
+ rejectRsaSha1: !!argv.rejectRsaSha1,
31
+ dkim2: !!argv.dkim2
31
32
  };
32
33
 
34
+ if (argv.rcptTo) {
35
+ opts.rcptTo = argv.rcptTo;
36
+ }
37
+
33
38
  if (argv.clientIp) {
34
39
  opts.ip = argv.clientIp;
35
40
  }
36
41
 
37
- if (argv.maxLookups) {
42
+ if (typeof argv.maxLookups === 'number') {
38
43
  opts.maxResolveCount = argv.maxLookups;
39
44
  }
40
45
 
41
- if (argv.maxVoidLookups) {
46
+ if (typeof argv.maxVoidLookups === 'number') {
42
47
  opts.maxVoidCount = argv.maxVoidLookups;
43
48
  }
44
49
 
@@ -48,37 +53,7 @@ const cmd = async argv => {
48
53
  }
49
54
  }
50
55
 
51
- if (argv.dnsCache) {
52
- let dnsCache = JSON.parse(await fs.promises.readFile(argv.dnsCache, 'utf-8'));
53
-
54
- opts.resolver = async (name, rr) => {
55
- let match = dnsCache?.[name]?.[rr];
56
-
57
- if (argv.verbose) {
58
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'} (using cache)`);
59
- }
60
-
61
- if (!match) {
62
- let err = new Error('Error');
63
- err.code = 'ENOTFOUND';
64
- throw err;
65
- }
66
-
67
- return match;
68
- };
69
- } else if (argv.verbose) {
70
- opts.resolver = async (name, rr) => {
71
- let match;
72
- try {
73
- match = await resolve(name, rr);
74
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'}`);
75
- return match;
76
- } catch (err) {
77
- console.error(`DNS query for ${rr} ${name}: ${err.message}${err.code ? ` [${err.code}]` : ''}`);
78
- throw err;
79
- }
80
- };
81
- }
56
+ opts.resolver = await createResolver(argv);
82
57
 
83
58
  let result = await authenticate(stream, opts);
84
59
  process.stdout.write(JSON.stringify(result, false, 2) + '\n');
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { resolve } = require('node:dns').promises;
5
+
6
+ /**
7
+ * The DNS resolver for a command: answers from the --dns-cache file, or real DNS queries that are
8
+ * logged with --verbose. Undefined when neither is set, so the library uses its default resolver
9
+ *
10
+ * @param {Object} argv Command options
11
+ * @returns {Function|undefined} async (name, rrtype) resolver
12
+ */
13
+ const createResolver = async argv => {
14
+ if (argv.dnsCache) {
15
+ let dnsCache = JSON.parse(await fs.promises.readFile(argv.dnsCache, 'utf-8'));
16
+
17
+ return async (name, rr) => {
18
+ let match = dnsCache?.[name]?.[rr];
19
+
20
+ if (argv.verbose) {
21
+ console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'} (using cache)`);
22
+ }
23
+
24
+ if (!match) {
25
+ let err = new Error('Error');
26
+ err.code = 'ENOTFOUND';
27
+ throw err;
28
+ }
29
+
30
+ return match;
31
+ };
32
+ }
33
+
34
+ if (argv.verbose) {
35
+ return async (name, rr) => {
36
+ let match;
37
+ try {
38
+ match = await resolve(name, rr);
39
+ console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'}`);
40
+ return match;
41
+ } catch (err) {
42
+ console.error(`DNS query for ${rr} ${name}: ${err.message}${err.code ? ` [${err.code}]` : ''}`);
43
+ throw err;
44
+ }
45
+ };
46
+ }
47
+
48
+ return undefined;
49
+ };
50
+
51
+ module.exports = { createResolver };
@@ -5,7 +5,7 @@ const { authenticate } = require('../mailauth');
5
5
  const { createSeal } = require('../arc');
6
6
  const fs = require('node:fs');
7
7
  const { GathererStream } = require('../gatherer-stream');
8
- const { resolve } = require('node:dns').promises;
8
+ const { createResolver } = require('./resolver');
9
9
 
10
10
  const writeMessageBody = async gatherer =>
11
11
  new Promise((resolve, reject) => {
@@ -75,6 +75,9 @@ const cmd = async argv => {
75
75
  if (argv.authResults || argv.authResultsFile) {
76
76
  // Seal-only mode: embed the caller-provided Authentication-Results value without authenticating the message
77
77
  let authResults = argv.authResultsFile ? (await fs.promises.readFile(argv.authResultsFile, 'utf-8')).trim() : argv.authResults;
78
+ // a folded value from a file usually has LF line endings, header folding needs CRLF.
79
+ // createSeal refuses any line break that does not continue the header with whitespace
80
+ authResults = authResults.replace(/\r?\n/g, '\r\n');
78
81
 
79
82
  let sealOpts = Object.assign({}, signatureOpts, {
80
83
  authResults,
@@ -95,7 +98,7 @@ const cmd = async argv => {
95
98
  console.error('--------');
96
99
  }
97
100
 
98
- let { headers, errors, warnings } = await createSeal(gatherer, { seal: sealOpts });
101
+ let { headers, errors, warnings, instance } = await createSeal(gatherer, { seal: sealOpts });
99
102
 
100
103
  if (!headers.length) {
101
104
  // nothing was sealed, say why instead of printing the message as if it had been
@@ -103,20 +106,19 @@ const cmd = async argv => {
103
106
  throw err;
104
107
  }
105
108
 
106
- // createSeal writes the instance it actually used back into the seal options
107
- if (sealOpts.i > 1 && !argv.cv) {
108
- console.error(`warning: sealed with i=${sealOpts.i} and cv=none; a chain with i>1 only validates when cv=pass (use --cv pass)`);
109
+ if (instance > 1 && !argv.cv) {
110
+ console.error(`warning: sealed with i=${instance} and cv=none; a chain with i>1 only validates when cv=pass (use --cv pass)`);
109
111
  }
110
112
 
111
113
  for (let warning of warnings || []) {
112
114
  switch (warning) {
113
115
  case 'arc-instance-gap':
114
- console.error(`warning: sealed with i=${sealOpts.i}, which does not follow the existing ARC chain`);
116
+ console.error(`warning: sealed with i=${instance}, which does not follow the existing ARC chain`);
115
117
  break;
116
118
  case 'arc-cv-instance':
117
119
  // covered by the message above for the default cv
118
120
  if (argv.cv) {
119
- console.error(`warning: cv=${argv.cv} is not valid for i=${sealOpts.i} (i=1 takes cv=none, later sets take cv=pass or cv=fail)`);
121
+ console.error(`warning: cv=${argv.cv} is not valid for i=${instance} (i=1 takes cv=none, later sets take cv=pass or cv=fail)`);
120
122
  }
121
123
  break;
122
124
  default:
@@ -159,37 +161,7 @@ const cmd = async argv => {
159
161
  }
160
162
  }
161
163
 
162
- if (argv.dnsCache) {
163
- let dnsCache = JSON.parse(await fs.promises.readFile(argv.dnsCache, 'utf-8'));
164
-
165
- opts.resolver = async (name, rr) => {
166
- let match = dnsCache?.[name]?.[rr];
167
-
168
- if (argv.verbose) {
169
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'} (using cache)`);
170
- }
171
-
172
- if (!match) {
173
- let err = new Error('Error');
174
- err.code = 'ENOTFOUND';
175
- throw err;
176
- }
177
-
178
- return match;
179
- };
180
- } else if (argv.verbose) {
181
- opts.resolver = async (name, rr) => {
182
- let match;
183
- try {
184
- match = await resolve(name, rr);
185
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'}`);
186
- return match;
187
- } catch (err) {
188
- console.error(`DNS query for ${rr} ${name}: ${err.message}${err.code ? ` [${err.code}]` : ''}`);
189
- throw err;
190
- }
191
- };
192
- }
164
+ opts.resolver = await createResolver(argv);
193
165
 
194
166
  let result = await authenticate(gatherer, opts);
195
167
 
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spf } = require('../spf');
4
- const fs = require('node:fs');
5
- const { resolve } = require('node:dns').promises;
4
+ const { createResolver } = require('./resolver');
6
5
 
7
6
  const cmd = async argv => {
8
7
  let address = argv.sender;
@@ -24,11 +23,11 @@ const cmd = async argv => {
24
23
  opts.ip = argv.clientIp;
25
24
  }
26
25
 
27
- if (argv.maxLookups) {
26
+ if (typeof argv.maxLookups === 'number') {
28
27
  opts.maxResolveCount = argv.maxLookups;
29
28
  }
30
29
 
31
- if (argv.maxVoidLookups) {
30
+ if (typeof argv.maxVoidLookups === 'number') {
32
31
  opts.maxVoidCount = argv.maxVoidLookups;
33
32
  }
34
33
 
@@ -46,37 +45,7 @@ const cmd = async argv => {
46
45
  }
47
46
  }
48
47
 
49
- if (argv.dnsCache) {
50
- let dnsCache = JSON.parse(await fs.promises.readFile(argv.dnsCache, 'utf-8'));
51
-
52
- opts.resolver = async (name, rr) => {
53
- let match = dnsCache?.[name]?.[rr];
54
-
55
- if (argv.verbose) {
56
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'} (using cache)`);
57
- }
58
-
59
- if (!match) {
60
- let err = new Error('Error');
61
- err.code = 'ENOTFOUND';
62
- throw err;
63
- }
64
-
65
- return match;
66
- };
67
- } else if (argv.verbose) {
68
- opts.resolver = async (name, rr) => {
69
- let match;
70
- try {
71
- match = await resolve(name, rr);
72
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'}`);
73
- return match;
74
- } catch (err) {
75
- console.error(`DNS query for ${rr} ${name}: ${err.message}${err.code ? ` [${err.code}]` : ''}`);
76
- throw err;
77
- }
78
- };
79
- }
48
+ opts.resolver = await createResolver(argv);
80
49
 
81
50
  let result;
82
51
  try {
@@ -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,