mailauth 5.0.3 → 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 (48) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +117 -41
  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 +82 -31
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +499 -45
  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 +267 -53
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/index.js +2 -1
  36. package/lib/dmarc/verify.js +205 -53
  37. package/lib/mailauth.js +86 -40
  38. package/lib/mta-sts.d.ts +77 -11
  39. package/lib/mta-sts.js +497 -182
  40. package/lib/parse-dkim-headers.js +376 -191
  41. package/lib/parse-received.js +104 -32
  42. package/lib/spf/index.d.ts +37 -1
  43. package/lib/spf/index.js +260 -28
  44. package/lib/spf/macro.js +50 -13
  45. package/lib/spf/spf-verify.js +440 -204
  46. package/lib/spf/syntax.js +366 -0
  47. package/lib/tools.js +524 -118
  48. package/package.json +12 -12
@@ -1,7 +1,22 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { getSigningHeaderLines, getPublicKey, parseDkimHeaders, formatAuthHeaderRow, getAlignment, getCurTime } = require('../../lib/tools');
4
+ const {
5
+ getSigningHeaderLines,
6
+ parseDkimHeaders,
7
+ formatAuthHeaderRow,
8
+ getAlignment,
9
+ getCurTime,
10
+ toALabel,
11
+ normalizeDomainName,
12
+ isSameOrSubdomain,
13
+ addWarning,
14
+ KEY_ERROR_COMMENTS,
15
+ splitAlgorithm,
16
+ isSupportedAlgorithm,
17
+ createKeyCache
18
+ } = require('../../lib/tools');
19
+ const { DOMAIN_NAME, SELECTOR, TIMESTAMP, BODY_LENGTH } = require('./syntax');
5
20
  const { MessageParser } = require('./message-parser');
6
21
  const { dkimBody } = require('./body');
7
22
  const { generateCanonicalizedHeader } = require('./header');
@@ -10,6 +25,31 @@ const addressparser = require('nodemailer/lib/addressparser');
10
25
  const crypto = require('node:crypto');
11
26
  const libmime = require('libmime');
12
27
 
28
+ // RFC 6376 section 3.5 syntax, used by the strict mode
29
+ // sig-a-tag-alg = sig-a-tag-k "-" sig-a-tag-h, both ALPHA *(ALPHA / DIGIT)
30
+ 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]|\\.)*")$/;
33
+
34
+ // Required tags of a DKIM-Signature (RFC 6376 section 3.5)
35
+ const REQUIRED_DKIM_TAGS = ['v', 'a', 'b', 'bh', 'd', 'h', 's'];
36
+
37
+ // Decodes dkim-quoted-printable (RFC 6376 section 2.11), used by the i= tag. FWS is not part
38
+ // of the value
39
+ const decodeDkimQuotedPrintable = value => {
40
+ let bytes = Buffer.from((value || '').toString().replace(/[ \t\r\n]+/g, ''), 'utf8');
41
+ let decoded = [];
42
+ for (let i = 0; i < bytes.length; i++) {
43
+ if (bytes[i] === 0x3d && i + 2 < bytes.length && /^[0-9A-Fa-f]{2}$/.test(bytes.subarray(i + 1, i + 3).toString())) {
44
+ decoded.push(parseInt(bytes.subarray(i + 1, i + 3).toString(), 16));
45
+ i += 2;
46
+ } else {
47
+ decoded.push(bytes[i]);
48
+ }
49
+ }
50
+ return Buffer.from(decoded).toString('utf8');
51
+ };
52
+
13
53
  class DkimVerifier extends MessageParser {
14
54
  constructor(options) {
15
55
  super();
@@ -18,6 +58,9 @@ class DkimVerifier extends MessageParser {
18
58
  this.resolver = this.options.resolver;
19
59
  this.minBitLength = this.options.minBitLength;
20
60
 
61
+ // follow RFC 6376, RFC 8301 and RFC 8463 exactly instead of the lenient defaults
62
+ this.strict = !!this.options.strict;
63
+
21
64
  this.curTime = getCurTime(this.options.curTime);
22
65
 
23
66
  this.results = [];
@@ -26,10 +69,16 @@ class DkimVerifier extends MessageParser {
26
69
  this.bodyHashes = new Map();
27
70
 
28
71
  this.headerFrom = [];
72
+ this.fromFields = 0;
29
73
  this.envelopeFrom = false;
30
74
 
31
- // ARC verification info
32
- this.arc = { chain: false };
75
+ // ARC verification info, `warnings` lists what the lenient mode accepted in the chain
76
+ this.arc = { chain: false, warnings: [] };
77
+
78
+ // one key lookup per key record for the message. The ARC validation reuses it through
79
+ // the (hidden) keyCache property of the ARC data
80
+ this.keyCache = createKeyCache(this.resolver);
81
+ Object.defineProperty(this.arc, 'keyCache', { value: this.keyCache, enumerable: false, configurable: true, writable: true });
33
82
 
34
83
  // should we also seal this message using ARC
35
84
  this.seal = this.options.seal;
@@ -47,7 +96,7 @@ class DkimVerifier extends MessageParser {
47
96
  this.headers = headers;
48
97
 
49
98
  try {
50
- this.arc.chain = getARChain(headers);
99
+ this.arc.chain = getARChain(headers, { strict: this.strict, warnings: this.arc.warnings });
51
100
  if (this.arc.chain?.length) {
52
101
  this.arc.lastEntry = this.arc.chain[this.arc.chain.length - 1];
53
102
  }
@@ -58,12 +107,15 @@ class DkimVerifier extends MessageParser {
58
107
  this.signatureHeaders = headers.parsed
59
108
  .filter(h => h.key === 'dkim-signature')
60
109
  .map(h => {
61
- const value = parseDkimHeaders(h.line);
110
+ // RFC 6376 section 3.2: tag names are case-sensitive, so in the strict mode "D="
111
+ // is an unknown tag and not the d= tag
112
+ const value = parseDkimHeaders(h.line, { strict: this.strict });
62
113
  value.type = 'DKIM';
63
114
  return value;
64
115
  });
65
116
 
66
117
  let fromHeaders = headers?.parsed?.filter(h => h.key === 'from');
118
+ this.fromFields = fromHeaders.length;
67
119
  for (let fromHeader of fromHeaders) {
68
120
  fromHeader = fromHeader.line.toString();
69
121
  let splitterPos = fromHeader.indexOf(':');
@@ -72,8 +124,13 @@ class DkimVerifier extends MessageParser {
72
124
  }
73
125
  let from = addressparser(fromHeader.trim());
74
126
  for (let addr of from) {
75
- if (addr && addr.address) {
76
- this.headerFrom.push(addr.address);
127
+ // RFC 6854 allows a group in From, and the mailboxes of the group are authors
128
+ // like any other. Leaving them out would leave DMARC without an Author Domain
129
+ for (let mailbox of [].concat(addr?.group || addr || [])) {
130
+ if (mailbox && mailbox.address) {
131
+ // the full addr-spec, the domain is what follows its last "@"
132
+ this.headerFrom.push(mailbox.address);
133
+ }
77
134
  }
78
135
  }
79
136
  }
@@ -94,62 +151,38 @@ class DkimVerifier extends MessageParser {
94
151
  }
95
152
  }
96
153
 
97
- // include newest ARC-Message-Signature as one of the signature headers to check for
154
+ // include newest ARC-Message-Signature as one of the signature headers to check for.
155
+ // The ARC-Seal is validated by the ARC module, it is not a DKIM signature, so it is
156
+ // not evaluated here (a c= tag on it used to make it one, and it ended up in the
157
+ // DKIM results)
98
158
  if (this.arc.lastEntry) {
99
159
  const signatureHeader = this.arc.lastEntry['arc-message-signature'];
100
160
  signatureHeader.type = 'ARC';
101
161
  this.signatureHeaders.push(signatureHeader);
102
162
 
103
- const sealHeader = this.arc.lastEntry['arc-seal'];
104
- sealHeader.type = 'AS';
105
- this.signatureHeaders.push(sealHeader);
163
+ if (!this.strict && !signatureHeader.parsed?.c) {
164
+ // Without c= the ARC-Message-Signature uses simple/simple, the DKIM-Signature
165
+ // default (RFC 8617 section 4.1.2, RFC 6376 section 3.5). Older versions of this
166
+ // library, and the ARC drafts, used relaxed/relaxed instead, so the lenient mode
167
+ // also tries that when simple/simple does not verify, and marks the result
168
+ signatureHeader.fallbackCanonicalization = 'relaxed/relaxed';
169
+ }
106
170
  }
107
171
 
108
172
  for (let signatureHeader of this.signatureHeaders) {
109
- signatureHeader.algorithm = signatureHeader.parsed?.a?.value || '';
110
- signatureHeader.signAlgo = signatureHeader.algorithm.split('-').shift().toLowerCase().trim();
111
- signatureHeader.hashAlgo = signatureHeader.algorithm.split('-').pop().toLowerCase().trim();
112
-
113
- signatureHeader.canonicalization = signatureHeader.parsed?.c?.value || '';
114
- signatureHeader.headerCanon = signatureHeader.canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
115
- // if body canonicalization is not set, then defaults to 'simple'
116
- signatureHeader.bodyCanon = (signatureHeader.canonicalization.split('/')[1] || 'simple').toLowerCase().trim();
117
-
118
- signatureHeader.signingDomain = signatureHeader.parsed?.d?.value || '';
119
- signatureHeader.selector = signatureHeader.parsed?.s?.value || '';
120
-
121
- signatureHeader.timestamp =
122
- signatureHeader.parsed?.t && !isNaN(signatureHeader.parsed?.t?.value) ? new Date(signatureHeader.parsed?.t?.value * 1000) : null;
123
-
124
- signatureHeader.expiration =
125
- signatureHeader.parsed?.x && !isNaN(signatureHeader.parsed?.x?.value) ? new Date(signatureHeader.parsed?.x?.value * 1000) : null;
126
-
127
- // `l=0` is a valid sig-l-tag and means that no body byte is covered by the
128
- // signature, so the value has to be tested for being a number, not for being
129
- // truthy, or a zero length limit would be read as no limit at all
130
- signatureHeader.maxBodyLength =
131
- typeof signatureHeader.parsed?.l?.value === 'number' && !isNaN(signatureHeader.parsed?.l?.value) ? signatureHeader.parsed?.l?.value : '';
132
-
133
- const validSignAlgo = ['rsa', 'ed25519'];
134
- const validHeaderAlgo = signatureHeader.type === 'DKIM' ? ['sha256', 'sha1'] : ['sha256'];
135
- const validHeaderCanon = signatureHeader.type !== 'AS' ? ['relaxed', 'simple'] : ['relaxed'];
136
- const validBodyCanon = signatureHeader.type !== 'AS' ? ['relaxed', 'simple'] : ['relaxed'];
137
-
138
- if (
139
- !validSignAlgo.includes(signatureHeader.signAlgo) ||
140
- !validHeaderAlgo.includes(signatureHeader.hashAlgo) ||
141
- !validHeaderCanon.includes(signatureHeader.headerCanon) ||
142
- !validBodyCanon.includes(signatureHeader.bodyCanon) ||
143
- !signatureHeader.signingDomain ||
144
- !signatureHeader.selector
145
- ) {
146
- signatureHeader.skip = true;
173
+ this.prepareSignature(signatureHeader);
174
+
175
+ if (signatureHeader.skip || signatureHeader.invalid) {
147
176
  continue;
148
177
  }
149
178
 
150
- signatureHeader.bodyHashKey = [signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength].join(':');
151
- if (!this.bodyHashes.has(signatureHeader.bodyHashKey)) {
152
- this.bodyHashes.set(signatureHeader.bodyHashKey, dkimBody(signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength));
179
+ signatureHeader.bodyHashKey = this.addBodyHash(signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength);
180
+ if (signatureHeader.fallbackCanonicalization) {
181
+ signatureHeader.fallbackBodyHashKey = this.addBodyHash(
182
+ getBodyCanon(signatureHeader.fallbackCanonicalization),
183
+ signatureHeader.hashAlgo,
184
+ signatureHeader.maxBodyLength
185
+ );
153
186
  }
154
187
 
155
188
  let headersArray = this.headers.parsed;
@@ -171,6 +204,197 @@ class DkimVerifier extends MessageParser {
171
204
  }
172
205
  }
173
206
 
207
+ // Registers a body hash, once for every canonicalization, hash and length combination
208
+ addBodyHash(bodyCanon, hashAlgo, maxBodyLength) {
209
+ let bodyHashKey = [bodyCanon, hashAlgo, maxBodyLength].join(':');
210
+ if (!this.bodyHashes.has(bodyHashKey)) {
211
+ this.bodyHashes.set(bodyHashKey, dkimBody(bodyCanon, hashAlgo, maxBodyLength));
212
+ }
213
+ return bodyHashKey;
214
+ }
215
+
216
+ /**
217
+ * Reads the tags of a signature header and validates them (RFC 6376 section 6.1.1).
218
+ *
219
+ * Sets `skip` for a signature that is not processed at all: the lenient mode leaves those
220
+ * out of the results as it always has, the strict mode reports them as neutral. Sets
221
+ * `invalid` to a reason for a signature that is reported as neutral without looking up
222
+ * its key. Collects `warnings` for what the lenient mode accepts and the strict mode would
223
+ * not.
224
+ */
225
+ prepareSignature(signatureHeader) {
226
+ const strict = this.strict;
227
+ const isDkim = signatureHeader.type === 'DKIM';
228
+
229
+ signatureHeader.warnings = [];
230
+ const warn = warning => addWarning(signatureHeader.warnings, warning);
231
+ // A rule the strict mode enforces: returns true when the signature is invalid for it,
232
+ // otherwise notes the warning (if any) and returns false
233
+ const violation = (reason, warning) => {
234
+ if (strict) {
235
+ signatureHeader.invalid = reason;
236
+ return true;
237
+ }
238
+ if (warning) {
239
+ warn(warning);
240
+ }
241
+ return false;
242
+ };
243
+
244
+ // the raw tag values, as they were written, for the syntax checks
245
+ const rawTags = new Map();
246
+ for (let tag of signatureHeader.tags || []) {
247
+ rawTags.set(tag.name, tag.value);
248
+ }
249
+
250
+ let tagListErrors = signatureHeader.syntaxErrors || [];
251
+ let hasCaseFoldedTags = (signatureHeader.tags || []).some(tag => tag.name !== tag.name.toLowerCase());
252
+
253
+ const parsed = signatureHeader.parsed || {};
254
+
255
+ signatureHeader.algorithm = (parsed.a?.value || '').toString();
256
+ Object.assign(signatureHeader, splitAlgorithm(signatureHeader.algorithm));
257
+
258
+ signatureHeader.canonicalization = (parsed.c?.value || '').toString();
259
+ signatureHeader.headerCanon = signatureHeader.canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
260
+ signatureHeader.bodyCanon = getBodyCanon(signatureHeader.canonicalization);
261
+
262
+ signatureHeader.signingDomain = (parsed.d?.value || '').toString();
263
+ signatureHeader.selector = (parsed.s?.value || '').toString();
264
+
265
+ signatureHeader.timestamp = parsed.t && !isNaN(parsed.t.value) ? new Date(parsed.t.value * 1000) : null;
266
+
267
+ signatureHeader.expiration = parsed.x && !isNaN(parsed.x.value) ? new Date(parsed.x.value * 1000) : null;
268
+
269
+ // `l=0` is a valid sig-l-tag and means that no body byte is covered by the
270
+ // signature, so the value has to be tested for being a number, not for being
271
+ // truthy, or a zero length limit would be read as no limit at all
272
+ signatureHeader.maxBodyLength = typeof parsed.l?.value === 'number' && !isNaN(parsed.l.value) ? parsed.l.value : '';
273
+
274
+ // AUID, "@d" when i= is not set (RFC 6376 section 3.5)
275
+ if (isDkim && parsed.i && typeof parsed.i.value === 'string') {
276
+ signatureHeader.identity = decodeDkimQuotedPrintable(parsed.i.value);
277
+ } else if (isDkim) {
278
+ signatureHeader.identity = signatureHeader.signingDomain ? `@${signatureHeader.signingDomain}` : '';
279
+ }
280
+
281
+ const validCanon = ['relaxed', 'simple'];
282
+
283
+ // rsa-sha1 is only a DKIM algorithm, ARC signatures always use sha256
284
+ if (!isSupportedAlgorithm(signatureHeader.signAlgo, signatureHeader.hashAlgo, isDkim) || (strict && !ALGORITHM.test(signatureHeader.algorithm))) {
285
+ signatureHeader.skip = 'unknown algorithm';
286
+ return;
287
+ }
288
+
289
+ if (!validCanon.includes(signatureHeader.headerCanon) || !validCanon.includes(signatureHeader.bodyCanon)) {
290
+ signatureHeader.skip = 'unknown canonicalization';
291
+ return;
292
+ }
293
+
294
+ if (!signatureHeader.signingDomain || !signatureHeader.selector) {
295
+ signatureHeader.skip = 'signature missing required tag';
296
+ return;
297
+ }
298
+
299
+ if (isDkim) {
300
+ // RFC 6376 section 3.2 and 6.1.1
301
+ if (tagListErrors.length && violation('signature syntax error', 'tag-syntax')) {
302
+ return;
303
+ }
304
+ if (hasCaseFoldedTags) {
305
+ warn('tag-syntax');
306
+ }
307
+
308
+ let missingTags = REQUIRED_DKIM_TAGS.filter(tag => !parsed[tag]);
309
+ if (missingTags.length) {
310
+ if (violation('signature missing required tag')) {
311
+ return;
312
+ }
313
+ // without h= the default header list is used, which does include From
314
+ missingTags.forEach(tag => warn(`missing-${tag}`));
315
+ }
316
+
317
+ // "Verifiers MUST return PERMFAIL (incompatible version)"
318
+ if (parsed.v && rawTags.get('v') !== '1' && violation('incompatible version', 'invalid-v')) {
319
+ return;
320
+ }
321
+
322
+ let numericError = [
323
+ ['t', TIMESTAMP],
324
+ ['x', TIMESTAMP],
325
+ ['l', BODY_LENGTH]
326
+ ].some(([tag, format]) => parsed[tag] && !format.test(rawTags.get(tag) || ''));
327
+ if (numericError && violation('signature syntax error', 'tag-syntax')) {
328
+ return;
329
+ }
330
+
331
+ // "This list MUST NOT be empty", the From check below covers the lenient mode
332
+ if (parsed.h && !normalizeFieldList(parsed.h.value).length && violation('signature syntax error')) {
333
+ return;
334
+ }
335
+
336
+ let invalidNames = !DOMAIN_NAME.test(toALabel(signatureHeader.signingDomain)) || !SELECTOR.test(toALabel(signatureHeader.selector));
337
+ if (invalidNames && violation('signature syntax error', 'tag-syntax')) {
338
+ return;
339
+ }
340
+
341
+ if (parsed.q) {
342
+ // "dns/txt" is the only query method, unrecognized ones are ignored
343
+ let methods = (parsed.q.value || '')
344
+ .toString()
345
+ .split(':')
346
+ .map(method => method.trim().toLowerCase());
347
+ if (!methods.includes('dns/txt') && violation('unsupported query method', 'query-method')) {
348
+ return;
349
+ }
350
+ }
351
+
352
+ if (parsed.i) {
353
+ // RFC 6376 section 6.1.1: the i= domain has to be d= or one of its subdomains
354
+ let identity = signatureHeader.identity;
355
+ let atPos = identity.lastIndexOf('@');
356
+ let localPart = atPos >= 0 ? identity.substring(0, atPos) : '';
357
+ let identityDomain = atPos >= 0 ? identity.substring(atPos + 1) : '';
358
+
359
+ let validSyntax = atPos >= 0 && (!localPart || LOCAL_PART.test(localPart)) && DOMAIN_NAME.test(toALabel(identityDomain));
360
+ let validDomain = atPos >= 0 && isSameOrSubdomain(identityDomain, signatureHeader.signingDomain);
361
+
362
+ if ((!validSyntax || !validDomain) && violation(validSyntax ? 'domain mismatch' : 'signature syntax error', 'identity-domain')) {
363
+ return;
364
+ }
365
+ signatureHeader.identityDomain = identityDomain;
366
+ }
367
+
368
+ if (signatureHeader.timestamp && signatureHeader.expiration && signatureHeader.expiration <= signatureHeader.timestamp) {
369
+ // "The value of the x= tag MUST be greater than the value of the t= tag". An x=
370
+ // before t= fails in both modes, an x= equal to t= only in the strict mode
371
+ if (violation('invalid expiration')) {
372
+ return;
373
+ }
374
+ if (signatureHeader.expiration.getTime() === signatureHeader.timestamp.getTime()) {
375
+ warn('invalid-expiration');
376
+ }
377
+ }
378
+
379
+ if (signatureHeader.hashAlgo === 'sha1') {
380
+ // RFC 8301 section 3.1: rsa-sha1 MUST NOT be used for verifying. Only the
381
+ // strict mode rejects it, the lenient mode lets the key owner opt out of it
382
+ // with the h= tag of the key record
383
+ warn('rsa-sha1');
384
+ }
385
+ }
386
+
387
+ // RFC 6376 section 6.1.1: a signature that does not cover From is ignored in every
388
+ // mode. It says nothing about the author the DMARC check is about, and the From
389
+ // header could have been replaced after signing. The same applies to the
390
+ // ARC-Message-Signature, which has the DKIM-Signature syntax (RFC 8617 section 4.1.2)
391
+ let signedFields = parsed.h ? normalizeFieldList(parsed.h.value) : ['from'];
392
+ if (!signedFields.includes('from')) {
393
+ signatureHeader.invalid = 'From field not signed';
394
+ return;
395
+ }
396
+ }
397
+
174
398
  async nextChunk(chunk) {
175
399
  for (let bodyHash of this.bodyHashes.values()) {
176
400
  bodyHash.update(chunk);
@@ -190,220 +414,328 @@ class DkimVerifier extends MessageParser {
190
414
  return true;
191
415
  }
192
416
 
193
- async finalChunk() {
194
- try {
195
- if (!this.headers || !this.bodyHashes.size) {
196
- return;
197
- }
417
+ getStatusHeader(signatureHeader) {
418
+ let header = {};
419
+
420
+ if (this.strict && signatureHeader.type === 'DKIM') {
421
+ // RFC 8601 section 2.7.1: header.d is the d= domain and header.i the AUID, which is
422
+ // the i= value or "@d" when there is no i=. Both use the domain-name syntax of
423
+ // RFC 6376 section 3.5, where an internationalized domain name is an A-label
424
+ let identity = signatureHeader.identity || '';
425
+ let atPos = identity.lastIndexOf('@');
426
+ header.d = signatureHeader.signingDomain ? toALabel(signatureHeader.signingDomain) : false;
427
+ header.i = identity ? (atPos >= 0 ? identity.substring(0, atPos + 1) + toALabel(identity.substring(atPos + 1)) : identity) : false;
428
+ } else {
429
+ // signing domain
430
+ header.i = signatureHeader.signingDomain ? `@${signatureHeader.signingDomain}` : false;
431
+ }
198
432
 
199
- // convert bodyHashes from hash objects to base64 strings
200
- for (let [key, bodyHash] of this.bodyHashes.entries()) {
201
- this.bodyHashes.get(key).hash = bodyHash.digest('base64');
202
- this.bodyHashes.get(key).mimeStructureStart = bodyHash.getMimeStructureStart();
203
- }
433
+ // dkim selector
434
+ header.s = signatureHeader.selector;
435
+ // algo
436
+ header.a = signatureHeader.parsed?.a?.value;
437
+ // signature value
438
+ header.b = signatureHeader.parsed?.b?.value ? `${signatureHeader.parsed?.b?.value.toString().substr(0, 8)}` : false;
204
439
 
205
- for (let signatureHeader of this.signatureHeaders) {
206
- if (signatureHeader.skip) {
207
- // TODO: add failing header line?
208
- continue;
209
- }
440
+ return header;
441
+ }
210
442
 
211
- let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, signatureHeader.parsed?.h?.value, true);
443
+ // The result properties every signature has, whether it was verified or not
444
+ baseResult(signatureHeader, format) {
445
+ return {
446
+ id: signatureHeader.parsed?.b?.value
447
+ ? crypto.createHash('sha256').update(Buffer.from(signatureHeader.parsed.b.value.toString(), 'base64')).digest('hex')
448
+ : crypto.randomUUID(),
449
+ signingDomain: signatureHeader.signingDomain,
450
+ selector: signatureHeader.selector,
451
+ signature: signatureHeader.parsed?.b?.value,
452
+ algo: signatureHeader.parsed?.a?.value,
453
+ format
454
+ };
455
+ }
212
456
 
213
- let { canonicalizedHeader } = generateCanonicalizedHeader(signatureHeader.type, signingHeaderLines, {
214
- signatureHeaderLine: signatureHeader.original,
215
- canonicalization: signatureHeader.canonicalization,
216
- instance: ['ARC', 'AS'].includes(signatureHeader.type) ? signatureHeader.parsed?.i?.value : false
217
- });
457
+ /**
458
+ * Verifies a signature
459
+ *
460
+ * @param {Object} signatureHeader Signature prepared by prepareSignature()
461
+ * @param {Boolean} [fallback] If true, verify with `fallbackCanonicalization` instead of the
462
+ * canonicalization of the signature. The result is then built as if the signature
463
+ * had that c= value, and the signature header object is not updated
464
+ * @returns {Object} Signature result
465
+ */
466
+ async verifySignature(signatureHeader, fallback) {
467
+ const canonicalization = fallback ? signatureHeader.fallbackCanonicalization : signatureHeader.canonicalization;
468
+ const bodyHashKey = fallback ? signatureHeader.fallbackBodyHashKey : signatureHeader.bodyHashKey;
469
+
470
+ let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, signatureHeader.parsed?.h?.value, true);
471
+
472
+ let { canonicalizedHeader } = generateCanonicalizedHeader(signatureHeader.type, signingHeaderLines, {
473
+ signatureHeaderLine: signatureHeader.original,
474
+ canonicalization,
475
+ instance: signatureHeader.type === 'ARC' ? signatureHeader.parsed?.i?.value : false,
476
+ strict: this.strict
477
+ });
478
+
479
+ let signingHeaders = {
480
+ keys: signingHeaderLines.keys,
481
+ headers: signingHeaderLines.headers.map(l => l.line.toString()),
482
+ canonicalizedHeader: canonicalizedHeader.toString('base64')
483
+ };
484
+
485
+ let publicKey, rr, modulusLength;
486
+ let status = {
487
+ result: 'neutral',
488
+ comment: false,
489
+ // ptype properties
490
+ header: this.getStatusHeader(signatureHeader)
491
+ };
492
+
493
+ if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) {
494
+ status.aligned = getAlignment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain])?.domain || false;
495
+ }
218
496
 
219
- let signingHeaders = {
220
- keys: signingHeaderLines.keys,
221
- headers: signingHeaderLines.headers.map(l => l.line.toString()),
222
- canonicalizedHeader: canonicalizedHeader.toString('base64')
223
- };
224
-
225
- let publicKey, rr, modulusLength;
226
- let status = {
227
- result: 'neutral',
228
- comment: false,
229
- // ptype properties
230
- header: {
231
- // signing domain
232
- i: signatureHeader.signingDomain ? `@${signatureHeader.signingDomain}` : false,
233
- // dkim selector
234
- s: signatureHeader.selector,
235
- // algo
236
- a: signatureHeader.parsed?.a?.value,
237
- // signature value
238
- b: signatureHeader.parsed?.b?.value ? `${signatureHeader.parsed?.b?.value.substr(0, 8)}` : false
497
+ const bodyHasher = this.bodyHashes.get(bodyHashKey);
498
+ const bodyHash = bodyHasher?.hash;
499
+ const mimeStructureStart = bodyHasher?.mimeStructureStart;
500
+
501
+ const warnings = [].concat(signatureHeader.warnings || []);
502
+
503
+ if (signatureHeader.invalid) {
504
+ // RFC 6376 section 6.1.1 PERMFAIL, reported as neutral, which RFC 8601 section
505
+ // 2.7.1 uses for a signature with syntax errors. The key is not looked up
506
+ status.result = 'neutral';
507
+ status.comment = signatureHeader.invalid;
508
+ } else if (signatureHeader.parsed?.bh?.value !== bodyHash) {
509
+ // RFC 6376 section 6.1.3 step 3 is a PERMFAIL, which RFC 8601 reports as "fail".
510
+ // The lenient mode keeps reporting neutral, as it always has
511
+ status.result = this.strict ? 'fail' : 'neutral';
512
+ status.comment = `body hash did not verify`;
513
+ } else if (this.strict && signatureHeader.type === 'DKIM' && signatureHeader.hashAlgo === 'sha1') {
514
+ // RFC 8301 section 3.1, "rsa-sha1 MUST NOT be used for signing or verifying"
515
+ status.result = 'policy';
516
+ status.comment = 'weak algorithm';
517
+ status.policy = { 'dkim-rules': 'weak-algorithm' };
518
+ } else {
519
+ try {
520
+ let res = await this.keyCache.get(
521
+ signatureHeader.type,
522
+ `${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`,
523
+ this.minBitLength,
524
+ {
525
+ strict: this.strict,
526
+ hashAlgo: signatureHeader.hashAlgo
239
527
  }
240
- };
528
+ );
529
+
530
+ publicKey = res?.publicKey;
531
+ rr = res?.rr;
532
+ modulusLength = res?.modulusLength;
241
533
 
242
- if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) {
243
- status.aligned = this.headerFrom?.length
244
- ? getAlignment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain])?.domain || false
245
- : false;
534
+ addWarning(warnings, ...(res?.warnings || []));
535
+
536
+ if (res?.testing) {
537
+ // key t=y, the domain is testing DKIM (RFC 6376 section 3.6.1)
538
+ status.testing = true;
246
539
  }
247
540
 
248
- const bodyHash = this.bodyHashes.get(signatureHeader.bodyHashKey)?.hash;
249
- const mimeStructureStart = this.bodyHashes.get(signatureHeader.bodyHashKey)?.mimeStructureStart;
541
+ let keyError = false;
542
+ if (this.strict && res?.keyType && res.keyType !== signatureHeader.signAlgo) {
543
+ // RFC 6376 section 6.1.2 step 8. The lenient mode lets the crypto library
544
+ // report the mismatch, as it always has
545
+ keyError = 'inappropriate key algorithm';
546
+ } else if (
547
+ res?.flags?.includes('s') &&
548
+ signatureHeader.identityDomain &&
549
+ normalizeDomainName(signatureHeader.identityDomain) !== normalizeDomainName(signatureHeader.signingDomain)
550
+ ) {
551
+ // key t=s: "the i= domain MUST NOT be a subdomain of d="
552
+ if (this.strict) {
553
+ keyError = 'domain mismatch';
554
+ } else {
555
+ addWarning(warnings, 'identity-domain');
556
+ }
557
+ }
250
558
 
251
- if (signatureHeader.parsed?.bh?.value !== bodyHash) {
559
+ if (keyError) {
252
560
  status.result = 'neutral';
253
- status.comment = `body hash did not verify`;
561
+ status.comment = keyError;
254
562
  } else {
255
563
  try {
256
- let res = await getPublicKey(
257
- signatureHeader.type,
258
- `${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`,
259
- this.minBitLength,
260
- this.resolver
261
- );
262
-
263
- publicKey = res?.publicKey;
264
- rr = res?.rr;
265
- modulusLength = res?.modulusLength;
266
-
267
- try {
268
- status.result = crypto.verify(
269
- signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null,
270
- signatureHeader.signAlgo === 'rsa' ? canonicalizedHeader : crypto.createHash('sha256').update(canonicalizedHeader).digest(),
271
- publicKey,
272
- Buffer.from(signatureHeader.parsed?.b?.value, 'base64')
273
- )
274
- ? 'pass'
275
- : 'fail';
276
-
277
- if (status.result === 'fail') {
278
- status.comment = 'bad signature';
279
- }
280
-
281
- if (status.result === 'pass') {
282
- if (signatureHeader.expiration && signatureHeader.timestamp && signatureHeader.expiration < signatureHeader.timestamp) {
283
- status.result = 'neutral';
284
- status.comment = 'invalid expiration';
285
- }
286
-
287
- if (signatureHeader.expiration && signatureHeader.expiration < this.curTime) {
288
- status.result = 'neutral';
289
- status.comment = 'expired';
290
- }
291
- }
292
- } catch (err) {
293
- status.result = 'neutral';
294
- status.comment = err.message;
295
- }
296
- } catch (err) {
297
- if (err.rr) {
298
- rr = err.rr;
564
+ status.result = crypto.verify(
565
+ signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null,
566
+ signatureHeader.signAlgo === 'rsa' ? canonicalizedHeader : crypto.createHash('sha256').update(canonicalizedHeader).digest(),
567
+ publicKey,
568
+ Buffer.from(signatureHeader.parsed?.b?.value || '', 'base64')
569
+ )
570
+ ? 'pass'
571
+ : 'fail';
572
+
573
+ if (status.result === 'fail') {
574
+ status.comment = 'bad signature';
299
575
  }
300
576
 
301
- switch (err.code) {
302
- case 'ENOTFOUND':
303
- case 'ENODATA':
577
+ if (status.result === 'pass') {
578
+ if (signatureHeader.expiration && signatureHeader.timestamp && signatureHeader.expiration < signatureHeader.timestamp) {
304
579
  status.result = 'neutral';
305
- status.comment = `no key`;
306
- break;
307
-
308
- case 'EINVALIDVER':
309
- status.result = 'neutral';
310
- status.comment = `unknown key version`;
311
- break;
312
-
313
- case 'EINVALIDTYPE':
314
- status.result = 'neutral';
315
- status.comment = `unknown key type`;
316
- break;
580
+ status.comment = 'invalid expiration';
581
+ }
317
582
 
318
- case 'EINVALIDVAL':
583
+ if (signatureHeader.expiration && signatureHeader.expiration < this.curTime) {
319
584
  status.result = 'neutral';
320
- status.comment = `invalid public key`;
321
- break;
322
-
323
- case 'ESHORTKEY':
324
- status.result = 'policy';
325
- if (!status.policy) {
326
- status.policy = {};
327
- }
328
- status.policy['dkim-rules'] = `weak-key`;
329
- break;
330
-
331
- default:
332
- status.result = 'temperror';
333
- status.comment = `DNS failure: ${err.code || err.message}`;
585
+ status.comment = 'expired';
586
+ }
334
587
  }
588
+ } catch (err) {
589
+ status.result = 'neutral';
590
+ status.comment = err.message;
335
591
  }
336
592
  }
337
-
338
- signatureHeader.bodyHashedBytes = this.bodyHashes.get(signatureHeader.bodyHashKey)?.bodyHashedBytes;
339
- signatureHeader.canonicalizedLength = this.bodyHashes.get(signatureHeader.bodyHashKey)?.canonicalizedLength;
340
- signatureHeader.sourceBodyLength = this.bodyHashes.get(signatureHeader.bodyHashKey)?.byteLength;
341
-
342
- let result = {
343
- id: signatureHeader.parsed?.b?.value
344
- ? crypto.createHash('sha256').update(Buffer.from(signatureHeader.parsed?.b?.value, 'base64')).digest('hex')
345
- : crypto.randomUUID(),
346
- signingDomain: signatureHeader.signingDomain,
347
- selector: signatureHeader.selector,
348
- signature: signatureHeader.parsed?.b?.value,
349
- algo: signatureHeader.parsed?.a?.value,
350
- format: signatureHeader.parsed?.c?.value,
351
- bodyHash,
352
- bodyHashExpecting: signatureHeader.parsed?.bh?.value,
353
- signingHeaders,
354
- status,
355
- signTime: signatureHeader.timestamp ? signatureHeader.timestamp.toISOString() : null,
356
- expiresAfter: signatureHeader.expiration ? signatureHeader.expiration.toISOString() : null,
357
- signatureTimeValid: this.getSignatureTimeValid(signatureHeader)
358
- };
359
-
360
- if (typeof signatureHeader.sourceBodyLength === 'number') {
361
- result.sourceBodyLength = signatureHeader.sourceBodyLength;
362
- }
363
-
364
- if (typeof signatureHeader.bodyHashedBytes === 'number') {
365
- result.canonBodyLength = signatureHeader.bodyHashedBytes;
593
+ } catch (err) {
594
+ if (err.rr) {
595
+ rr = err.rr;
366
596
  }
367
597
 
368
- if (typeof signatureHeader.canonicalizedLength === 'number') {
369
- result.canonBodyLengthTotal = signatureHeader.canonicalizedLength;
370
- }
371
-
372
- if (typeof signatureHeader.maxBodyLength === 'number') {
373
- result.canonBodyLengthLimited = true;
374
- result.canonBodyLengthLimit = signatureHeader.maxBodyLength;
375
- if (result.canonBodyLengthTotal > result.canonBodyLength) {
376
- status.underSized = result.canonBodyLengthTotal - result.canonBodyLength;
598
+ if (KEY_ERROR_COMMENTS.has(err.code)) {
599
+ status.result = 'neutral';
600
+ status.comment = KEY_ERROR_COMMENTS.get(err.code);
601
+ } else if (err.code === 'ESHORTKEY') {
602
+ status.result = 'policy';
603
+ if (!status.policy) {
604
+ status.policy = {};
377
605
  }
606
+ status.policy['dkim-rules'] = `weak-key`;
378
607
  } else {
379
- result.canonBodyLengthLimited = false;
608
+ status.result = 'temperror';
609
+ status.comment = `DNS failure: ${err.code || err.message}`;
380
610
  }
611
+ }
612
+ }
381
613
 
382
- if (typeof mimeStructureStart === 'number') {
383
- result.mimeStructureStart = mimeStructureStart;
384
- }
614
+ if (warnings.length) {
615
+ // what the lenient mode accepted that the strict mode would not, never rendered
616
+ // into the Authentication-Results header
617
+ status.warnings = warnings;
618
+ }
385
619
 
386
- if (publicKey) {
387
- result.publicKey = publicKey.toString();
388
- }
620
+ const bodyHashedBytes = bodyHasher?.bodyHashedBytes;
621
+ const canonicalizedLength = bodyHasher?.canonicalizedLength;
622
+ const sourceBodyLength = bodyHasher?.byteLength;
389
623
 
390
- if (modulusLength) {
391
- result.modulusLength = modulusLength;
392
- }
624
+ if (!fallback) {
625
+ signatureHeader.bodyHashedBytes = bodyHashedBytes;
626
+ signatureHeader.canonicalizedLength = canonicalizedLength;
627
+ signatureHeader.sourceBodyLength = sourceBodyLength;
628
+ }
393
629
 
394
- if (rr) {
395
- result.rr = rr;
396
- }
630
+ let result = Object.assign(this.baseResult(signatureHeader, fallback ? canonicalization : signatureHeader.parsed?.c?.value), {
631
+ bodyHash,
632
+ bodyHashExpecting: signatureHeader.parsed?.bh?.value,
633
+ signingHeaders,
634
+ status,
635
+ signTime: signatureHeader.timestamp && !isNaN(signatureHeader.timestamp) ? signatureHeader.timestamp.toISOString() : null,
636
+ expiresAfter: signatureHeader.expiration && !isNaN(signatureHeader.expiration) ? signatureHeader.expiration.toISOString() : null,
637
+ signatureTimeValid: this.getSignatureTimeValid(signatureHeader)
638
+ });
639
+
640
+ if (typeof sourceBodyLength === 'number') {
641
+ result.sourceBodyLength = sourceBodyLength;
642
+ }
643
+
644
+ if (typeof bodyHashedBytes === 'number') {
645
+ result.canonBodyLength = bodyHashedBytes;
646
+ }
647
+
648
+ if (typeof canonicalizedLength === 'number') {
649
+ result.canonBodyLengthTotal = canonicalizedLength;
650
+ }
651
+
652
+ if (typeof signatureHeader.maxBodyLength === 'number') {
653
+ result.canonBodyLengthLimited = true;
654
+ result.canonBodyLengthLimit = signatureHeader.maxBodyLength;
655
+ if (result.canonBodyLengthTotal > result.canonBodyLength) {
656
+ status.underSized = result.canonBodyLengthTotal - result.canonBodyLength;
657
+ }
658
+ } else {
659
+ result.canonBodyLengthLimited = false;
660
+ }
661
+
662
+ if (typeof mimeStructureStart === 'number') {
663
+ result.mimeStructureStart = mimeStructureStart;
664
+ }
665
+
666
+ if (publicKey) {
667
+ result.publicKey = publicKey.toString();
668
+ }
669
+
670
+ if (modulusLength) {
671
+ result.modulusLength = modulusLength;
672
+ }
673
+
674
+ if (rr) {
675
+ result.rr = rr;
676
+ }
677
+
678
+ if (typeof result.status.comment === 'boolean') {
679
+ delete result.status.comment;
680
+ }
681
+
682
+ return result;
683
+ }
684
+
685
+ // A signature that could not be processed at all. The strict mode reports it as neutral
686
+ // (RFC 8601 section 2.7.1: "the signature or signatures contained syntax errors or were
687
+ // not otherwise able to be processed"), as "none" means that the message was not signed
688
+ skippedSignatureResult(signatureHeader) {
689
+ let status = {
690
+ result: 'neutral',
691
+ comment: signatureHeader.skip,
692
+ header: this.getStatusHeader(signatureHeader)
693
+ };
694
+
695
+ return Object.assign(this.baseResult(signatureHeader, signatureHeader.parsed?.c?.value), { status });
696
+ }
397
697
 
398
- if (typeof result.status.comment === 'boolean') {
399
- delete result.status.comment;
698
+ async finalChunk() {
699
+ try {
700
+ if (!this.headers || (!this.bodyHashes.size && !this.signatureHeaders.some(signatureHeader => signatureHeader.invalid || signatureHeader.skip))) {
701
+ return;
702
+ }
703
+
704
+ // convert bodyHashes from hash objects to base64 strings
705
+ for (let [key, bodyHash] of this.bodyHashes.entries()) {
706
+ this.bodyHashes.get(key).hash = bodyHash.digest('base64');
707
+ this.bodyHashes.get(key).mimeStructureStart = bodyHash.getMimeStructureStart();
708
+ }
709
+
710
+ for (let signatureHeader of this.signatureHeaders) {
711
+ let result;
712
+
713
+ if (signatureHeader.skip) {
714
+ if (!this.strict || signatureHeader.type !== 'DKIM') {
715
+ // the lenient mode does not report what it can not process
716
+ continue;
717
+ }
718
+ result = this.skippedSignatureResult(signatureHeader);
719
+ } else {
720
+ result = await this.verifySignature(signatureHeader);
721
+
722
+ if (signatureHeader.fallbackCanonicalization && !signatureHeader.invalid && result.status.result !== 'pass') {
723
+ // only used when it verifies, the result of the default canonicalization
724
+ // is kept otherwise. The key lookup is cached, so it is not repeated
725
+ let fallbackResult = await this.verifySignature(signatureHeader, true);
726
+ if (fallbackResult.status.result === 'pass') {
727
+ fallbackResult.status.warnings = addWarning([].concat(fallbackResult.status.warnings || []), 'ams-c-default');
728
+ result = fallbackResult;
729
+ }
730
+ }
400
731
  }
401
732
 
402
733
  switch (signatureHeader.type) {
403
734
  case 'ARC':
404
- if (this.arc.lastEntry) {
405
- this.arc.lastEntry.messageSignature = result;
735
+ if (!this.arc.lastEntry) {
736
+ break;
406
737
  }
738
+ this.arc.lastEntry.messageSignature = result;
407
739
  break;
408
740
  case 'DKIM':
409
741
  default:
@@ -422,7 +754,7 @@ class DkimVerifier extends MessageParser {
422
754
  }
423
755
 
424
756
  this.results.forEach(result => {
425
- result.info = formatAuthHeaderRow('dkim', result.status);
757
+ result.info = formatAuthHeaderRow('dkim', result.status, { strict: this.strict });
426
758
  });
427
759
  }
428
760
 
@@ -432,4 +764,15 @@ class DkimVerifier extends MessageParser {
432
764
  }
433
765
  }
434
766
 
767
+ // the body canonicalization of a c= value, 'simple' when it is not set (RFC 6376 section 3.5)
768
+ const getBodyCanon = canonicalization => (canonicalization.split('/')[1] || 'simple').toLowerCase().trim();
769
+
770
+ // the lower case header field names of an h= tag
771
+ const normalizeFieldList = value =>
772
+ (value || '')
773
+ .toString()
774
+ .split(':')
775
+ .map(name => name.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '').toLowerCase())
776
+ .filter(name => name);
777
+
435
778
  module.exports = { DkimVerifier };