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.
@@ -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,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 };