mailauth 7.2.0 → 7.3.1

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