mailauth 6.0.0 → 7.1.0

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