mailauth 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +99 -0
- package/bin/mailauth.js +62 -0
- package/cli.md +149 -7
- package/docs/dkim2.md +154 -0
- package/index.d.ts +359 -0
- package/lib/commands/dkim2-hash.js +25 -0
- package/lib/commands/dkim2-sign.js +77 -0
- package/lib/commands/dkim2-verify.js +59 -0
- package/lib/commands/report.js +8 -33
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +2 -32
- package/lib/commands/spf.js +2 -33
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim2/dkim2-parser.js +52 -0
- package/lib/dkim2/dkim2-signer.js +334 -0
- package/lib/dkim2/dkim2-verifier.js +572 -0
- package/lib/dkim2/fields.js +570 -0
- package/lib/dkim2/hash.d.ts +3 -0
- package/lib/dkim2/hash.js +51 -0
- package/lib/dkim2/key.js +203 -0
- package/lib/dkim2/message-state.js +181 -0
- package/lib/dkim2/recipe.js +134 -0
- package/lib/dkim2/sign.d.ts +3 -0
- package/lib/dkim2/sign.js +38 -0
- package/lib/dkim2/verify.d.ts +3 -0
- package/lib/dkim2/verify.js +19 -0
- package/lib/mailauth.js +40 -0
- package/lib/tools.js +1 -0
- package/package.json +3 -3
- package/stryker.config.json +2 -2
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// DKIM2 verifier (draft-ietf-dkim-dkim2-spec-06 sections 10 and 11)
|
|
4
|
+
|
|
5
|
+
const { Buffer } = require('node:buffer');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { formatAuthHeaderRow, getCurTime, isSameOrSubdomain } = require('../tools');
|
|
8
|
+
const { MI_KEY, SIG_KEY, HASH_ALGORITHMS, parseMessageInstance, parseSignature, parsePath, continuesCustody, buildSignatureInput } = require('./fields');
|
|
9
|
+
const { Dkim2Parser } = require('./dkim2-parser');
|
|
10
|
+
const { createKeyLookup, KeyError, ALGORITHM_KEY_TYPES, signatureData } = require('./key');
|
|
11
|
+
|
|
12
|
+
// draft-ietf-dkim-dkim2-bcp-01 section 7.5 recommends a local ceiling on the number of
|
|
13
|
+
// Message-Instance and DKIM2-Signature header fields, with a PERMERROR above it
|
|
14
|
+
const DEFAULT_MAX_INSTANCES = 20;
|
|
15
|
+
// section 11.3: a failure when a signature is more than 14 days old
|
|
16
|
+
const DEFAULT_MAX_SIGNATURE_AGE = 14 * 24 * 3600;
|
|
17
|
+
// section 8.4: signatures from the future MAY be ignored. Five minutes allows for clock skew
|
|
18
|
+
const DEFAULT_MAX_FUTURE_TIME = 5 * 60;
|
|
19
|
+
|
|
20
|
+
// the order in which a result wins over the others, FAIL first so that a cryptographic failure is
|
|
21
|
+
// never reported as a TEMPERROR (section 10.4)
|
|
22
|
+
const RESULT_ORDER = ['fail', 'permerror', 'temperror', 'pass'];
|
|
23
|
+
|
|
24
|
+
const pickResult = results => RESULT_ORDER.find(result => results.includes(result)) || 'pass';
|
|
25
|
+
|
|
26
|
+
// a number from a header field for an error string, "?" when it could not be read
|
|
27
|
+
const ordinal = value => (Number.isSafeInteger(value) ? value : '?');
|
|
28
|
+
|
|
29
|
+
const verifySignature = (algorithm, input, publicKey, signature) => {
|
|
30
|
+
try {
|
|
31
|
+
return crypto.verify(...signatureData(algorithm, input), publicKey, signature);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// the first index from 1 that is missing from a map keyed by numbers
|
|
38
|
+
const firstGap = map => {
|
|
39
|
+
let pos = 1;
|
|
40
|
+
while (map.has(pos)) {
|
|
41
|
+
pos++;
|
|
42
|
+
}
|
|
43
|
+
return pos;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
class Dkim2Verifier extends Dkim2Parser {
|
|
47
|
+
/**
|
|
48
|
+
* @param {Object} [options]
|
|
49
|
+
* @param {Function} [options.resolver] DNS resolver
|
|
50
|
+
* @param {String} [options.mailFrom] SMTP MAIL FROM of the delivery, "" for the null path. When
|
|
51
|
+
* set, it is checked against the mf= of the highest DKIM2-Signature (section 11.4)
|
|
52
|
+
* @param {String|String[]} [options.rcptTo] SMTP RCPT TO addresses of the delivery, each of
|
|
53
|
+
* which has to be listed in rt= of the highest DKIM2-Signature
|
|
54
|
+
* @param {Number|Boolean} [options.maxSignatureAge=1209600] Seconds after t= when a signature
|
|
55
|
+
* expires (section 11.3), false to not check
|
|
56
|
+
* @param {Number|Boolean} [options.maxFutureTime=300] Seconds a t= may be ahead of the current
|
|
57
|
+
* time (section 8.4), false to not check
|
|
58
|
+
* @param {Date|Number|String} [options.curTime] Time to verify against, defaults to now
|
|
59
|
+
* @param {Number} [options.maxInstances=20] Most Message-Instance, and most DKIM2-Signature,
|
|
60
|
+
* header fields processed for one message
|
|
61
|
+
* @param {Function} [options.checkReplay] async ({ key, exploded }) => true when a message with
|
|
62
|
+
* the same m=1 hashes was seen before (section 11.9)
|
|
63
|
+
*/
|
|
64
|
+
constructor(options) {
|
|
65
|
+
super();
|
|
66
|
+
this.options = options || {};
|
|
67
|
+
this.result = null;
|
|
68
|
+
|
|
69
|
+
this.maxInstances = this.options.maxInstances || DEFAULT_MAX_INSTANCES;
|
|
70
|
+
this.instanceRows = [];
|
|
71
|
+
this.signatureRows = [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// too many DKIM2 header fields to process, which verify() reports
|
|
75
|
+
get overLimit() {
|
|
76
|
+
return this.instanceRows.length > this.maxInstances || this.signatureRows.length > this.maxInstances;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async messageHeaders(headers) {
|
|
80
|
+
let rows = headers.parsed;
|
|
81
|
+
this.instanceRows = rows.filter(row => row.key === MI_KEY).map(row => ({ line: row.line }));
|
|
82
|
+
this.signatureRows = rows.filter(row => row.key === SIG_KEY).map(row => ({ line: row.line }));
|
|
83
|
+
|
|
84
|
+
if ((!this.instanceRows.length && !this.signatureRows.length) || this.overLimit) {
|
|
85
|
+
// nothing to verify, or nothing that will be verified, so the body is not hashed at all
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let row of this.signatureRows) {
|
|
90
|
+
row.parsed = parseSignature(row.line);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (let row of this.instanceRows) {
|
|
94
|
+
row.parsed = parseMessageInstance(row.line);
|
|
95
|
+
// hash the body with every algorithm a Message-Instance uses
|
|
96
|
+
for (let { algorithm } of row.parsed.hashes) {
|
|
97
|
+
if (HASH_ALGORITHMS.has(algorithm)) {
|
|
98
|
+
this.hashBodyWith(algorithm);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(row.parsed.recipe?.b)) {
|
|
102
|
+
// a body Recipe copies lines from the body, so the body has to be kept
|
|
103
|
+
this.keepBody();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async finalChunk() {
|
|
109
|
+
this.result = await this.verify();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async verify() {
|
|
113
|
+
let errors = [];
|
|
114
|
+
let instances = new Map();
|
|
115
|
+
let signatures = new Map();
|
|
116
|
+
let instanceResults = [];
|
|
117
|
+
let signatureResults = new Map();
|
|
118
|
+
|
|
119
|
+
// the DKIM2-Signature a failure of a Message-Instance is reported against, the one with
|
|
120
|
+
// the lowest i= that covers it (draft-gondwana-dkim2-authres-00 section 3.2.2)
|
|
121
|
+
const signatureFor = m => {
|
|
122
|
+
for (let i = 1; signatures.has(i); i++) {
|
|
123
|
+
if (signatures.get(i).m === m) {
|
|
124
|
+
return i;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const addError = (result, message, ref) => {
|
|
131
|
+
let entry = { result, message };
|
|
132
|
+
if (Number.isSafeInteger(ref?.i)) {
|
|
133
|
+
entry.i = ref.i;
|
|
134
|
+
}
|
|
135
|
+
if (Number.isSafeInteger(ref?.m)) {
|
|
136
|
+
entry.m = ref.m;
|
|
137
|
+
let i = signatureFor(ref.m);
|
|
138
|
+
if (i) {
|
|
139
|
+
entry.i = i;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
errors.push(entry);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const finish = replay => this.formatResult({ errors, signatures, signatureResults, instanceResults, replay });
|
|
146
|
+
|
|
147
|
+
if (!this.instanceRows.length && !this.signatureRows.length) {
|
|
148
|
+
return this.formatResult({ none: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (this.overLimit) {
|
|
152
|
+
addError('permerror', `Message has more than ${this.maxInstances} Message-Instance or DKIM2-Signature header fields`);
|
|
153
|
+
return finish();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// section 11.2: every field is valid, numbered without gaps, and every instance is signed
|
|
157
|
+
for (let { line, parsed } of this.instanceRows) {
|
|
158
|
+
if (parsed.error) {
|
|
159
|
+
addError('permerror', `Message-Instance m=${ordinal(parsed.m)} ${parsed.error}`);
|
|
160
|
+
} else if (instances.has(parsed.m)) {
|
|
161
|
+
addError('permerror', `Message-Instance m=${parsed.m} appears more than once`);
|
|
162
|
+
} else {
|
|
163
|
+
instances.set(parsed.m, Object.assign({ line }, parsed));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (let { line, parsed } of this.signatureRows) {
|
|
168
|
+
if (parsed.error) {
|
|
169
|
+
addError('permerror', `DKIM2-Signature i=${ordinal(parsed.i)} ${parsed.error}`, { i: parsed.i });
|
|
170
|
+
} else if (signatures.has(parsed.i)) {
|
|
171
|
+
addError('permerror', `DKIM2-Signature i=${parsed.i} appears more than once`, { i: parsed.i });
|
|
172
|
+
} else {
|
|
173
|
+
signatures.set(parsed.i, Object.assign({ line }, parsed));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (errors.length) {
|
|
178
|
+
return finish();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let maxM = Math.max(0, ...instances.keys());
|
|
182
|
+
let maxI = Math.max(0, ...signatures.keys());
|
|
183
|
+
|
|
184
|
+
let missingM = firstGap(instances);
|
|
185
|
+
if (missingM <= maxM || !maxM) {
|
|
186
|
+
addError('permerror', `Message-Instance m=${missingM} missing`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let missingI = firstGap(signatures);
|
|
190
|
+
if (missingI <= maxI || !maxI) {
|
|
191
|
+
addError('permerror', `DKIM2-Signature i=${missingI} missing`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (errors.length) {
|
|
195
|
+
return finish();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (let [i, signature] of signatures) {
|
|
199
|
+
if (signature.m > maxM) {
|
|
200
|
+
addError('permerror', `Message-Instance m=${signature.m} missing`, { i });
|
|
201
|
+
return finish();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let maxSignedM = Math.max(...Array.from(signatures.values()).map(signature => signature.m));
|
|
206
|
+
if (maxM > maxSignedM) {
|
|
207
|
+
addError('permerror', `Message-Instance m=${maxM} is not signed`);
|
|
208
|
+
return finish();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// recreate every instance of the message by applying the Recipes from the top down
|
|
212
|
+
let current = this.currentState();
|
|
213
|
+
|
|
214
|
+
let states = new Map([[maxM, current]]);
|
|
215
|
+
for (let m = maxM; m > 1; m--) {
|
|
216
|
+
let state = states.get(m);
|
|
217
|
+
let recipe = instances.get(m).recipe;
|
|
218
|
+
if (recipe) {
|
|
219
|
+
try {
|
|
220
|
+
state = state.previous(recipe);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
addError('permerror', `Message-Instance m=${m} contains invalid JSON: ${err.message}`, { m });
|
|
223
|
+
return finish();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
states.set(m - 1, state);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// section 11.7: the hashes of every instance
|
|
230
|
+
for (let m = 1; m <= maxM; m++) {
|
|
231
|
+
let instance = instances.get(m);
|
|
232
|
+
let state = states.get(m);
|
|
233
|
+
let hashResults = [];
|
|
234
|
+
|
|
235
|
+
for (let { algorithm, headerHash, bodyHash } of instance.hashes) {
|
|
236
|
+
if (!HASH_ALGORITHMS.has(algorithm)) {
|
|
237
|
+
// section 3.4: hashes with an unknown algorithm are ignored
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let headerResult = state.headerHash(algorithm) === headerHash ? 'pass' : 'fail';
|
|
242
|
+
if (headerResult === 'fail') {
|
|
243
|
+
addError('fail', `Message Instance m=${m} header hash ${algorithm} mismatch`, { m });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// a body before a null body Recipe can not be checked
|
|
247
|
+
let computedBodyHash = state.bodyHash(algorithm);
|
|
248
|
+
let bodyResult = computedBodyHash === null ? 'unknown' : computedBodyHash === bodyHash ? 'pass' : 'fail';
|
|
249
|
+
if (bodyResult === 'fail') {
|
|
250
|
+
addError('fail', `Message Instance m=${m} body hash ${algorithm} mismatch`, { m });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
hashResults.push({ algorithm, header: headerResult, body: bodyResult });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!hashResults.length) {
|
|
257
|
+
addError('permerror', `Message-Instance m=${m} has no supported hash algorithm`, { m });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let entry = { m, hashes: hashResults };
|
|
261
|
+
if (instance.recipe) {
|
|
262
|
+
entry.recipe = {
|
|
263
|
+
headers: Array.from(instance.recipe.h.keys()),
|
|
264
|
+
body: instance.recipe.b === undefined ? 'unchanged' : instance.recipe.b === null ? 'unrecoverable' : 'recipe'
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
instanceResults.push(entry);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// section 11.3
|
|
271
|
+
let maxAge = this.options.maxSignatureAge === undefined ? DEFAULT_MAX_SIGNATURE_AGE : this.options.maxSignatureAge;
|
|
272
|
+
let now = Math.floor(getCurTime(this.options.curTime).getTime() / 1000);
|
|
273
|
+
let maxFuture = this.options.maxFutureTime === undefined ? DEFAULT_MAX_FUTURE_TIME : this.options.maxFutureTime;
|
|
274
|
+
for (let [i, signature] of signatures) {
|
|
275
|
+
if (maxAge && now - signature.t > maxAge) {
|
|
276
|
+
addError('permerror', `DKIM2-Signature i=${i} signature expired`, { i });
|
|
277
|
+
}
|
|
278
|
+
// section 8.4: signatures from the future MAY be ignored, and draft-ietf-dkim-dkim2-bcp-01
|
|
279
|
+
// section 3.6 lets verifiers treat them with suspicion
|
|
280
|
+
if (typeof maxFuture === 'number' && signature.t - now > maxFuture) {
|
|
281
|
+
addError('permerror', `DKIM2-Signature i=${i} signature timestamp is in the future`, { i });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this.checkCustody(signatures, maxI, addError);
|
|
286
|
+
|
|
287
|
+
// sections 11.5 and 11.6, from the most recent signature down (section 10.1)
|
|
288
|
+
// the key lookups of every hop run at the same time, the results are recorded in order
|
|
289
|
+
let keyLookup = createKeyLookup(this.options.resolver);
|
|
290
|
+
let hopOrder = Array.from({ length: maxI }, (v, index) => maxI - index);
|
|
291
|
+
let hopResults = await Promise.all(hopOrder.map(i => this.verifyHop(signatures.get(i), instances, signatures, keyLookup)));
|
|
292
|
+
hopOrder.forEach((i, index) => {
|
|
293
|
+
let result = hopResults[index];
|
|
294
|
+
signatureResults.set(i, result);
|
|
295
|
+
if (result.status.result !== 'pass') {
|
|
296
|
+
addError(result.status.result, result.status.comment, { i });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// section 11.8
|
|
301
|
+
let compareAlgorithm = this.bodyHashers.keys().next().value;
|
|
302
|
+
let lastExploded = 0;
|
|
303
|
+
for (let [i, signature] of signatures) {
|
|
304
|
+
if (signature.flags.includes('exploded')) {
|
|
305
|
+
lastExploded = Math.max(lastExploded, i);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let donotmodify = Array.from(signatures.values()).filter(signature => signature.flags.includes('donotmodify'));
|
|
310
|
+
if (compareAlgorithm && donotmodify.some(signature => !states.get(signature.m).isPreservedIn(current, compareAlgorithm))) {
|
|
311
|
+
addError('fail', 'Message has been modified despite a donotmodify request');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (Array.from(signatures.values()).some(signature => signature.flags.includes('donotexplode') && signature.i < lastExploded)) {
|
|
315
|
+
addError('fail', 'Message has been exploded despite a donotexplode request');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// section 11.9: the m=1 hashes identify the message, the exploded flag allows copies
|
|
319
|
+
let firstHashes = instances.get(1).hashes;
|
|
320
|
+
let replayHash = firstHashes.find(hash => HASH_ALGORITHMS.has(hash.algorithm)) || firstHashes[0];
|
|
321
|
+
let replay = {
|
|
322
|
+
key: `${replayHash.algorithm}:${replayHash.headerHash}:${replayHash.bodyHash}`,
|
|
323
|
+
exploded: lastExploded > 0
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (typeof this.options.checkReplay === 'function') {
|
|
327
|
+
let duplicate = await this.options.checkReplay(Object.assign({}, replay));
|
|
328
|
+
if (duplicate && !replay.exploded) {
|
|
329
|
+
addError('fail', 'Duplicate message with no exploded flag');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return finish(replay);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Section 11.4 and the links between the signatures described in section 9.4
|
|
337
|
+
checkCustody(signatures, maxI, addError) {
|
|
338
|
+
for (let i = 1; i <= maxI; i++) {
|
|
339
|
+
let signature = signatures.get(i);
|
|
340
|
+
|
|
341
|
+
// section 8.8: the d= domain is the mf= domain or a parent of it, unless mf= is null
|
|
342
|
+
if (signature.mailFrom && signature.mailFrom !== '<>' && !isSameOrSubdomain(parsePath(signature.mailFrom).domain, signature.signingDomain)) {
|
|
343
|
+
addError('permerror', `DKIM2-Signature i=${i} MAIL FROM and d= do not match`, { i });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// section 8.7: nd= names the d= of the next signature exactly
|
|
347
|
+
let next = signatures.get(i + 1);
|
|
348
|
+
if (signature.nextDomain && next && next.signingDomain !== signature.nextDomain) {
|
|
349
|
+
addError('permerror', `DKIM2-Signature i=${i} MAIL nd= does not match`, { i });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// section 9.4: the MAIL FROM of every hop matches a RCPT TO of the hop before it
|
|
353
|
+
if (!continuesCustody(signatures.get(i - 1), signature.mailFrom, signature.signingDomain)) {
|
|
354
|
+
addError(
|
|
355
|
+
'permerror',
|
|
356
|
+
`DKIM2-Signature i=${i} MAIL FROM ${signature.mailFrom && signature.mailFrom !== '<>' ? signature.mailFrom : signature.signingDomain} did not match`,
|
|
357
|
+
{ i }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let { mailFrom, rcptList, hasMailFrom } = this.envelope();
|
|
363
|
+
if (!hasMailFrom && !rcptList.length) {
|
|
364
|
+
// the delivery is not known, so it can not be compared
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let top = signatures.get(maxI);
|
|
369
|
+
if (top.nextDomain) {
|
|
370
|
+
// section 9.3: without mf= and rt= the delivery can only be accepted on out-of-band information
|
|
371
|
+
addError('permerror', `DKIM2-Signature i=${maxI} unexpected nd= tag`, { i: maxI });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// section 11.4: an exact match, domains in lower case, local parts as they are
|
|
376
|
+
if (hasMailFrom && parsePath(mailFrom).address !== parsePath(top.mailFrom).address) {
|
|
377
|
+
addError('permerror', `DKIM2-Signature i=${maxI} MAIL FROM ${mailFrom} did not match`, { i: maxI });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let listed = new Set(top.rcptTo.map(path => parsePath(path).address));
|
|
381
|
+
for (let rcpt of rcptList) {
|
|
382
|
+
if (!listed.has(parsePath(rcpt).address)) {
|
|
383
|
+
addError('permerror', `DKIM2-Signature i=${maxI} RCPT TO ${rcpt} did not match`, { i: maxI });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// the SMTP envelope of the delivery, as far as the caller gave it
|
|
389
|
+
envelope() {
|
|
390
|
+
let { mailFrom, rcptTo } = this.options;
|
|
391
|
+
return {
|
|
392
|
+
mailFrom,
|
|
393
|
+
hasMailFrom: typeof mailFrom === 'string',
|
|
394
|
+
rcptList: [].concat(rcptTo || []).filter(value => typeof value === 'string')
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Without the envelope the highest DKIM2-Signature can not be compared with the delivery
|
|
399
|
+
// (section 11.4), and an unchanged copy of the message sent to anyone else passes as well
|
|
400
|
+
envelopeWarnings() {
|
|
401
|
+
let { hasMailFrom, rcptList } = this.envelope();
|
|
402
|
+
let warnings = [];
|
|
403
|
+
if (!hasMailFrom) {
|
|
404
|
+
warnings.push('mail-from-not-checked');
|
|
405
|
+
}
|
|
406
|
+
if (!rcptList.length) {
|
|
407
|
+
warnings.push('rcpt-to-not-checked');
|
|
408
|
+
}
|
|
409
|
+
return warnings;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Sections 9.6, 11.5 and 11.6 for one DKIM2-Signature
|
|
413
|
+
async verifyHop(signature, instances, signatures, keyLookup) {
|
|
414
|
+
let i = signature.i;
|
|
415
|
+
|
|
416
|
+
// the instances it covers, the signatures before it, and itself with the values emptied
|
|
417
|
+
let input = buildSignatureInput(
|
|
418
|
+
Array.from({ length: signature.m }, (v, index) => instances.get(index + 1).line),
|
|
419
|
+
Array.from({ length: i - 1 }, (v, index) => signatures.get(index + 1).line),
|
|
420
|
+
signature.line
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// start every key lookup before waiting for any of them
|
|
424
|
+
let lookups = signature.signatures.map(({ selector, algorithm }) =>
|
|
425
|
+
ALGORITHM_KEY_TYPES.has(algorithm) ? keyLookup(signature.signingDomain, selector, algorithm) : null
|
|
426
|
+
);
|
|
427
|
+
lookups.forEach(lookup => lookup?.catch(() => false));
|
|
428
|
+
|
|
429
|
+
let values = [];
|
|
430
|
+
for (let [index, { selector, algorithm, signature: value }] of signature.signatures.entries()) {
|
|
431
|
+
let entry = { selector, algorithm };
|
|
432
|
+
values.push(entry);
|
|
433
|
+
|
|
434
|
+
if (!lookups[index]) {
|
|
435
|
+
// section 3.4: unknown algorithms are ignored
|
|
436
|
+
entry.result = 'none';
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let key;
|
|
441
|
+
try {
|
|
442
|
+
key = await lookups[index];
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (!(err instanceof KeyError)) {
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
447
|
+
entry.result = err.result;
|
|
448
|
+
entry.comment = `public key ${selector} ${err.message}`;
|
|
449
|
+
if (err.rr) {
|
|
450
|
+
entry.rr = err.rr;
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
entry.result = verifySignature(algorithm, input, key.publicKey, Buffer.from(value, 'base64')) ? 'pass' : 'fail';
|
|
456
|
+
entry.rr = key.rr;
|
|
457
|
+
if (key.modulusLength) {
|
|
458
|
+
entry.modulusLength = key.modulusLength;
|
|
459
|
+
}
|
|
460
|
+
if (key.testing) {
|
|
461
|
+
// draft-ietf-dkim-dkim2-dns-00 section 3.4.1 t=y: the signer is testing DKIM2
|
|
462
|
+
entry.testing = true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let selectorsWith = result =>
|
|
467
|
+
values
|
|
468
|
+
.filter(entry => entry.result === result)
|
|
469
|
+
.map(entry => entry.selector)
|
|
470
|
+
.join(', ');
|
|
471
|
+
|
|
472
|
+
let status;
|
|
473
|
+
let passed = selectorsWith('pass');
|
|
474
|
+
let failed = selectorsWith('fail');
|
|
475
|
+
let keyErrors = values.filter(entry => ['permerror', 'temperror'].includes(entry.result));
|
|
476
|
+
|
|
477
|
+
if (failed) {
|
|
478
|
+
// section 11.6: every signature that can be checked is checked, and any failure fails
|
|
479
|
+
status = {
|
|
480
|
+
result: 'fail',
|
|
481
|
+
comment: passed
|
|
482
|
+
? `DKIM2-Signature i=${i} ${passed} signature passed, ${failed} signature failed`
|
|
483
|
+
: `DKIM2-Signature i=${i} ${failed} incorrect signature`
|
|
484
|
+
};
|
|
485
|
+
} else if (passed) {
|
|
486
|
+
status = { result: 'pass' };
|
|
487
|
+
} else if (keyErrors.length) {
|
|
488
|
+
// a TEMPERROR may pass on a later attempt, so it is reported before a PERMERROR
|
|
489
|
+
let keyError = keyErrors.find(entry => entry.result === 'temperror') || keyErrors[0];
|
|
490
|
+
status = { result: keyError.result, comment: `DKIM2-Signature i=${i} ${keyError.comment}` };
|
|
491
|
+
} else {
|
|
492
|
+
// Section 3.4 has the unknown algorithms ignored, so nothing could be verified and nothing
|
|
493
|
+
// was found incorrect: a PERMERROR by the definitions of section 11.1. The FAIL of section
|
|
494
|
+
// 11.6 step 3 is about the signatures that can be checked
|
|
495
|
+
status = { result: 'permerror', comment: `DKIM2-Signature i=${i} has no supported signature algorithm` };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { status, values };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
formatResult({ none, errors, signatures, signatureResults, instanceResults, replay }) {
|
|
502
|
+
if (none) {
|
|
503
|
+
let status = { result: 'none' };
|
|
504
|
+
return { status, info: formatAuthHeaderRow('dkim2', status), errors: [], instances: [], signatures: [] };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let result = pickResult(errors.map(error => error.result));
|
|
508
|
+
let reported = errors.find(error => error.result === result);
|
|
509
|
+
|
|
510
|
+
let signatureList = [];
|
|
511
|
+
let hops = [];
|
|
512
|
+
for (let i = 1; signatures.has(i); i++) {
|
|
513
|
+
let signature = signatures.get(i);
|
|
514
|
+
let hop = signatureResults.get(i);
|
|
515
|
+
|
|
516
|
+
// the outcome of a hop includes the errors that are reported against it
|
|
517
|
+
let hopResult = hop ? pickResult([hop.status.result, ...errors.filter(error => error.i === i).map(error => error.result)]) : 'skipped';
|
|
518
|
+
hops.push(`i=${i} ${signature.signingDomain} ${hopResult}`);
|
|
519
|
+
|
|
520
|
+
let entry = {
|
|
521
|
+
i,
|
|
522
|
+
m: signature.m,
|
|
523
|
+
signingDomain: signature.signingDomain,
|
|
524
|
+
timestamp: signature.t,
|
|
525
|
+
signTime: new Date(signature.t * 1000).toISOString(),
|
|
526
|
+
flags: signature.flags,
|
|
527
|
+
status: hop ? { result: hopResult, comment: hop.status.comment } : { result: 'skipped' },
|
|
528
|
+
values: hop ? hop.values : signature.signatures.map(({ selector, algorithm }) => ({ selector, algorithm }))
|
|
529
|
+
};
|
|
530
|
+
for (let key of ['mailFrom', 'rcptTo', 'nextDomain', 'nonce']) {
|
|
531
|
+
if (signature[key] !== undefined) {
|
|
532
|
+
entry[key] = signature[key];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
signatureList.push(entry);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let status = { result };
|
|
539
|
+
if (reported) {
|
|
540
|
+
status.comment = reported.message;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let warnings = this.envelopeWarnings();
|
|
544
|
+
if (warnings.length) {
|
|
545
|
+
// like the DKIM1 warnings, only present when there is something to report and never
|
|
546
|
+
// written into the Authentication-Results text
|
|
547
|
+
status.warnings = warnings;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// draft-gondwana-dkim2-authres-00 section 3: one resinfo for the message, the hops and the
|
|
551
|
+
// diagnostic in a comment, header.d of the originator, header.i of the failing signature
|
|
552
|
+
let header = {};
|
|
553
|
+
if (signatures.has(1)) {
|
|
554
|
+
header.d = signatures.get(1).signingDomain;
|
|
555
|
+
}
|
|
556
|
+
if (result !== 'pass' && typeof reported?.i === 'number') {
|
|
557
|
+
header.i = reported.i;
|
|
558
|
+
}
|
|
559
|
+
status.header = header;
|
|
560
|
+
|
|
561
|
+
let comment = [hops.join(', '), reported?.message].filter(value => value).join('; ');
|
|
562
|
+
let info = formatAuthHeaderRow('dkim2', { result, comment, header });
|
|
563
|
+
|
|
564
|
+
let output = { status, info, errors, instances: instanceResults, signatures: signatureList };
|
|
565
|
+
if (replay) {
|
|
566
|
+
output.replay = replay;
|
|
567
|
+
}
|
|
568
|
+
return output;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
module.exports = { Dkim2Verifier };
|