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
package/lib/arc/index.js CHANGED
@@ -1,21 +1,110 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
+ const { isIP } = require('node:net');
4
5
  const {
5
6
  parseDkimHeaders,
6
7
  formatRelaxedLine,
7
8
  stripSignatureValue,
8
- getPublicKey,
9
9
  formatAuthHeaderRow,
10
10
  formatSignatureHeaderLine,
11
11
  writeToStream,
12
- validateAlgorithm
12
+ validateAlgorithm,
13
+ createError,
14
+ addWarning,
15
+ KEY_ERROR_COMMENTS,
16
+ splitAlgorithm,
17
+ createKeyCache
13
18
  } = require('../../lib/tools');
19
+ const { TIMESTAMP, BODY_LENGTH, MAX_ARC_INSTANCE, ARC_HEADER_KEYS } = require('../dkim/syntax');
14
20
  const crypto = require('node:crypto');
15
21
  const { DkimSigner } = require('../dkim/dkim-signer');
16
22
 
23
+ // RFC 8617 section 3.9: chain-status = ("none" / "fail" / "pass"). ABNF strings are case-insensitive
24
+ const CHAIN_STATUS = ['none', 'pass', 'fail'];
25
+
26
+ // RFC 8617 section 3.9: position = 1*2DIGIT
27
+ const INSTANCE_VALUE = /^[0-9]{1,2}$/;
28
+
29
+ // RFC 8617 section 4.1.1: arc-info = instance [CFWS] ";" authres-payload, so the instance is the
30
+ // first thing in an ARC-Authentication-Results value. Comments (without nesting) are allowed as CFWS
31
+ const CFWS = '(?:[ \\t\\r\\n]|\\([^()\\\\]*\\))*';
32
+ const AAR_INSTANCE = new RegExp(`^${CFWS}i${CFWS}=${CFWS}([0-9]{1,2})${CFWS};`);
33
+
34
+ // Tags an ARC-Seal (RFC 8617 section 4.1.3) and an ARC-Message-Signature (section 4.1.2, with
35
+ // the DKIM-Signature tags of RFC 6376 section 3.5 except v= and i=) can not do without
36
+ const REQUIRED_TAGS = {
37
+ 'arc-seal': ['a', 'b', 'cv', 'd', 's'],
38
+ 'arc-message-signature': ['a', 'b', 'bh', 'd', 'h', 's']
39
+ };
40
+
41
+ const NUMERIC_TAGS = {
42
+ 'arc-seal': [['t', TIMESTAMP]],
43
+ 'arc-message-signature': [
44
+ ['t', TIMESTAMP],
45
+ ['x', TIMESTAMP],
46
+ ['l', BODY_LENGTH]
47
+ ]
48
+ };
49
+
50
+ const SHORT_NAMES = {
51
+ 'arc-seal': 'as',
52
+ 'arc-message-signature': 'ams',
53
+ 'arc-authentication-results': 'aar'
54
+ };
55
+
56
+ // the cv= value of an ARC set in lower case, or an empty string
57
+ const getCv = arcInstance => (arcInstance?.['arc-seal']?.parsed?.cv?.value || '').toString().toLowerCase().trim();
58
+
59
+ // The highest i= found on the ARC header fields of a message, or 0, and the cv= of the seal
60
+ // with that instance. A new set follows it (RFC 8617 section 5.1 step 3) even when the chain
61
+ // can not be parsed, so that it never reuses an instance that already exists
62
+ const getHighestArcInstance = headers => {
63
+ let highest = 0;
64
+ let latestCv = false;
65
+ for (let row of headers?.parsed || []) {
66
+ if (!ARC_HEADER_KEYS.includes(row.key)) {
67
+ continue;
68
+ }
69
+ let parsed;
70
+ try {
71
+ parsed = parseDkimHeaders(row.line)?.parsed;
72
+ } catch (err) {
73
+ continue;
74
+ }
75
+ let instance = parsed?.i?.value;
76
+ if (typeof instance !== 'number' || !Number.isInteger(instance) || instance < 1) {
77
+ continue;
78
+ }
79
+ if (instance > highest) {
80
+ highest = instance;
81
+ latestCv = false;
82
+ }
83
+ if (instance === highest && row.key === 'arc-seal' && typeof parsed?.cv?.value === 'string') {
84
+ latestCv = parsed.cv.value.toLowerCase().trim();
85
+ }
86
+ }
87
+ return { highest, latestCv };
88
+ };
89
+
90
+ // Where a new ARC set goes (RFC 8617 section 5.1 steps 2 and 3): the highest existing instance
91
+ // and the cv= of its seal, from the header fields and from the parsed chain, for callers that
92
+ // pass the chain without the complete header list
93
+ const getArcPosition = (headers, chain) => {
94
+ let { highest, latestCv } = getHighestArcInstance(headers);
95
+ let lastEntry = chain?.length ? chain[chain.length - 1] : false;
96
+ if (lastEntry && lastEntry.i >= highest) {
97
+ highest = lastEntry.i;
98
+ latestCv = getCv(lastEntry) || latestCv;
99
+ }
100
+ return { highest, latestCv };
101
+ };
102
+
103
+ // the DNS name of the key record of an ARC-Seal
104
+ const sealKeyName = seal => `${seal.parsed?.s?.value}._domainkey.${seal.parsed?.d?.value}`;
105
+
17
106
  const verifyAS = async (chain, opts) => {
18
- const { resolver } = opts || {};
107
+ const { strict, warnings, keyCache } = opts || {};
19
108
 
20
109
  let chunks = [];
21
110
  let signatureHeader;
@@ -32,39 +121,63 @@ const verifyAS = async (chain, opts) => {
32
121
  } else {
33
122
  signatureHeader = link['arc-seal'];
34
123
  if (!signatureHeader.parsed?.s?.value || !signatureHeader.parsed?.d?.value) {
35
- let err = new Error(`Invalid ARC-Seal header`);
36
- err.code = 'invalid_arc_seal';
37
- throw err;
124
+ throw createError(`Invalid ARC-Seal header`, 'invalid_arc_seal');
38
125
  }
39
126
 
40
- chunks.push(stripSignatureValue(formatRelaxedLine(link['arc-seal'].original)));
127
+ chunks.push(stripSignatureValue(formatRelaxedLine(link['arc-seal'].original), strict));
41
128
  }
42
129
  }
43
130
 
131
+ // The algorithm is read from the seal itself. Every seal of the chain is verified here, not
132
+ // only the newest one, so nothing set up elsewhere for the newest seal can be relied on
133
+ let algorithm = (signatureHeader.parsed?.a?.value || '').toString().toLowerCase().trim();
134
+ try {
135
+ // rsa-sha256 and ed25519-sha256 only, ARC has no rsa-sha1
136
+ validateAlgorithm(algorithm, true);
137
+ } catch (err) {
138
+ throw createError(`i=${chain.length} invalid seal algorithm`, 'invalid_arc_seal');
139
+ }
140
+ let { signAlgo } = splitAlgorithm(algorithm);
141
+
44
142
  let canonicalizedHeader = Buffer.concat(chunks);
45
143
 
46
144
  let publicKey;
47
- let queryDomain = `${signatureHeader.parsed?.s?.value}._domainkey.${signatureHeader.parsed?.d?.value}`;
145
+ let queryDomain = sealKeyName(signatureHeader);
48
146
  try {
49
- let res = await getPublicKey('AS', queryDomain, opts.minBitLength, resolver);
147
+ // the key record's own h= and s= restrictions apply to ARC keys as well (RFC 6376
148
+ // section 3.6.1), and every ARC signature uses sha256
149
+ let res = await keyCache.get('AS', queryDomain, opts?.minBitLength, { strict, hashAlgo: 'sha256' });
50
150
  publicKey = res?.publicKey;
151
+
152
+ if (res?.keyType && res.keyType !== signAlgo) {
153
+ // RFC 6376 section 6.1.2 step 8: the key has to suit the a= algorithm
154
+ throw createError('Key type does not match the seal algorithm', 'EINVALIDKEYALGO');
155
+ }
156
+
157
+ if (Array.isArray(warnings)) {
158
+ addWarning(warnings, ...(res?.warnings || []));
159
+ }
51
160
  } catch (err) {
52
161
  err.queryDomain = queryDomain;
53
162
  // the error code is what the ARC result reports as a comment
54
163
  throw err;
55
164
  }
56
165
 
57
- let pass = crypto.verify(
58
- signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null,
59
- canonicalizedHeader,
60
- publicKey,
61
- Buffer.from(signatureHeader.parsed?.b?.value, 'base64')
62
- );
166
+ let pass = false;
167
+ try {
168
+ pass = crypto.verify(
169
+ signAlgo === 'rsa' ? 'sha256' : null,
170
+ // RFC 8463 section 3: Ed25519 signs the SHA-256 hash of the canonicalized data
171
+ signAlgo === 'rsa' ? canonicalizedHeader : crypto.createHash('sha256').update(canonicalizedHeader).digest(),
172
+ publicKey,
173
+ Buffer.from(signatureHeader.parsed?.b?.value || '', 'base64')
174
+ );
175
+ } catch (err) {
176
+ pass = false;
177
+ }
63
178
 
64
179
  if (!pass) {
65
- let err = new Error(`i=${chain.length} seal signature validation failed`);
66
- err.code = 'failing_arc_seal';
67
- throw err;
180
+ throw createError(`i=${chain.length} seal signature validation failed`, 'failing_arc_seal');
68
181
  }
69
182
 
70
183
  return true;
@@ -75,13 +188,13 @@ const signAS = async (chain, entry, signatureData) => {
75
188
 
76
189
  instance = instance || 1;
77
190
 
78
- const signAlgo = algorithm?.split('-').shift();
191
+ const { signAlgo } = splitAlgorithm(algorithm);
79
192
 
80
193
  signTime = signTime || new Date();
81
194
 
82
195
  let chunks = [];
83
196
 
84
- if (signatureData.cv === 'pass') {
197
+ if (cv === 'pass') {
85
198
  // sign existing only chain for passing validation
86
199
  for (let i = 0; i < chain.length; i++) {
87
200
  let link = chain[i];
@@ -146,35 +259,172 @@ const signAS = async (chain, entry, signatureData) => {
146
259
  return formatSignatureHeaderLine('AS', headerOpts, true);
147
260
  };
148
261
 
262
+ /**
263
+ * Validates the ARC-Seal signatures of a chain (RFC 8617 section 5.2 steps 2, 3.C and 6).
264
+ * Throws if the chain is not valid, so it never returns true for a chain with an invalid seal.
265
+ *
266
+ * @param {Object} data { chain } as returned by getARChain()
267
+ * @param {Object} [opts] { resolver, minBitLength, strict, warnings, keyCache }, where keyCache
268
+ * is a createKeyCache() of the same resolver, shared with other key lookups of the message
269
+ * @returns {Boolean} false if there is no chain, true if every seal validates
270
+ */
149
271
  const verifyASChain = async (data, opts) => {
150
272
  if (!data?.chain?.length) {
151
273
  return false;
152
274
  }
153
275
 
154
- for (let i = data.chain.length - 1; i >= 0; i--) {
155
- if (!['none', 'pass'].includes(data.chain[i]?.['arc-seal']?.parsed?.cv?.value)) {
156
- // no need to look further
157
- // validate this header set only
158
- // TODO: what should we report as the result?
159
- await verifyAS([data.chain[i]], opts);
160
- break;
276
+ const chain = data.chain;
277
+ opts = Object.assign({}, opts);
278
+ if (!opts.keyCache) {
279
+ opts.keyCache = createKeyCache(opts.resolver);
280
+ }
281
+
282
+ for (let i = 0; i < chain.length; i++) {
283
+ let arcInstance = chain[i];
284
+
285
+ if (arcInstance?.i !== i + 1) {
286
+ throw createError(`i=${arcInstance?.i} expected=${i + 1}`, 'invalid_arc_instance');
287
+ }
288
+
289
+ for (let headerKey of ARC_HEADER_KEYS) {
290
+ if (!arcInstance[headerKey]) {
291
+ throw createError(`i=${arcInstance.i} no ${headerKey} set`, 'missing_arc_header');
292
+ }
293
+ }
294
+
295
+ // Step 2 (a newest seal with cv=fail fails the chain) and step 3.C: cv=none for i=1 and
296
+ // cv=pass for every later set. The values are compared case-insensitively, as an ABNF
297
+ // string is. A seal with cv=fail only signs its own set (section 5.1.2), so it can
298
+ // never vouch for the sets before it
299
+ let cv = getCv(arcInstance);
300
+ let expected = i === 0 ? 'none' : 'pass';
301
+ if (cv !== expected) {
302
+ throw createError(`i=${arcInstance.i} cv=${cv}`, 'invalid_cv_value');
303
+ }
304
+ }
305
+
306
+ // The keys of all the seals are fetched at once. A failed lookup is only reported when its
307
+ // seal is validated, so the errors come in the same order as without the prefetch
308
+ for (let link of chain) {
309
+ let seal = link['arc-seal'];
310
+ if (seal.parsed?.s?.value && seal.parsed?.d?.value) {
311
+ opts.keyCache.get('AS', sealKeyName(seal), opts.minBitLength, { strict: opts.strict, hashAlgo: 'sha256' });
161
312
  }
313
+ }
162
314
 
163
- // throws if validation fails
164
- await verifyAS(data.chain.slice(0, i + 1), opts);
315
+ // Step 6: every seal, from the newest to the oldest, throws if validation fails
316
+ for (let i = chain.length - 1; i >= 0; i--) {
317
+ await verifyAS(chain.slice(0, i + 1), opts);
165
318
  }
166
319
 
167
320
  return true;
168
321
  };
169
322
 
170
- const getARChain = headers => {
323
+ // The instance of an ARC header field exactly as written, or null. For the
324
+ // ARC-Authentication-Results it is the leading "i=N;" (RFC 8617 section 4.1.1), for the other
325
+ // two it is the value of the (case-sensitive) i tag
326
+ const getRawInstance = (headerKey, value, line) => {
327
+ if (headerKey === 'arc-authentication-results') {
328
+ let str = (line || '').toString('binary');
329
+ str = str.substring(str.indexOf(':') + 1);
330
+ let match = AAR_INSTANCE.exec(str);
331
+ return match ? match[1] : null;
332
+ }
333
+
334
+ let tag = (value?.tags || []).find(tag => tag.name === 'i');
335
+ return tag ? tag.value : null;
336
+ };
337
+
338
+ // Checks the tag-list of an ARC-Seal or ARC-Message-Signature (RFC 6376 section 3.2 and 3.5,
339
+ // RFC 8617 section 4.1). The strict mode, where getARChain has read the tags case-sensitively,
340
+ // rejects the set for any syntax error, the lenient mode keeps reading them the way it always
341
+ // has and only notes what the strict mode would have rejected
342
+ const checkTagList = (arcInstance, headerKey, strict, warn) => {
343
+ let header = arcInstance[headerKey];
344
+ let tags = header.tags || [];
345
+
346
+ let problems = [].concat(header.syntaxErrors || []);
347
+
348
+ let rawTags = new Map();
349
+ for (let tag of tags) {
350
+ rawTags.set(tag.name, tag.value);
351
+ }
352
+
353
+ for (let tag of REQUIRED_TAGS[headerKey]) {
354
+ if (!rawTags.has(tag)) {
355
+ problems.push(`missing ${tag}=`);
356
+ }
357
+ }
358
+
359
+ for (let [tag, format] of NUMERIC_TAGS[headerKey]) {
360
+ if (rawTags.has(tag) && !format.test(rawTags.get(tag))) {
361
+ problems.push(`invalid ${tag}=`);
362
+ }
363
+ }
364
+
365
+ if (strict) {
366
+ if (problems.length) {
367
+ throw createError(`i=${arcInstance.i} ${SHORT_NAMES[headerKey]} ${problems[0]}`, 'invalid_arc_tags');
368
+ }
369
+ return;
370
+ }
371
+
372
+ if (problems.length || tags.some(tag => tag.name !== tag.name.toLowerCase())) {
373
+ warn('arc-tag-syntax');
374
+ }
375
+ };
376
+
377
+ /**
378
+ * Collects the ARC sets of a message and validates the structure of the chain (RFC 8617
379
+ * section 5.2 steps 1 to 3).
380
+ *
381
+ * @param {Object} headers Parsed message headers
382
+ * @param {Object} [opts]
383
+ * @param {Boolean} [opts.strict=false] Apply the RFC 8617 and RFC 6376 syntax rules exactly
384
+ * @param {Array} [opts.warnings] Lax acceptance markers are added here
385
+ * @returns {Array|false} The ARC sets ordered by instance, or false if there are none. Throws
386
+ * for a chain that is not valid
387
+ */
388
+ const getARChain = (headers, opts) => {
389
+ opts = opts || {};
390
+ const strict = !!opts.strict;
391
+ const warn = warning => {
392
+ if (Array.isArray(opts.warnings)) {
393
+ addWarning(opts.warnings, warning);
394
+ }
395
+ };
396
+
171
397
  let headerRows = (headers && headers.parsed) || [];
172
398
 
173
399
  let arcChain = new Map();
174
400
  for (let row of headerRows) {
175
- if (['arc-seal', 'arc-message-signature', 'arc-authentication-results'].includes(row.key)) {
176
- let value = parseDkimHeaders(row.line);
177
- let instance = value?.parsed?.i?.value;
401
+ if (ARC_HEADER_KEYS.includes(row.key)) {
402
+ // RFC 6376 section 3.2: "Tags MUST be interpreted in a case-sensitive manner", so
403
+ // the strict mode keys the tags of the ARC-Seal and ARC-Message-Signature by name
404
+ // as written
405
+ let value = parseDkimHeaders(row.line, { strict });
406
+
407
+ let rawInstance = getRawInstance(row.key, value, row.line);
408
+ let validInstance =
409
+ typeof rawInstance === 'string' && INSTANCE_VALUE.test(rawInstance) && Number(rawInstance) >= 1 && Number(rawInstance) <= MAX_ARC_INSTANCE;
410
+
411
+ let instance;
412
+ if (strict) {
413
+ if (!validInstance) {
414
+ // RFC 8617 section 3.9, instance = "i=" 1*2DIGIT, 1 to 50. A header field
415
+ // with any other instance is not part of a valid chain
416
+ throw createError(`invalid ${SHORT_NAMES[row.key]} instance`, 'invalid_arc_instance');
417
+ }
418
+ instance = Number(rawInstance);
419
+ } else {
420
+ // the lenient mode reads the instance as a number, and ignores ARC header
421
+ // fields that have none
422
+ instance = value?.parsed?.i?.value;
423
+ if (!validInstance) {
424
+ warn('arc-instance-syntax');
425
+ }
426
+ }
427
+
178
428
  if (instance) {
179
429
  if (!arcChain.has(instance)) {
180
430
  arcChain.set(instance, {
@@ -182,9 +432,7 @@ const getARChain = headers => {
182
432
  });
183
433
  } else if (arcChain.get(instance)[row.key]) {
184
434
  // value for this header is already set
185
- let err = new Error(`i=${instance} multiple ${row.key} values`);
186
- err.code = 'multiple_arc_keys';
187
- throw err;
435
+ throw createError(`i=${instance} multiple ${row.key} values`, 'multiple_arc_keys');
188
436
  }
189
437
  arcChain.get(instance)[row.key] = value;
190
438
  }
@@ -197,88 +445,75 @@ const getARChain = headers => {
197
445
  return false;
198
446
  }
199
447
 
200
- if (arcChain.length > 50) {
201
- let err = new Error(`chain-length=${arcChain.length}`);
202
- err.code = 'invalid_arc_count';
203
- throw err;
448
+ if (arcChain.length > MAX_ARC_INSTANCE) {
449
+ throw createError(`chain-length=${arcChain.length}`, 'invalid_arc_count');
204
450
  }
205
451
 
206
452
  for (let i = 0; i < arcChain.length; i++) {
207
453
  const arcInstance = arcChain[i];
454
+ const isNewest = i === arcChain.length - 1;
208
455
 
209
456
  if (arcInstance.i !== i + 1) {
210
457
  // not a complete sequence
211
- let err = new Error(`i=${arcInstance.i} expected=${i + 1}`);
212
- err.code = 'invalid_arc_instance';
213
- throw err;
458
+ throw createError(`i=${arcInstance.i} expected=${i + 1}`, 'invalid_arc_instance');
214
459
  }
215
460
 
216
- for (let headerKey of ['arc-seal', 'arc-message-signature', 'arc-authentication-results']) {
461
+ for (let headerKey of ARC_HEADER_KEYS) {
217
462
  if (!arcInstance[headerKey]) {
218
463
  // missing required header
219
- let err = new Error(`i=${arcInstance.i} no ${headerKey} set`);
220
- err.code = 'missing_arc_header';
221
- throw err;
464
+ throw createError(`i=${arcInstance.i} no ${headerKey} set`, 'missing_arc_header');
222
465
  }
223
466
  }
224
467
 
225
- if (i === 0 && arcInstance['arc-seal']?.parsed?.cv?.value?.toLowerCase() !== 'none') {
226
- let err = new Error(`i=1 cv="${arcInstance['arc-seal']?.parsed?.cv?.value}`);
227
- err.code = 'invalid_cv_value';
228
- throw err;
468
+ // Every seal is validated, so the syntax of every seal counts. Only the newest
469
+ // ARC-Message-Signature is validated (step 4), the older ones are just sealed data
470
+ checkTagList(arcInstance, 'arc-seal', strict, warn);
471
+ if (isNewest) {
472
+ checkTagList(arcInstance, 'arc-message-signature', strict, warn);
229
473
  }
230
474
 
231
- let asC = arcInstance['arc-seal']?.parsed?.c?.value?.toLowerCase().trim();
232
- if (asC && asC !== 'relaxed/relaxed') {
233
- let err = new Error(`i=${arcInstance.i} invalid as c`);
234
- err.code = 'unexpected_as_c_value';
235
- throw err;
236
- }
475
+ let cv = getCv(arcInstance);
237
476
 
238
- // add missing c value
239
- if (arcInstance['arc-message-signature']?.parsed && !arcInstance['arc-message-signature']?.parsed?.c) {
240
- arcInstance['arc-message-signature'].parsed.c = { value: 'relaxed/relaxed' };
477
+ if (i === 0 && cv !== 'none') {
478
+ throw createError(`i=1 cv="${arcInstance['arc-seal']?.parsed?.cv?.value}`, 'invalid_cv_value');
241
479
  }
242
480
 
481
+ // c= is not checked here. An ARC-Seal has no c= tag (RFC 8617 section 4.1.3, it always
482
+ // uses relaxed header canonicalization), so a c= tag on it is an unknown tag, and
483
+ // unknown tags are ignored (RFC 6376 section 3.2). The newest ARC-Message-Signature is
484
+ // verified by the DKIM verifier, where a missing c= means simple/simple (RFC 6376
485
+ // section 3.5) and the lenient mode also tries relaxed/relaxed
486
+
243
487
  if (arcInstance['arc-seal']?.parsed?.a && !arcInstance['arc-seal']?.parsed?.a?.value) {
244
- let err = new Error(`i=${arcInstance.i} empty a`);
245
- err.code = 'invalid_a_value';
246
- throw err;
488
+ throw createError(`i=${arcInstance.i} empty a`, 'invalid_a_value');
247
489
  }
248
490
 
249
491
  if (!arcInstance['arc-seal']?.parsed?.a?.value) {
250
- let err = new Error(`i=${arcInstance.i} missing a`);
251
- err.code = 'missing_a_value';
252
- throw err;
492
+ throw createError(`i=${arcInstance.i} missing a`, 'missing_a_value');
253
493
  }
254
494
 
255
495
  // throws if using non-supported algorithm
256
496
  validateAlgorithm(arcInstance['arc-seal']?.parsed?.a?.value, true);
257
497
  validateAlgorithm(arcInstance['arc-message-signature']?.parsed?.a?.value, true);
258
498
 
259
- if (i > 0 && arcInstance['arc-seal']?.parsed?.cv?.value?.toLowerCase() !== 'pass') {
260
- let err = new Error(`i=${arcInstance.i} cv=${arcInstance['arc-seal']?.parsed?.cv?.value}`);
261
- err.code = 'invalid_cv_value';
262
- throw err;
499
+ if (i > 0 && cv !== 'pass') {
500
+ throw createError(`i=${arcInstance.i} cv=${arcInstance['arc-seal']?.parsed?.cv?.value}`, 'invalid_cv_value');
263
501
  }
264
502
 
265
503
  if (arcInstance['arc-seal']?.parsed?.h) {
266
- let err = new Error(`i=${arcInstance.i} unexpected as h`);
267
- err.code = 'unexpected_as_h_value';
268
- throw err;
504
+ throw createError(`i=${arcInstance.i} unexpected as h`, 'unexpected_as_h_value');
269
505
  }
270
506
 
271
507
  let amsH = arcInstance['arc-message-signature']?.parsed?.h?.value
272
- ?.trim()
508
+ ?.toString()
509
+ .trim()
273
510
  .toLowerCase()
274
511
  .split(':')
275
512
  .map(v => v.trim())
276
513
  .filter(v => v);
277
514
 
278
515
  if (amsH?.some(v => v === 'arc-seal')) {
279
- let err = new Error(`i=${arcInstance.i} invalid ams h`);
280
- err.code = 'invalid_ams_h_value';
281
- throw err;
516
+ throw createError(`i=${arcInstance.i} invalid ams h`, 'invalid_ams_h_value');
282
517
  }
283
518
  }
284
519
 
@@ -287,6 +522,9 @@ const getARChain = headers => {
287
522
 
288
523
  // {chain, last}
289
524
  const arc = async (data, opts) => {
525
+ opts = opts || {};
526
+ const strict = !!opts.strict;
527
+
290
528
  const status = {
291
529
  result: 'none'
292
530
  };
@@ -300,24 +538,35 @@ const arc = async (data, opts) => {
300
538
  value: data.chain
301
539
  });
302
540
 
541
+ // what the lenient mode accepted that the strict mode would have rejected
542
+ const warnings = [].concat(data?.warnings || []);
543
+
303
544
  try {
304
545
  if (data.error) {
305
546
  // raise error from `getARChain`
306
547
  throw data.error;
307
548
  }
308
549
 
309
- let hasChain = await verifyASChain(data, opts);
550
+ let hasChain = await verifyASChain(data, {
551
+ resolver: opts.resolver,
552
+ minBitLength: opts.minBitLength,
553
+ strict,
554
+ warnings,
555
+ // the key lookups of the DKIM verifier, when they used the same resolver
556
+ keyCache: data.keyCache?.resolver === opts.resolver ? data.keyCache : undefined
557
+ });
558
+
310
559
  if (hasChain) {
311
560
  result.i = data?.lastEntry?.i || false;
312
561
  result.signature = data?.lastEntry?.messageSignature || false;
313
562
 
314
563
  if (result?.signature?.status?.result !== 'pass') {
315
564
  // no valid ARC-Message-Signature found
316
- let err = new Error(`i=${result.i} no valid signature`);
317
- err.code = 'missing_valid_ams';
318
- throw err;
565
+ throw createError(`i=${result.i} no valid signature`, 'missing_valid_ams');
319
566
  }
320
567
 
568
+ addWarning(warnings, ...(result.signature.status.warnings || []));
569
+
321
570
  result.authenticationResults = data?.lastEntry?.['arc-authentication-results']?.parsed;
322
571
 
323
572
  if (result.authenticationResults) {
@@ -357,7 +606,7 @@ const arc = async (data, opts) => {
357
606
  result.i = data?.lastEntry?.i || false;
358
607
  status.result = 'fail';
359
608
  // if last entry was listed as passing then add our seal even if the validation failed
360
- status.shouldSeal = ['pass', 'none'].includes(data?.lastEntry?.['arc-seal']?.parsed?.cv?.value);
609
+ status.shouldSeal = ['pass', 'none'].includes(getCv(data?.lastEntry));
361
610
 
362
611
  switch (err.code) {
363
612
  case 'invalid_arc_seal':
@@ -370,8 +619,7 @@ const arc = async (data, opts) => {
370
619
  case 'unexpected_as_h_value':
371
620
  case 'invalid_ams_h_value':
372
621
  case 'missing_valid_ams':
373
- case 'unexpected_as_c_value':
374
- case 'unexpected_ams_c_value':
622
+ case 'invalid_arc_tags':
375
623
  status.comment = err.message
376
624
  .toLowerCase()
377
625
  .replace(/["'()]/g, ' ')
@@ -382,26 +630,19 @@ const arc = async (data, opts) => {
382
630
 
383
631
  case 'ENOTFOUND':
384
632
  case 'ENODATA':
385
- if (err.queryDomain) {
386
- status.comment = `no key for ${err.queryDomain}`;
387
- }
388
- break;
389
-
390
633
  case 'EINVALIDVER':
391
- if (err.queryDomain) {
392
- status.comment = `unknown key version for ${err.queryDomain}`;
393
- }
394
- break;
395
-
396
634
  case 'EINVALIDTYPE':
635
+ case 'EINVALIDVAL':
636
+ case 'EINVALIDHASH':
637
+ case 'EINVALIDSERVICE':
397
638
  if (err.queryDomain) {
398
- status.comment = `unknown key type for ${err.queryDomain}`;
639
+ status.comment = `${KEY_ERROR_COMMENTS.get(err.code)} for ${err.queryDomain}`;
399
640
  }
400
641
  break;
401
642
 
402
- case 'EINVALIDVAL':
643
+ case 'EINVALIDKEYALGO':
403
644
  if (err.queryDomain) {
404
- status.comment = `invalid public key for ${err.queryDomain}`;
645
+ status.comment = `inappropriate key algorithm for ${err.queryDomain}`;
405
646
  }
406
647
  break;
407
648
 
@@ -414,7 +655,14 @@ const arc = async (data, opts) => {
414
655
  }
415
656
  }
416
657
 
417
- if (status.result !== 'none') {
658
+ if (strict && opts.ip && isIP(opts.ip.toString())) {
659
+ // RFC 8617 section 6: smtp.remote-ip SHOULD be recorded
660
+ status.smtp = { 'remote-ip': opts.ip.toString() };
661
+ }
662
+
663
+ // The strict mode reports arc=none as well (RFC 8617 section 5.2 step 1 and section 6), the
664
+ // lenient mode leaves it out of the Authentication-Results header as it always has
665
+ if (status.result !== 'none' || strict) {
418
666
  if (status.result === 'pass' && result.authenticationResults) {
419
667
  let comment = [`i=${result.i}`, result.authenticationResults.spf ? `spf=${result.authenticationResults.spf.result}` : false];
420
668
 
@@ -438,18 +686,83 @@ const arc = async (data, opts) => {
438
686
  status.comment = comment.filter(v => v).join(' ');
439
687
  }
440
688
 
441
- result.info = formatAuthHeaderRow('arc', status);
689
+ result.info = formatAuthHeaderRow('arc', status, { strict });
690
+ }
691
+
692
+ if (warnings.length && status.result !== 'fail') {
693
+ // never rendered into the Authentication-Results header
694
+ result.warnings = warnings;
442
695
  }
443
696
 
444
697
  return result;
445
698
  };
446
699
 
700
+ const sealError = (seal, err) => ({
701
+ type: 'ARC',
702
+ selector: seal?.selector,
703
+ signingDomain: seal?.signingDomain,
704
+ err
705
+ });
706
+
707
+ // whether an option has a value, false and an empty string mean that it is not set
708
+ const isSet = value => typeof value !== 'undefined' && value !== null && value !== false && value !== '';
709
+
710
+ /**
711
+ * Creates an ARC set (RFC 8617 section 5.1) for a message
712
+ *
713
+ * @param {ReadableStream|Buffer|String|false} input The message, or false to use `data.headers`
714
+ * and `seal.bodyHash` instead
715
+ * @param {Object} data { headers, arc, seal, strict }
716
+ * @returns {Object} { headers, errors, warnings }. `headers` is empty when no set was created,
717
+ * and `errors` says why
718
+ */
447
719
  const createSeal = async (input, data) => {
448
720
  let { headers, arc, seal } = data;
449
721
  let bodyHash = seal?.bodyHash;
450
722
 
723
+ const strict = !!(data.strict || seal?.strict);
724
+ const warnings = [];
725
+ const warn = warning => addWarning(warnings, warning);
726
+
727
+ let dkimSigner;
728
+ // the signer's warnings first, then the ones of the seal, each listed once
729
+ const collectWarnings = () => addWarning([].concat(dkimSigner?.warnings || []), ...warnings);
730
+
731
+ const refuse = (message, code) => ({ headers: [], errors: [sealError(seal, createError(message, code))], warnings: collectWarnings() });
732
+
733
+ // Caller input that can only produce an ARC set every validator rejects is refused in
734
+ // every mode
735
+ let explicitInstance = isSet(seal.i);
736
+ if (explicitInstance) {
737
+ let instance = Number(seal.i);
738
+ if (!Number.isInteger(instance) || instance < 1 || instance > MAX_ARC_INSTANCE) {
739
+ // RFC 8617 section 4.2.1, instance values range from 1 to 50
740
+ return refuse(`ARC instance ${JSON.stringify(seal.i)} is out of range (1-${MAX_ARC_INSTANCE})`, 'EINVALIDINSTANCE');
741
+ }
742
+ seal.i = instance;
743
+ }
744
+
745
+ let cv = false;
746
+ if (isSet(seal.cv)) {
747
+ cv = seal.cv.toString().toLowerCase().trim();
748
+ if (!CHAIN_STATUS.includes(cv)) {
749
+ return refuse(`Invalid cv value ${JSON.stringify(seal.cv)} (expecting "none", "pass" or "fail")`, 'EINVALIDCV');
750
+ }
751
+ }
752
+
753
+ let algorithm = false;
754
+ if (seal.algorithm) {
755
+ algorithm = seal.algorithm.toString().toLowerCase().trim();
756
+ try {
757
+ // rsa-sha256 or ed25519-sha256, no validator accepts an ARC signature made with sha1
758
+ validateAlgorithm(algorithm, true);
759
+ } catch (err) {
760
+ return refuse(`Unsupported ARC algorithm ${JSON.stringify(seal.algorithm)}`, 'EINVALIDALGO');
761
+ }
762
+ }
763
+
451
764
  // Step 1. Calculate ARC-Message-Signature
452
- let dkimSigner = new DkimSigner({
765
+ dkimSigner = new DkimSigner({
453
766
  // headers and bodyHash are prepared values if we do not have the source message anymore
454
767
  headers,
455
768
  bodyHash: seal.bodyHash,
@@ -459,15 +772,22 @@ const createSeal = async (input, data) => {
459
772
  // which headers to sign
460
773
  headerList: seal.headerList,
461
774
 
775
+ strict,
776
+
462
777
  arc: {
463
- instance: seal.i, // overriden if not set
464
- algorithm: seal.algorithm,
778
+ instance: seal.i,
779
+ // follows the key type when not set
780
+ algorithm,
465
781
  signingDomain: seal.signingDomain,
466
782
  selector: seal.selector,
467
783
  privateKey: seal.privateKey
468
784
  },
469
785
 
470
- getARChain // pass as a property so we do not have to use circular require()
786
+ getARChain, // pass as a property so we do not have to use circular require()
787
+
788
+ // a set without an explicit instance follows the highest existing one, the same
789
+ // value is used for the ARC-Seal below
790
+ getArcInstance: hdrs => getArcPosition(hdrs, (arc || dkimSigner.arc)?.chain).highest + 1
471
791
  });
472
792
 
473
793
  if (input) {
@@ -480,14 +800,65 @@ const createSeal = async (input, data) => {
480
800
 
481
801
  bodyHash = dkimSigner.bodyHashes.get(hashKey)?.hash;
482
802
 
803
+ headers = dkimSigner.headers;
483
804
  arc = arc || dkimSigner.arc;
484
- seal.i = arc.instance;
485
805
  } else {
486
806
  // this gives us dkimSigner.arc.messageSignature
487
807
  await dkimSigner.finalize();
488
808
  }
489
809
 
490
- seal.i = seal.i || 1;
810
+ let { highest, latestCv } = getArcPosition(headers, arc?.chain);
811
+
812
+ if (!explicitInstance) {
813
+ if (highest >= MAX_ARC_INSTANCE) {
814
+ // RFC 8617 section 4.2.1, instance values range from 1 to 50. A 51st set would make
815
+ // the whole chain invalid for every validator, so the message is not sealed
816
+ return refuse(`Can not add ARC set i=${highest + 1}, the chain already has ${highest} sets`, 'EINVALIDINSTANCE');
817
+ }
818
+ seal.i = highest + 1;
819
+ }
820
+
821
+ // RFC 8617 section 5.1 step 2: a chain whose newest seal says cv=fail is not sealed again
822
+ if (latestCv === 'fail') {
823
+ return refuse(`The newest ARC-Seal (i=${highest}) has cv=fail, the chain can not be sealed again`, 'EARCCHAINFAILED');
824
+ }
825
+
826
+ // RFC 8617 section 5.1 step 3: the next instance follows the highest existing one
827
+ if (seal.i <= highest) {
828
+ // a second set with the same instance makes the whole chain invalid
829
+ return refuse(`ARC instance ${seal.i} already exists on the message (highest instance ${highest})`, 'EINVALIDINSTANCE');
830
+ }
831
+
832
+ if (seal.i !== highest + 1) {
833
+ // an explicit instance that leaves a gap, which no validator accepts
834
+ if (strict) {
835
+ return refuse(`ARC instance ${seal.i} does not follow the highest existing instance ${highest}`, 'EINVALIDINSTANCE');
836
+ }
837
+ warn('arc-instance-gap');
838
+ }
839
+
840
+ if (!cv) {
841
+ if (seal.i !== 1) {
842
+ // there is no way to tell here whether the chain validated
843
+ return refuse(`A cv value is required for ARC instance ${seal.i}`, 'EINVALIDCV');
844
+ }
845
+ // RFC 8617 section 4.4, there was no chain
846
+ cv = 'none';
847
+ }
848
+
849
+ if ((seal.i === 1) !== (cv === 'none')) {
850
+ // RFC 8617 section 5.2 step 3.C: cv=none for i=1, pass or fail for every later set
851
+ if (strict) {
852
+ return refuse(`cv=${cv} is not valid for ARC instance ${seal.i}`, 'EINVALIDCV');
853
+ }
854
+ warn('arc-cv-instance');
855
+ }
856
+
857
+ if (cv === 'pass' && seal.i > 1 && !arc?.chain?.length) {
858
+ // a cv=pass seal signs the whole chain (RFC 8617 section 5.1.1), and there is no valid
859
+ // chain on the message to sign
860
+ return refuse(`cv=pass needs a valid ARC chain to seal`, 'EINVALIDCV');
861
+ }
491
862
 
492
863
  const messageSignature = dkimSigner.arc?.messageSignature;
493
864
  if (!messageSignature) {
@@ -495,33 +866,43 @@ const createSeal = async (input, data) => {
495
866
  // being the usual reason. There is no ARC set to seal then: sealing anyway hashes
496
867
  // a header that does not exist and puts an empty line into the header block, which
497
868
  // ends it early for everything downstream
498
- return { headers: [], errors: dkimSigner.errors };
869
+ return { headers: [], errors: dkimSigner.errors, warnings: collectWarnings() };
499
870
  }
500
871
 
501
872
  const authResults = `ARC-Authentication-Results: i=${seal.i}; ${seal.authResults}`;
502
873
 
874
+ // the ARC-Seal uses the algorithm of the ARC-Message-Signature, which follows the key type
875
+ // when no algorithm was given
876
+ let sealAlgorithm = (parseDkimHeaders(messageSignature)?.parsed?.a?.value || algorithm || 'rsa-sha256').toString().toLowerCase();
877
+
503
878
  // Step 2. Calculate ARC-Seal
504
- const arcSeal = await signAS(
505
- arc.chain,
506
- {
507
- 'arc-authentication-results': authResults,
508
- 'arc-message-signature': messageSignature
509
- },
510
- {
511
- instance: seal.i,
512
- algorithm: seal.algorithm || 'rsa-sha256',
513
- signingDomain: seal.signingDomain,
514
- selector: seal.selector,
515
- bodyHash,
516
- cv: seal.cv,
517
- signTime: seal.signTime,
518
- privateKey: seal.privateKey
519
- }
520
- );
879
+ let arcSeal;
880
+ try {
881
+ arcSeal = await signAS(
882
+ arc?.chain || [],
883
+ {
884
+ 'arc-authentication-results': authResults,
885
+ 'arc-message-signature': messageSignature
886
+ },
887
+ {
888
+ instance: seal.i,
889
+ algorithm: sealAlgorithm,
890
+ signingDomain: seal.signingDomain,
891
+ selector: seal.selector,
892
+ bodyHash,
893
+ cv,
894
+ signTime: seal.signTime,
895
+ privateKey: seal.privateKey
896
+ }
897
+ );
898
+ } catch (err) {
899
+ return { headers: [], errors: dkimSigner.errors.concat(sealError(seal, err)), warnings: collectWarnings() };
900
+ }
521
901
 
522
902
  return {
523
903
  headers: [arcSeal, messageSignature, authResults],
524
- errors: dkimSigner.errors
904
+ errors: dkimSigner.errors,
905
+ warnings: collectWarnings()
525
906
  };
526
907
  };
527
908