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,570 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Parsing, formatting and canonicalization of the two DKIM2 header fields, Message-Instance
|
|
4
|
+
// and DKIM2-Signature (draft-ietf-dkim-dkim2-spec-06 sections 6, 7, 8 and 9.6)
|
|
5
|
+
|
|
6
|
+
const { Buffer } = require('node:buffer');
|
|
7
|
+
const { formatRelaxedLine, toLineBytes, isValidDnsName, isSameOrSubdomain } = require('../tools');
|
|
8
|
+
const { parseRecipe } = require('./recipe');
|
|
9
|
+
|
|
10
|
+
const MI_KEY = 'message-instance';
|
|
11
|
+
const SIG_KEY = 'dkim2-signature';
|
|
12
|
+
|
|
13
|
+
// Section 4.1: header fields that are not signed, processed as if they were not present
|
|
14
|
+
const UNSIGNED_FIELDS = new Set([
|
|
15
|
+
'apparently-to',
|
|
16
|
+
'arc-authentication-results',
|
|
17
|
+
'arc-message-signature',
|
|
18
|
+
'arc-seal',
|
|
19
|
+
'authentication-results',
|
|
20
|
+
'auto-submitted',
|
|
21
|
+
'delivered-to',
|
|
22
|
+
'dkim-signature',
|
|
23
|
+
'dl-expansion-history',
|
|
24
|
+
'original-recipient',
|
|
25
|
+
'received',
|
|
26
|
+
'return-path',
|
|
27
|
+
'sio-label-history',
|
|
28
|
+
'vbr-info',
|
|
29
|
+
'x400-received',
|
|
30
|
+
'x400-trace'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const isUnsignedField = key => UNSIGNED_FIELDS.has(key) || key.startsWith('received-') || key.startsWith('x-');
|
|
34
|
+
|
|
35
|
+
// Section 6.2: the header hash leaves out the unsigned fields and the DKIM2 fields themselves
|
|
36
|
+
const isHashedField = key => !!key && key !== MI_KEY && key !== SIG_KEY && !isUnsignedField(key);
|
|
37
|
+
|
|
38
|
+
const HASH_ALGORITHMS = new Set(['sha256', 'sha512']);
|
|
39
|
+
|
|
40
|
+
// Section 8.3: at most 64 printable ASCII characters, no semicolon
|
|
41
|
+
const NONCE = /^[\x21-\x3a\x3c-\x7e]{0,64}$/;
|
|
42
|
+
// Section 2.14 textstring, without the surrounding FWS
|
|
43
|
+
const TEXTSTRING = /^[A-Za-z0-9_-]+$/;
|
|
44
|
+
// RFC 5234 1*DIGIT. t= values up to 10^12 have to be accepted (section 8.4), 15 digits keep every
|
|
45
|
+
// value a safe integer
|
|
46
|
+
const NUMBER = /^[0-9]{1,15}$/;
|
|
47
|
+
// Section 2.14 base64string with the FWS removed. It "MUST be padded", so the length is a multiple of 4
|
|
48
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
49
|
+
// RFC 5321 Domain, also used for selectors (section 3.5). The underscore is accepted as it is
|
|
50
|
+
// in DKIM1 key record names
|
|
51
|
+
const DOMAIN_LABEL = '[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?';
|
|
52
|
+
const DOMAIN = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
|
|
53
|
+
// Section 7 and 8 x-tag-name
|
|
54
|
+
const TAG_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
55
|
+
|
|
56
|
+
const WSP_ALL = /[ \t\r\n]+/g;
|
|
57
|
+
|
|
58
|
+
const isBase64 = value => !!value && BASE64.test(value);
|
|
59
|
+
const isDomain = value => !!value && DOMAIN.test(value) && isValidDnsName(value);
|
|
60
|
+
|
|
61
|
+
const toNumber = value => (NUMBER.test(value) ? Number(value) : NaN);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Splits the value of a DKIM2 header field into its tags. Tag names are case insensitive
|
|
65
|
+
* (sections 7 and 8), so they are returned in lower case. Semicolons never occur inside a value.
|
|
66
|
+
*
|
|
67
|
+
* @param {String} value Header field value as a 'binary' string
|
|
68
|
+
* @returns {Object} { tags: Map of name to value with the FWS around it removed, error }
|
|
69
|
+
*/
|
|
70
|
+
const parseTags = value => {
|
|
71
|
+
let tags = new Map();
|
|
72
|
+
let segments = value.split(';');
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < segments.length; i++) {
|
|
75
|
+
let segment = segments[i].replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
|
|
76
|
+
if (!segment) {
|
|
77
|
+
// the tag-list ends every tag with a semicolon, and a final one can be left out
|
|
78
|
+
if (i < segments.length - 1) {
|
|
79
|
+
return { tags, error: 'empty tag' };
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let eqPos = segment.indexOf('=');
|
|
85
|
+
if (eqPos < 0) {
|
|
86
|
+
return { tags, error: 'tag without a value' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let name = segment.substring(0, eqPos).replace(/[ \t\r\n]+$/, '');
|
|
90
|
+
if (!TAG_NAME.test(name)) {
|
|
91
|
+
return { tags, error: 'invalid tag name' };
|
|
92
|
+
}
|
|
93
|
+
name = name.toLowerCase();
|
|
94
|
+
|
|
95
|
+
if (tags.has(name)) {
|
|
96
|
+
return { tags, error: `duplicate tag ${name}` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
tags.set(name, segment.substring(eqPos + 1).replace(/^[ \t\r\n]+/, ''));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { tags, error: null };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// the value of a header line, after the colon, as a 'binary' string
|
|
106
|
+
const getFieldValue = line => {
|
|
107
|
+
let str = toLineBytes(line).toString('binary');
|
|
108
|
+
let colonPos = str.indexOf(':');
|
|
109
|
+
return colonPos >= 0 ? str.substring(colonPos + 1) : '';
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// A list of base64strings, each of which can contain FWS (section 2.14)
|
|
113
|
+
const splitBase64List = value => value.replace(WSP_ALL, '').split(',');
|
|
114
|
+
|
|
115
|
+
// The value of a tag that can not contain whitespace, such as a number or a domain
|
|
116
|
+
const plainValue = value => (/[ \t\r\n]/.test(value) ? null : value);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parses a Message-Instance header field (section 7)
|
|
120
|
+
*
|
|
121
|
+
* @param {Buffer|String} line Header line
|
|
122
|
+
* @returns {Object} { m, hashes: [{ algorithm, headerHash, bodyHash }], recipe, error }. `m` is
|
|
123
|
+
* NaN when the m= tag could not be read, `recipe` is undefined without an r= tag
|
|
124
|
+
*/
|
|
125
|
+
const parseMessageInstance = line => {
|
|
126
|
+
let result = { m: NaN, hashes: [], recipe: undefined, error: null };
|
|
127
|
+
let { tags, error } = parseTags(getFieldValue(line));
|
|
128
|
+
|
|
129
|
+
let mValue = tags.get('m');
|
|
130
|
+
if (typeof mValue === 'string') {
|
|
131
|
+
result.m = toNumber(plainValue(mValue) || '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fail = message => {
|
|
135
|
+
result.error = message;
|
|
136
|
+
return result;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (error) {
|
|
140
|
+
return fail('syntax error');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (let tag of ['m', 'h']) {
|
|
144
|
+
if (!tags.has(tag)) {
|
|
145
|
+
return fail(`tag=${tag} missing`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!(result.m >= 1)) {
|
|
150
|
+
return fail('syntax error');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// mi-h-tag: hash-set *("," hash-set), hash-set = hash-name ":" header-hash ":" body-hash
|
|
154
|
+
let seen = new Set();
|
|
155
|
+
for (let hashSet of tags.get('h').replace(WSP_ALL, '').split(',')) {
|
|
156
|
+
let parts = hashSet.split(':');
|
|
157
|
+
if (parts.length !== 3 || !TEXTSTRING.test(parts[0]) || !isBase64(parts[1]) || !isBase64(parts[2])) {
|
|
158
|
+
return fail('syntax error');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let algorithm = parts[0].toLowerCase();
|
|
162
|
+
if (seen.has(algorithm)) {
|
|
163
|
+
return fail('has a duplicate hash algorithm');
|
|
164
|
+
}
|
|
165
|
+
seen.add(algorithm);
|
|
166
|
+
|
|
167
|
+
result.hashes.push({ algorithm, headerHash: parts[1], bodyHash: parts[2] });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (tags.has('r')) {
|
|
171
|
+
let recipeValue = tags.get('r').replace(WSP_ALL, '');
|
|
172
|
+
if (!isBase64(recipeValue)) {
|
|
173
|
+
return fail('syntax error');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let recipe = parseRecipe(Buffer.from(recipeValue, 'base64').toString('utf8'));
|
|
177
|
+
if (recipe.error) {
|
|
178
|
+
return fail(`contains invalid JSON: ${recipe.error}`);
|
|
179
|
+
}
|
|
180
|
+
result.recipe = recipe.value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Decodes an mf= or rt= path: the base64 encoded RFC 5321 reverse-path or Forward-path, angle
|
|
187
|
+
// brackets included (sections 8.5 and 8.6). Returns false for a value that is not a path
|
|
188
|
+
const decodePath = (value, allowNull) => {
|
|
189
|
+
if (!isBase64(value)) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
let path = Buffer.from(value, 'base64').toString('utf8');
|
|
193
|
+
if (!/^<[^<>\r\n]*>$/.test(path) || (path === '<>' && !allowNull)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return path;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Parses a DKIM2-Signature header field (section 8)
|
|
201
|
+
*
|
|
202
|
+
* @param {Buffer|String} line Header line
|
|
203
|
+
* @returns {Object} { i, m, t, nonce, mailFrom, rcptTo, nextDomain, signingDomain, signatures,
|
|
204
|
+
* flags, error }. `i` is NaN when the i= tag could not be read
|
|
205
|
+
*/
|
|
206
|
+
const parseSignature = line => {
|
|
207
|
+
let result = {
|
|
208
|
+
i: NaN,
|
|
209
|
+
m: NaN,
|
|
210
|
+
t: NaN,
|
|
211
|
+
nonce: undefined,
|
|
212
|
+
mailFrom: undefined,
|
|
213
|
+
rcptTo: undefined,
|
|
214
|
+
nextDomain: undefined,
|
|
215
|
+
signingDomain: undefined,
|
|
216
|
+
signatures: [],
|
|
217
|
+
flags: [],
|
|
218
|
+
error: null
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
let { tags, error } = parseTags(getFieldValue(line));
|
|
222
|
+
|
|
223
|
+
let iValue = tags.get('i');
|
|
224
|
+
if (typeof iValue === 'string') {
|
|
225
|
+
result.i = toNumber(plainValue(iValue) || '');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const fail = message => {
|
|
229
|
+
result.error = message;
|
|
230
|
+
return result;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (error) {
|
|
234
|
+
return fail('syntax error');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (let tag of ['i', 'm', 't', 'd', 's']) {
|
|
238
|
+
if (!tags.has(tag)) {
|
|
239
|
+
return fail(`tag=${tag} missing`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// there is either an nd= tag or both mf= and rt=, never both kinds
|
|
244
|
+
if (tags.has('nd')) {
|
|
245
|
+
for (let tag of ['mf', 'rt']) {
|
|
246
|
+
if (tags.has(tag)) {
|
|
247
|
+
return fail(`tag=${tag} was unexpected`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
for (let tag of ['mf', 'rt']) {
|
|
252
|
+
if (!tags.has(tag)) {
|
|
253
|
+
return fail(`tag=${tag} missing`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
result.m = toNumber(plainValue(tags.get('m')) || '');
|
|
259
|
+
result.t = toNumber(plainValue(tags.get('t')) || '');
|
|
260
|
+
if (!(result.i >= 1) || !(result.m >= 1) || isNaN(result.t)) {
|
|
261
|
+
return fail('syntax error');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
result.signingDomain = plainValue(tags.get('d'));
|
|
265
|
+
if (!isDomain(result.signingDomain)) {
|
|
266
|
+
return fail('syntax error');
|
|
267
|
+
}
|
|
268
|
+
result.signingDomain = result.signingDomain.toLowerCase();
|
|
269
|
+
|
|
270
|
+
if (tags.has('nd')) {
|
|
271
|
+
result.nextDomain = plainValue(tags.get('nd'));
|
|
272
|
+
if (!isDomain(result.nextDomain)) {
|
|
273
|
+
return fail('syntax error');
|
|
274
|
+
}
|
|
275
|
+
result.nextDomain = result.nextDomain.toLowerCase();
|
|
276
|
+
} else {
|
|
277
|
+
result.mailFrom = decodePath(tags.get('mf').replace(WSP_ALL, ''), true);
|
|
278
|
+
result.rcptTo = splitBase64List(tags.get('rt')).map(value => decodePath(value, false));
|
|
279
|
+
if (!result.mailFrom || result.rcptTo.some(path => !path)) {
|
|
280
|
+
return fail('syntax error');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (tags.has('n')) {
|
|
285
|
+
// section 8.3: implementations SHOULD reject a nonce longer than 64 characters
|
|
286
|
+
result.nonce = tags.get('n');
|
|
287
|
+
if (!NONCE.test(result.nonce)) {
|
|
288
|
+
return fail('syntax error');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (tags.has('f')) {
|
|
293
|
+
for (let flag of tags.get('f').split(',')) {
|
|
294
|
+
flag = flag.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
|
|
295
|
+
if (!TEXTSTRING.test(flag)) {
|
|
296
|
+
return fail('syntax error');
|
|
297
|
+
}
|
|
298
|
+
// flag values are case significant, and unknown ones are ignored
|
|
299
|
+
result.flags.push(flag);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// sig-s-tag: sig-set *("," sig-set), sig-set = selector ":" sig-name ":" message-sig
|
|
304
|
+
let selectors = new Set();
|
|
305
|
+
let algorithmCounts = new Map();
|
|
306
|
+
for (let sigSet of tags.get('s').replace(WSP_ALL, '').split(',')) {
|
|
307
|
+
let parts = sigSet.split(':');
|
|
308
|
+
if (parts.length !== 3 || !isDomain(parts[0]) || !TEXTSTRING.test(parts[1]) || !isBase64(parts[2])) {
|
|
309
|
+
return fail('syntax error');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let selector = parts[0].toLowerCase();
|
|
313
|
+
let algorithm = parts[1].toLowerCase();
|
|
314
|
+
|
|
315
|
+
if (selectors.has(selector)) {
|
|
316
|
+
return fail('has a duplicate selector');
|
|
317
|
+
}
|
|
318
|
+
selectors.add(selector);
|
|
319
|
+
|
|
320
|
+
// section 8.9: one additional signature with the same algorithm is allowed
|
|
321
|
+
let count = (algorithmCounts.get(algorithm) || 0) + 1;
|
|
322
|
+
if (count > 2) {
|
|
323
|
+
return fail('has more selectors than allowed');
|
|
324
|
+
}
|
|
325
|
+
algorithmCounts.set(algorithm, count);
|
|
326
|
+
|
|
327
|
+
result.signatures.push({ selector, algorithm, signature: parts[2] });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return result;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Canonicalizes a header field for the header hash (section 6.2). This is the same
|
|
335
|
+
* transformation as the DKIM1 "relaxed" header canonicalization
|
|
336
|
+
*
|
|
337
|
+
* @param {Buffer|String} line Header line
|
|
338
|
+
* @returns {Buffer} Canonicalized line, ending with CRLF
|
|
339
|
+
*/
|
|
340
|
+
const canonicalHeaderField = line => formatRelaxedLine(line, '\r\n');
|
|
341
|
+
|
|
342
|
+
// Empties the signature values of the s= tag of a DKIM2-Signature value that has no whitespace
|
|
343
|
+
// left in it, for the signature input (sections 9.6 and 11.6). Semicolons can not occur inside a
|
|
344
|
+
// value, so splitting on them finds the tag the same way the parser does
|
|
345
|
+
const blankSignatureValues = value =>
|
|
346
|
+
value
|
|
347
|
+
.split(';')
|
|
348
|
+
.map(segment => {
|
|
349
|
+
let eqPos = segment.indexOf('=');
|
|
350
|
+
if (eqPos < 0 || segment.substring(0, eqPos).toLowerCase() !== 's') {
|
|
351
|
+
return segment;
|
|
352
|
+
}
|
|
353
|
+
let sigSets = segment
|
|
354
|
+
.substring(eqPos + 1)
|
|
355
|
+
.split(',')
|
|
356
|
+
.map(sigSet => {
|
|
357
|
+
// selector:sig-name:value, only the value is emptied. Anything else is a syntax
|
|
358
|
+
// error that is reported anyway, and is left as it is
|
|
359
|
+
let parts = sigSet.split(':');
|
|
360
|
+
return parts.length === 3 ? `${parts[0]}:${parts[1]}:` : sigSet;
|
|
361
|
+
});
|
|
362
|
+
return `${segment.substring(0, eqPos + 1)}${sigSets.join(',')}`;
|
|
363
|
+
})
|
|
364
|
+
.join(';');
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Canonicalizes a Message-Instance or DKIM2-Signature header field for the signature input
|
|
368
|
+
* (section 9.6): the name in lower case, the field unfolded and every WSP character removed
|
|
369
|
+
*
|
|
370
|
+
* @param {Buffer|String} line Header line
|
|
371
|
+
* @param {Boolean} [blank] Empty the signature values of the s= tag
|
|
372
|
+
* @returns {Buffer} Canonicalized line, ending with CRLF
|
|
373
|
+
*/
|
|
374
|
+
const canonicalSignatureField = (line, blank) => {
|
|
375
|
+
let str = toLineBytes(line).toString('binary').replace(WSP_ALL, '');
|
|
376
|
+
let colonPos = str.indexOf(':');
|
|
377
|
+
let name = str.substring(0, colonPos).toLowerCase();
|
|
378
|
+
let value = str.substring(colonPos + 1);
|
|
379
|
+
if (blank) {
|
|
380
|
+
value = blankSignatureValues(value);
|
|
381
|
+
}
|
|
382
|
+
return Buffer.from(`${name}:${value}\r\n`, 'binary');
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// The longest a folded line gets, not counting the CRLF
|
|
386
|
+
const LINE_LENGTH = 76;
|
|
387
|
+
const BASE64_CHUNK = 64;
|
|
388
|
+
|
|
389
|
+
// Splits a base64 value into pieces a fold can go between. FWS is allowed anywhere inside a
|
|
390
|
+
// base64string (section 2.14) and is ignored when the value is used. `lead` is the text in front
|
|
391
|
+
// of the value, such as "s=selector:rsa-sha256:", and `space` tells if it starts a new tag
|
|
392
|
+
const base64Atoms = (lead, value, trail, space) => {
|
|
393
|
+
let atoms = lead ? [{ text: lead, space: space !== false }] : [];
|
|
394
|
+
for (let pos = 0; pos < value.length; pos += BASE64_CHUNK) {
|
|
395
|
+
atoms.push({ text: value.substring(pos, pos + BASE64_CHUNK), space: false });
|
|
396
|
+
}
|
|
397
|
+
if (!atoms.length) {
|
|
398
|
+
atoms.push({ text: '', space: space !== false });
|
|
399
|
+
}
|
|
400
|
+
atoms[atoms.length - 1].text += trail || '';
|
|
401
|
+
return atoms;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Joins the atoms of a header field, folding between them when a line would get too long. An
|
|
405
|
+
// atom with `space` set is a new tag and gets a space in front of it when it is not folded
|
|
406
|
+
const foldAtoms = (name, atoms) => {
|
|
407
|
+
let lines = [];
|
|
408
|
+
let line = `${name}:`;
|
|
409
|
+
for (let atom of atoms) {
|
|
410
|
+
let separator = atom.space ? ' ' : '';
|
|
411
|
+
if (line.length + separator.length + atom.text.length > LINE_LENGTH && line.length > name.length + 1) {
|
|
412
|
+
lines.push(line);
|
|
413
|
+
line = ` ${atom.text}`;
|
|
414
|
+
} else {
|
|
415
|
+
line += separator + atom.text;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
lines.push(line);
|
|
419
|
+
return lines.join('\r\n');
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const plainAtom = text => ({ text, space: true });
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Formats a Message-Instance header field
|
|
426
|
+
*
|
|
427
|
+
* @param {Object} options
|
|
428
|
+
* @param {Number} options.m Revision number
|
|
429
|
+
* @param {Object[]} options.hashes [{ algorithm, headerHash, bodyHash }]
|
|
430
|
+
* @param {Object} [options.recipe] Recipe object for the r= tag
|
|
431
|
+
* @returns {String} Folded header line, without the final CRLF
|
|
432
|
+
*/
|
|
433
|
+
const formatMessageInstance = ({ m, hashes, recipe }) => {
|
|
434
|
+
let atoms = [plainAtom(`m=${m};`)];
|
|
435
|
+
|
|
436
|
+
hashes.forEach((hash, index) => {
|
|
437
|
+
// a fold can go before the "," as FWS at the end of the previous base64string
|
|
438
|
+
atoms.push(
|
|
439
|
+
...base64Atoms(`${index ? ',' : 'h='}${hash.algorithm}:`, hash.headerHash, ':', !index),
|
|
440
|
+
...base64Atoms('', hash.bodyHash, index === hashes.length - 1 ? ';' : '')
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (recipe) {
|
|
445
|
+
atoms.push(...base64Atoms('r=', Buffer.from(JSON.stringify(recipe)).toString('base64'), ';'));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return foldAtoms('Message-Instance', atoms);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const encodePath = path => Buffer.from(path).toString('base64');
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Formats a DKIM2-Signature header field. Signature values that are not known yet are written
|
|
455
|
+
* as empty strings, which is the form the signature input uses (section 9.6)
|
|
456
|
+
*
|
|
457
|
+
* @param {Object} options
|
|
458
|
+
* @returns {String} Folded header line, without the final CRLF
|
|
459
|
+
*/
|
|
460
|
+
const formatSignature = ({ i, m, t, mailFrom, rcptTo, nextDomain, signingDomain, nonce, flags, signatures }) => {
|
|
461
|
+
let atoms = [plainAtom(`i=${i};`), plainAtom(`m=${m};`), plainAtom(`t=${t};`), plainAtom(`d=${signingDomain};`)];
|
|
462
|
+
|
|
463
|
+
if (nextDomain) {
|
|
464
|
+
atoms.push(plainAtom(`nd=${nextDomain};`));
|
|
465
|
+
} else {
|
|
466
|
+
atoms.push(...base64Atoms('mf=', encodePath(mailFrom), ';'));
|
|
467
|
+
rcptTo.forEach((path, index) => {
|
|
468
|
+
atoms.push(...base64Atoms(index ? ',' : 'rt=', encodePath(path), index === rcptTo.length - 1 ? ';' : '', !index));
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (typeof nonce === 'string') {
|
|
473
|
+
atoms.push(plainAtom(`n=${nonce};`));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (flags?.length) {
|
|
477
|
+
atoms.push(plainAtom(`f=${flags.join(',')};`));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
signatures.forEach((entry, index) => {
|
|
481
|
+
atoms.push(
|
|
482
|
+
...base64Atoms(
|
|
483
|
+
`${index ? ',' : 's='}${entry.selector}:${entry.algorithm}:`,
|
|
484
|
+
entry.signature || '',
|
|
485
|
+
index === signatures.length - 1 ? ';' : '',
|
|
486
|
+
!index
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return foldAtoms('DKIM2-Signature', atoms);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Splits an RFC 5321 path into its address, with the domain in lower case as section 11.4
|
|
496
|
+
* compares it. A source route in front of the mailbox is left out
|
|
497
|
+
*
|
|
498
|
+
* @param {String} path Path with or without the angle brackets
|
|
499
|
+
* @returns {Object} { address, domain }, both empty for the null path
|
|
500
|
+
*/
|
|
501
|
+
const parsePath = path => {
|
|
502
|
+
let address = (path || '').toString().trim().replace(/^<|>$/g, '');
|
|
503
|
+
if (address.charAt(0) === '@' && address.indexOf(':') > 0) {
|
|
504
|
+
address = address.substring(address.indexOf(':') + 1);
|
|
505
|
+
}
|
|
506
|
+
let atPos = address.lastIndexOf('@');
|
|
507
|
+
if (atPos < 0) {
|
|
508
|
+
return { address, domain: '' };
|
|
509
|
+
}
|
|
510
|
+
let domain = address.substring(atPos + 1).toLowerCase();
|
|
511
|
+
return { address: `${address.substring(0, atPos)}@${domain}`, domain };
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Section 9.4: whether a hop continues the chain of custody of the hop before it. Its MAIL FROM
|
|
516
|
+
* domain has to match a RCPT TO of the previous hop after removing labels from the left, which is
|
|
517
|
+
* the same as being that domain or one of its subdomains. A hop with nd= has no MAIL FROM, and the
|
|
518
|
+
* null MAIL FROM has no domain, so the signing domain is matched instead: section 8.8 only waives
|
|
519
|
+
* the match of d= and mf= for the null MAIL FROM, never the chain of custody. A previous hop with
|
|
520
|
+
* nd= is checked against the d= of the next hop instead
|
|
521
|
+
*
|
|
522
|
+
* @param {Object} previous Parsed previous DKIM2-Signature
|
|
523
|
+
* @param {String} [mailFrom] MAIL FROM path of the hop
|
|
524
|
+
* @param {String} signingDomain Signing domain of the hop
|
|
525
|
+
* @returns {Boolean}
|
|
526
|
+
*/
|
|
527
|
+
const continuesCustody = (previous, mailFrom, signingDomain) => {
|
|
528
|
+
if (!previous || previous.nextDomain) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
let domain = mailFrom && mailFrom !== '<>' ? parsePath(mailFrom).domain : signingDomain;
|
|
532
|
+
return previous.rcptTo.some(path => isSameOrSubdomain(domain, parsePath(path).domain));
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Builds the signature input of section 9.6: the Message-Instance fields by m=, the
|
|
537
|
+
* DKIM2-Signature fields before the signature by i=, and the signature itself with its signature
|
|
538
|
+
* values emptied
|
|
539
|
+
*
|
|
540
|
+
* @param {Array} instanceLines Message-Instance header lines in m= order
|
|
541
|
+
* @param {Array} signatureLines Earlier DKIM2-Signature header lines in i= order
|
|
542
|
+
* @param {Buffer|String} ownLine The DKIM2-Signature that is signed or verified
|
|
543
|
+
* @returns {Buffer}
|
|
544
|
+
*/
|
|
545
|
+
const buildSignatureInput = (instanceLines, signatureLines, ownLine) =>
|
|
546
|
+
Buffer.concat([
|
|
547
|
+
...instanceLines.map(line => canonicalSignatureField(line)),
|
|
548
|
+
...signatureLines.map(line => canonicalSignatureField(line)),
|
|
549
|
+
canonicalSignatureField(ownLine, true)
|
|
550
|
+
]);
|
|
551
|
+
|
|
552
|
+
module.exports = {
|
|
553
|
+
MI_KEY,
|
|
554
|
+
SIG_KEY,
|
|
555
|
+
HASH_ALGORITHMS,
|
|
556
|
+
NONCE,
|
|
557
|
+
TEXTSTRING,
|
|
558
|
+
isHashedField,
|
|
559
|
+
isDomain,
|
|
560
|
+
parseMessageInstance,
|
|
561
|
+
parseSignature,
|
|
562
|
+
canonicalHeaderField,
|
|
563
|
+
canonicalSignatureField,
|
|
564
|
+
formatMessageInstance,
|
|
565
|
+
formatSignature,
|
|
566
|
+
parsePath,
|
|
567
|
+
isBase64,
|
|
568
|
+
continuesCustody,
|
|
569
|
+
buildSignatureInput
|
|
570
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { writeToStream } = require('../tools');
|
|
4
|
+
const { Dkim2Parser } = require('./dkim2-parser');
|
|
5
|
+
const { HASH_ALGORITHMS } = require('./fields');
|
|
6
|
+
|
|
7
|
+
// Reads a message and keeps its DKIM2 state: the hashed header fields and the body hashes
|
|
8
|
+
class HashParser extends Dkim2Parser {
|
|
9
|
+
constructor(algorithms) {
|
|
10
|
+
super();
|
|
11
|
+
algorithms.forEach(algorithm => this.hashBodyWith(algorithm));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async finalChunk() {
|
|
15
|
+
this.state = this.currentState();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Computes the DKIM2 header and body hashes of a message as they go into the h= tag of a
|
|
21
|
+
* Message-Instance header field (draft-ietf-dkim-dkim2-spec-06 sections 6 and 7.3)
|
|
22
|
+
*
|
|
23
|
+
* @param {ReadableStream|Buffer|String} input RFC 5322 message
|
|
24
|
+
* @param {Object} [options]
|
|
25
|
+
* @param {String[]} [options.algorithms=['sha256']] "sha256" and/or "sha512"
|
|
26
|
+
* @returns {Object} { headers, hashes }. `headers` lists the names of the hashed header fields in
|
|
27
|
+
* the order they are hashed, `hashes` is [{ algorithm, headerHash, bodyHash }]
|
|
28
|
+
* @throws {Error} For an unsupported hash algorithm
|
|
29
|
+
*/
|
|
30
|
+
const dkim2Hash = async (input, options) => {
|
|
31
|
+
let algorithms = Array.from(new Set((options?.algorithms || ['sha256']).map(algorithm => String(algorithm).toLowerCase())));
|
|
32
|
+
for (let algorithm of algorithms) {
|
|
33
|
+
if (!HASH_ALGORITHMS.has(algorithm)) {
|
|
34
|
+
let err = new Error(`Unsupported hash algorithm ${algorithm}, use sha256 or sha512`);
|
|
35
|
+
err.code = 'EINVALIDALGO';
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let parser = new HashParser(algorithms);
|
|
41
|
+
await writeToStream(parser, input);
|
|
42
|
+
let { state } = parser;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
// section 6.2: sorted by name
|
|
46
|
+
headers: Array.from(state.headers.keys()).sort(),
|
|
47
|
+
hashes: algorithms.map(algorithm => ({ algorithm, headerHash: state.headerHash(algorithm), bodyHash: state.bodyHash(algorithm) }))
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
module.exports = { dkim2Hash };
|