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,203 @@
1
+ 'use strict';
2
+
3
+ // DKIM2 public key lookup (draft-ietf-dkim-dkim2-dns-00 section 3.4 and
4
+ // draft-ietf-dkim-dkim2-spec-06 section 11.5). The records are DKIM1 key records, read with
5
+ // the DKIM2 rules: one TXT record per selector, the retired h=, n= and s= tags ignored, and
6
+ // only the rsa and ed25519 key types.
7
+
8
+ const { Buffer } = require('node:buffer');
9
+ const crypto = require('node:crypto');
10
+ const dns = require('node:dns').promises;
11
+ const { parseTagList, toALabel, isValidDnsName, isBareRsaPublicKey } = require('../tools');
12
+ const { isBase64 } = require('./fields');
13
+
14
+ const MIN_RSA_KEY_SIZE = 1024;
15
+ // Verifiers MUST handle 1024 to 2048 bits and MAY handle more (section 3.2). Longer keys are not used
16
+ // in practice and only make verification slower
17
+ const MAX_RSA_KEY_SIZE = 8192;
18
+ // section 3.2: "The signing algorithm MUST use a public exponent of 65537"
19
+ const RSA_EXPONENT = 65537n;
20
+
21
+ // DNS answers that mean there is no record. Anything else could be transient
22
+ const NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA']);
23
+
24
+ // the key type of each signing algorithm (section 8.9: the k= value has to match the algorithm)
25
+ const ALGORITHM_KEY_TYPES = new Map([
26
+ ['rsa-sha256', 'rsa'],
27
+ ['ed25519-sha256', 'ed25519']
28
+ ]);
29
+
30
+ // Section 3.2: why an RSA key can not be used, or null when it can
31
+ const rsaKeyProblem = keyObject => {
32
+ let details = keyObject.asymmetricKeyDetails;
33
+ if (details.modulusLength < MIN_RSA_KEY_SIZE) {
34
+ return 'is too short';
35
+ }
36
+ if (details.modulusLength > MAX_RSA_KEY_SIZE) {
37
+ return 'is too long';
38
+ }
39
+ if (details.publicExponent !== RSA_EXPONENT) {
40
+ return 'has an unsupported exponent';
41
+ }
42
+ return null;
43
+ };
44
+
45
+ // Sections 3.2 and 3.3: the hash name and the data for crypto.sign() and crypto.verify(). RSA
46
+ // PKCS#1 v1.5 hashes the input itself, Ed25519 (PureEdDSA) signs the SHA-256 hash of it
47
+ const signatureData = (algorithm, input) => (algorithm === 'ed25519-sha256' ? [null, crypto.createHash('sha256').update(input).digest()] : ['sha256', input]);
48
+
49
+ // RFC 8410 SubjectPublicKeyInfo prefix for a raw 32 byte Ed25519 key
50
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
51
+
52
+ class KeyError extends Error {
53
+ /**
54
+ * @param {String} result "permerror" or "temperror"
55
+ * @param {String} message The end of the section 11.5 error string, after "public key <selector>"
56
+ * @param {String} [rr] The key record
57
+ */
58
+ constructor(result, message, rr) {
59
+ super(message);
60
+ this.result = result;
61
+ if (rr) {
62
+ this.rr = rr;
63
+ }
64
+ }
65
+ }
66
+
67
+ const permerror = (reason, rr) => new KeyError('permerror', reason, rr);
68
+
69
+ /**
70
+ * Fetches and parses the key record of a selector
71
+ *
72
+ * @param {String} name Key record name, "selector._domainkey.domain"
73
+ * @param {Function} resolver DNS resolver
74
+ * @returns {Object} { publicKey: KeyObject, keyType, modulusLength, testing, rr }
75
+ * @throws {KeyError}
76
+ */
77
+ const fetchKey = async (name, resolver) => {
78
+ let lookupName = toALabel(name);
79
+ if (!isValidDnsName(lookupName)) {
80
+ throw permerror('does not exist');
81
+ }
82
+
83
+ let list;
84
+ try {
85
+ list = await resolver(lookupName, 'TXT');
86
+ } catch (err) {
87
+ // section 11.5: a timeout is a TEMPERROR, an absent key a PERMERROR
88
+ if (NO_RECORD_CODES.has(err.code)) {
89
+ throw permerror('does not exist');
90
+ }
91
+ throw new KeyError('temperror', 'could not be fetched');
92
+ }
93
+
94
+ if (!Array.isArray(list) || !list.length) {
95
+ throw permerror('does not exist');
96
+ }
97
+
98
+ if (list.length > 1) {
99
+ // draft-ietf-dkim-dkim2-dns-00 section 3.4.2.2, an error per section 11.5
100
+ throw permerror('has multiple records');
101
+ }
102
+
103
+ // the strings of a TXT record are joined with no whitespace between them
104
+ let rr = [].concat(list[0]).join('');
105
+
106
+ let entry = parseTagList(rr, { strict: true });
107
+ let tags = entry.parsed;
108
+ if (entry.syntaxErrors.length) {
109
+ throw permerror('has a syntax error', rr);
110
+ }
111
+
112
+ // v= is optional, but when present it is "DKIM1" and the first tag. A record with any other
113
+ // version is discarded
114
+ if (tags.v && (tags.v.value !== 'DKIM1' || entry.tags[0].name !== 'v')) {
115
+ throw permerror('has a syntax error', rr);
116
+ }
117
+
118
+ if (!tags.p) {
119
+ // p= is required
120
+ throw permerror('has a syntax error', rr);
121
+ }
122
+
123
+ if (!tags.p.value) {
124
+ // an empty p= means the key has been revoked
125
+ throw permerror('has been revoked', rr);
126
+ }
127
+
128
+ if (!isBase64(tags.p.value)) {
129
+ throw permerror('has a syntax error', rr);
130
+ }
131
+
132
+ // k= defaults to "rsa". Unknown key types are ignored, so they never match an algorithm
133
+ let keyType = tags.k ? tags.k.value : 'rsa';
134
+ if (!['rsa', 'ed25519'].includes(keyType)) {
135
+ return { publicKey: null, keyType, rr };
136
+ }
137
+
138
+ let rawKey = Buffer.from(tags.p.value, 'base64');
139
+ let publicKey;
140
+ try {
141
+ if (keyType === 'ed25519') {
142
+ // RFC 8463 section 4: the bare 32 byte key
143
+ if (rawKey.length !== 32) {
144
+ throw new Error('Invalid Ed25519 key length');
145
+ }
146
+ publicKey = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, rawKey]), format: 'der', type: 'spki' });
147
+ } else {
148
+ // an RSAPublicKey, or the SubjectPublicKeyInfo that is published in practice
149
+ publicKey = crypto.createPublicKey({ key: rawKey, format: 'der', type: isBareRsaPublicKey(rawKey) ? 'pkcs1' : 'spki' });
150
+ }
151
+ } catch (err) {
152
+ throw permerror('has a syntax error', rr);
153
+ }
154
+
155
+ if (publicKey.asymmetricKeyType !== keyType) {
156
+ throw permerror('has a syntax error', rr);
157
+ }
158
+
159
+ let modulusLength;
160
+ if (keyType === 'rsa') {
161
+ modulusLength = publicKey.asymmetricKeyDetails.modulusLength;
162
+ let problem = rsaKeyProblem(publicKey);
163
+ if (problem) {
164
+ throw permerror(problem, rr);
165
+ }
166
+ }
167
+
168
+ // t= flags. Only "y" means anything to DKIM2, "s" is about the DKIM1 i= tag
169
+ let flags = String(tags.t?.value || '')
170
+ .split(':')
171
+ .map(flag => flag.trim());
172
+
173
+ return { publicKey, keyType, modulusLength, testing: flags.includes('y'), rr };
174
+ };
175
+
176
+ /**
177
+ * Creates a key lookup for one message, so that every signature naming the same key record
178
+ * shares one DNS query
179
+ *
180
+ * @param {Function} [resolver] DNS resolver, defaults to dns.promises.resolve
181
+ * @returns {Function} async (domain, selector, algorithm) => key info, throws KeyError
182
+ */
183
+ const createKeyLookup = resolver => {
184
+ resolver = resolver || dns.resolve;
185
+ let cache = new Map();
186
+
187
+ return async (domain, selector, algorithm) => {
188
+ let name = `${selector}._domainkey.${domain}`;
189
+ if (!cache.has(name)) {
190
+ let lookup = fetchKey(name, resolver);
191
+ lookup.catch(() => false);
192
+ cache.set(name, lookup);
193
+ }
194
+
195
+ let key = await cache.get(name);
196
+ if (key.keyType !== ALGORITHM_KEY_TYPES.get(algorithm)) {
197
+ throw permerror('algorithm mismatch', key.rr);
198
+ }
199
+ return key;
200
+ };
201
+ };
202
+
203
+ module.exports = { createKeyLookup, KeyError, ALGORITHM_KEY_TYPES, rsaKeyProblem, signatureData };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ // One instance of a message as DKIM2 hashes it: the hashed header fields grouped by name, and
4
+ // the body. Applying the Recipe of a Message-Instance gives the previous instance
5
+ // (draft-ietf-dkim-dkim2-spec-06 sections 5, 6 and 11)
6
+
7
+ const { Buffer } = require('node:buffer');
8
+ const crypto = require('node:crypto');
9
+ const { SimpleHash } = require('../dkim/body/simple');
10
+ const { isHashedField, canonicalHeaderField } = require('./fields');
11
+ const { applySteps } = require('./recipe');
12
+
13
+ // how much body text is handed to the hash at a time when a body is hashed from its lines
14
+ const HASH_CHUNK_SIZE = 64 * 1024;
15
+
16
+ // "d" values are JSON strings, the message is handled as 'binary' strings of its bytes
17
+ const utf8ToBinary = value => Buffer.from(value, 'utf8').toString('binary');
18
+
19
+ /**
20
+ * Splits a body into lines numbered top down (section 5.2), without the line terminators
21
+ *
22
+ * @param {Buffer} body Message body with CRLF line endings
23
+ * @returns {String[]} Lines as 'binary' strings
24
+ */
25
+ const splitBodyLines = body => {
26
+ let str = body.toString('binary');
27
+ if (!str) {
28
+ return [];
29
+ }
30
+ let lines = str.split('\r\n');
31
+ if (str.endsWith('\r\n')) {
32
+ // the terminator of the last line, not an empty line after it
33
+ lines.pop();
34
+ }
35
+ return lines;
36
+ };
37
+
38
+ // Section 6.1: the body hash of a list of lines, each ending with CRLF
39
+ const hashBodyLines = (lines, algorithm) => {
40
+ let hasher = new SimpleHash(algorithm);
41
+ let chunk = '';
42
+ for (let line of lines) {
43
+ chunk += line + '\r\n';
44
+ if (chunk.length >= HASH_CHUNK_SIZE) {
45
+ hasher.update(Buffer.from(chunk, 'binary'));
46
+ chunk = '';
47
+ }
48
+ }
49
+ if (chunk) {
50
+ hasher.update(Buffer.from(chunk, 'binary'));
51
+ }
52
+ return hasher.digest('base64');
53
+ };
54
+
55
+ class MessageState {
56
+ /**
57
+ * @param {Map} headers Lower case field name to the canonicalized fields, numbered bottom up
58
+ * @param {Object|null} body { lines, hashes } where `lines` may be null when the body was not
59
+ * kept and `hashes` is a Map of algorithm to base64 hash. null for a body that can not
60
+ * be recreated (a null body Recipe)
61
+ */
62
+ constructor(headers, body) {
63
+ this.headers = headers;
64
+ this.body = body;
65
+ this.headerHashes = new Map();
66
+ }
67
+
68
+ /**
69
+ * Builds the state of the message as it is
70
+ *
71
+ * @param {Object[]} parsedHeaders Rows from parseHeaders(), top down
72
+ * @param {Object} body { lines, hashes }
73
+ * @returns {MessageState}
74
+ */
75
+ static fromMessage(parsedHeaders, body) {
76
+ let headers = new Map();
77
+ for (let i = parsedHeaders.length - 1; i >= 0; i--) {
78
+ let row = parsedHeaders[i];
79
+ if (!isHashedField(row.key)) {
80
+ continue;
81
+ }
82
+ if (!headers.has(row.key)) {
83
+ headers.set(row.key, []);
84
+ }
85
+ headers.get(row.key).push(canonicalHeaderField(row.line));
86
+ }
87
+ return new MessageState(headers, body);
88
+ }
89
+
90
+ // Section 6.2: the fields in alphabetical order of their names, fields with the same name
91
+ // from the bottom up
92
+ headerHash(algorithm) {
93
+ if (!this.headerHashes.has(algorithm)) {
94
+ let hash = crypto.createHash(algorithm);
95
+ for (let name of Array.from(this.headers.keys()).sort()) {
96
+ for (let line of this.headers.get(name)) {
97
+ hash.update(line);
98
+ }
99
+ }
100
+ this.headerHashes.set(algorithm, hash.digest('base64'));
101
+ }
102
+ return this.headerHashes.get(algorithm);
103
+ }
104
+
105
+ // the body hash, or null when the body of this instance can not be recreated
106
+ bodyHash(algorithm) {
107
+ if (!this.body) {
108
+ return null;
109
+ }
110
+ if (!this.body.hashes.has(algorithm)) {
111
+ this.body.hashes.set(algorithm, hashBodyLines(this.body.lines, algorithm));
112
+ }
113
+ return this.body.hashes.get(algorithm);
114
+ }
115
+
116
+ /**
117
+ * Applies a Recipe to get the previous instance of the message
118
+ *
119
+ * @param {Object} recipe Parsed Recipe ({ h: Map, b })
120
+ * @returns {MessageState}
121
+ * @throws {Error} When the Recipe does not fit this instance
122
+ */
123
+ previous(recipe) {
124
+ let headers = new Map(this.headers);
125
+ for (let [name, steps] of recipe.h) {
126
+ // section 5.1: Recipes SHOULD NOT name the fields that are not signed, and the
127
+ // hash does not see them anyway
128
+ if (!isHashedField(name)) {
129
+ continue;
130
+ }
131
+ let fields = applySteps(this.headers.get(name) || [], steps, value => canonicalHeaderField(Buffer.from(`${name}:${value}`, 'utf8')));
132
+ headers.set(name, fields);
133
+ }
134
+
135
+ let body = this.body;
136
+ if (recipe.b === null || (Array.isArray(recipe.b) && !body)) {
137
+ // the previous body can not be recreated, or it was built from one that can not
138
+ body = null;
139
+ } else if (Array.isArray(recipe.b)) {
140
+ if (!body.lines) {
141
+ throw new Error('The message body was not kept for the body Recipe');
142
+ }
143
+ body = { lines: applySteps(body.lines, recipe.b, utf8ToBinary), hashes: new Map() };
144
+ }
145
+
146
+ return new MessageState(headers, body);
147
+ }
148
+
149
+ /**
150
+ * Whether `later` only added header fields to this instance and kept the body, which is what a
151
+ * donotmodify request allows (section 8.10)
152
+ *
153
+ * @param {MessageState} later A later instance of the message
154
+ * @param {String} algorithm Hash algorithm for comparing the bodies
155
+ * @returns {Boolean}
156
+ */
157
+ isPreservedIn(later, algorithm) {
158
+ let bodyHash = this.bodyHash(algorithm);
159
+ if (!bodyHash || bodyHash !== later.bodyHash(algorithm)) {
160
+ return false;
161
+ }
162
+
163
+ for (let [name, fields] of this.headers) {
164
+ // every field of this instance is still there, in the same order
165
+ let laterFields = later.headers.get(name) || [];
166
+ let pos = 0;
167
+ for (let field of fields) {
168
+ while (pos < laterFields.length && !laterFields[pos].equals(field)) {
169
+ pos++;
170
+ }
171
+ if (pos >= laterFields.length) {
172
+ return false;
173
+ }
174
+ pos++;
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+ }
180
+
181
+ module.exports = { MessageState, splitBodyLines };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ // DKIM2 Recipes (draft-ietf-dkim-dkim2-spec-06 section 5): JSON instructions in the r= tag of a
4
+ // Message-Instance header field that recreate the previous instance of the message from the
5
+ // current one. This module validates a Recipe and applies it to a message state.
6
+
7
+ // RFC 5322 field-name, the keys of the "h" object
8
+ const FIELD_NAME = /^[\x21-\x39\x3b-\x7e]+$/;
9
+
10
+ const isPlainObject = value => !!value && typeof value === 'object' && !Array.isArray(value);
11
+ const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
12
+
13
+ // Validates the steps of a header or body Recipe. Every step is an object with exactly one of
14
+ // "c" (copy a range) or "d" (literal data). Other keys are ignored, as section 2 requires for
15
+ // unrecognised JSON fields, although the schema of section 5 does not allow them
16
+ const validateSteps = (steps, label) => {
17
+ if (!Array.isArray(steps)) {
18
+ return `${label} is not an array`;
19
+ }
20
+
21
+ let lastEnd = 0;
22
+ for (let step of steps) {
23
+ if (!isPlainObject(step) || own(step, 'c') === own(step, 'd')) {
24
+ return `${label} has a step that is not a "c" or a "d" step`;
25
+ }
26
+
27
+ if (own(step, 'c')) {
28
+ let range = step.c;
29
+ if (!Array.isArray(range) || range.length !== 2 || !range.every(value => Number.isSafeInteger(value) && value >= 1)) {
30
+ return `${label} has an invalid "c" range`;
31
+ }
32
+ // the start of every "c" step is after the end of all the earlier ones
33
+ if (range[0] > range[1] || range[0] <= lastEnd) {
34
+ return `${label} has "c" ranges out of order`;
35
+ }
36
+ lastEnd = range[1];
37
+ } else {
38
+ let data = step.d;
39
+ if (!Array.isArray(data) || !data.length || !data.every(value => typeof value === 'string' && !/[\r\n]/.test(value))) {
40
+ return `${label} has an invalid "d" step`;
41
+ }
42
+ }
43
+ }
44
+
45
+ return null;
46
+ };
47
+
48
+ /**
49
+ * Parses and validates the JSON of a Recipe
50
+ *
51
+ * @param {String} json Decoded r= value
52
+ * @returns {Object} { value, error }. `value.h` is a Map of lower case header field name to its
53
+ * steps, `value.b` is the body steps, null for a body that can not be recreated, or
54
+ * undefined when the body did not change
55
+ */
56
+ const parseRecipe = json => {
57
+ let recipe;
58
+ try {
59
+ recipe = JSON.parse(json);
60
+ } catch (err) {
61
+ return { error: 'not valid JSON' };
62
+ }
63
+
64
+ if (!isPlainObject(recipe)) {
65
+ return { error: 'not a JSON object' };
66
+ }
67
+
68
+ if (!own(recipe, 'h') && !own(recipe, 'b')) {
69
+ return { error: 'neither "h" nor "b" is present' };
70
+ }
71
+
72
+ let value = { h: new Map(), b: undefined };
73
+
74
+ if (own(recipe, 'h')) {
75
+ if (!isPlainObject(recipe.h) || !Object.keys(recipe.h).length) {
76
+ return { error: '"h" is not an object with header field names' };
77
+ }
78
+ for (let name of Object.keys(recipe.h)) {
79
+ // section 5.1: header field names in the JSON keys MUST be in lower case
80
+ if (!FIELD_NAME.test(name) || name !== name.toLowerCase()) {
81
+ return { error: `invalid header field name ${JSON.stringify(name)}` };
82
+ }
83
+ let error = validateSteps(recipe.h[name], `"h" Recipe for ${name}`);
84
+ if (error) {
85
+ return { error };
86
+ }
87
+ value.h.set(name, recipe.h[name]);
88
+ }
89
+ }
90
+
91
+ if (own(recipe, 'b')) {
92
+ if (recipe.b !== null) {
93
+ let error = validateSteps(recipe.b, '"b" Recipe');
94
+ if (error) {
95
+ return { error };
96
+ }
97
+ }
98
+ value.b = recipe.b;
99
+ }
100
+
101
+ return { value };
102
+ };
103
+
104
+ /**
105
+ * Runs the steps of a Recipe against a list of items: header fields of one name numbered
106
+ * bottom up, or body lines numbered top down. The output keeps the same direction
107
+ *
108
+ * @param {Array} items Current items
109
+ * @param {Object[]} steps Validated Recipe steps
110
+ * @param {Function} fromData Converts a "d" string to an item
111
+ * @returns {Array} Items of the previous instance
112
+ * @throws {Error} When a "c" range goes past the last item
113
+ */
114
+ const applySteps = (items, steps, fromData) => {
115
+ let output = [];
116
+ for (let step of steps) {
117
+ if (step.c) {
118
+ let [start, end] = step.c;
119
+ if (end > items.length) {
120
+ throw new Error(`"c" range ${start}-${end} is past the last of ${items.length}`);
121
+ }
122
+ for (let pos = start - 1; pos < end; pos++) {
123
+ output.push(items[pos]);
124
+ }
125
+ } else {
126
+ for (let data of step.d) {
127
+ output.push(fromData(data));
128
+ }
129
+ }
130
+ }
131
+ return output;
132
+ };
133
+
134
+ module.exports = { parseRecipe, applySteps };
@@ -0,0 +1,3 @@
1
+ // Type definitions for mailauth/lib/dkim2/sign
2
+
3
+ export { dkim2Sign, Dkim2SignStream } from '../../index';
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { writeToStream } = require('../tools');
4
+ const { DkimSignStream, formatSignatures } = require('../dkim/sign');
5
+ const { Dkim2Signer } = require('./dkim2-signer');
6
+
7
+ /**
8
+ * Signs a message with DKIM2: adds a DKIM2-Signature header field and, when needed, a
9
+ * Message-Instance header field (draft-ietf-dkim-dkim2-spec-06 section 9)
10
+ *
11
+ * @param {ReadableStream|Buffer|String} input RFC 5322 message
12
+ * @param {Object} options Signing options, see README
13
+ * @returns {Object} { signatures, signature, messageInstance, i, m }. `signatures` holds the
14
+ * header lines to prepend to the message, each ending with CRLF
15
+ * @throws {Error} For invalid options, an invalid DKIM2 chain on the message, or a recipe that
16
+ * does not recreate the previous instance
17
+ */
18
+ const dkim2Sign = async (input, options) => {
19
+ let signer = new Dkim2Signer(options);
20
+ await writeToStream(signer, input);
21
+
22
+ return {
23
+ signatures: formatSignatures(signer.signatureHeaders),
24
+ signature: signer.signatureHeaders[0],
25
+ messageInstance: signer.signatureHeaders[1] || null,
26
+ i: signer.i,
27
+ m: signer.m
28
+ };
29
+ };
30
+
31
+ // Transform stream that outputs the message with the DKIM2 header fields on top
32
+ class Dkim2SignStream extends DkimSignStream {
33
+ createSigner(options) {
34
+ return new Dkim2Signer(options);
35
+ }
36
+ }
37
+
38
+ module.exports = { dkim2Sign, Dkim2SignStream };
@@ -0,0 +1,3 @@
1
+ // Type definitions for mailauth/lib/dkim2/verify
2
+
3
+ export { dkim2Verify } from '../../index';
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const { writeToStream } = require('../tools');
4
+ const { Dkim2Verifier } = require('./dkim2-verifier');
5
+
6
+ /**
7
+ * Verifies the DKIM2 header fields of a message (draft-ietf-dkim-dkim2-spec-06 section 11)
8
+ *
9
+ * @param {ReadableStream|Buffer|String} input RFC 5322 message
10
+ * @param {Object} [options] Verification options, see Dkim2Verifier
11
+ * @returns {Object} Verification result, see docs/dkim2.md
12
+ */
13
+ const dkim2Verify = async (input, options) => {
14
+ let verifier = new Dkim2Verifier(options);
15
+ await writeToStream(verifier, input);
16
+ return verifier.result;
17
+ };
18
+
19
+ module.exports = { dkim2Verify };
package/lib/mailauth.js CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  const { dkimSign, DkimSignStream } = require('./dkim/sign');
4
4
  const { dkimVerify } = require('./dkim/verify');
5
+ const { dkim2Sign, Dkim2SignStream } = require('./dkim2/sign');
6
+ const { dkim2Verify } = require('./dkim2/verify');
7
+ const { dkim2Hash } = require('./dkim2/hash');
5
8
  const { spf } = require('./spf');
6
9
  const { dmarc, evaluateDmarc } = require('./dmarc');
7
10
  const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
@@ -9,6 +12,7 @@ const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
9
12
  const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
10
13
  const { parseReceived, getClientAddress, getClientHelo } = require('./parse-received');
11
14
  const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
15
+ const { buffer: readStream } = require('node:stream/consumers');
12
16
  const libmime = require('libmime');
13
17
  const os = require('node:os');
14
18
  const { isIP } = require('net');
@@ -33,6 +37,9 @@ const { isIP } = require('net');
33
37
  * @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
34
38
  * @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
35
39
  * @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
40
+ * @param {Boolean} [opts.dkim2=false] If true then DKIM2 header fields are verified as well (draft-ietf-dkim-dkim2-spec-06)
41
+ * @param {String|String[]} [opts.rcptTo] RCPT TO addresses of the delivery, checked against the DKIM2 chain of custody
42
+ * @param {Object} [opts.dkim2Options] Extra options for the DKIM2 verifier (maxSignatureAge, maxFutureTime, maxInstances, checkReplay, curTime)
36
43
  * @returns Authentication result
37
44
  */
38
45
  const authenticate = async (input, opts) => {
@@ -45,6 +52,11 @@ const authenticate = async (input, opts) => {
45
52
  // quoted, or throws under strict, before any work is done
46
53
  const authservId = formatAuthservId(opts.mta, opts.strict);
47
54
 
55
+ if (opts.dkim2 && typeof input?.on === 'function') {
56
+ // DKIM2 reads the message a second time, so a stream is read into memory first
57
+ input = await readStream(input);
58
+ }
59
+
48
60
  const dkimResult = await dkimVerify(input, {
49
61
  resolver: opts.resolver,
50
62
  sender: opts.sender, // defaults to Return-Path header
@@ -97,7 +109,24 @@ const authenticate = async (input, opts) => {
97
109
  opts.helo = `[${opts.helo}]`;
98
110
  }
99
111
 
112
+ // DKIM2 runs alongside SPF. The delivery is compared with the highest DKIM2-Signature only
113
+ // when it is known
114
+ const dkim2Verification =
115
+ opts.dkim2 &&
116
+ dkim2Verify(
117
+ input,
118
+ Object.assign({}, opts.dkim2Options, {
119
+ resolver: opts.resolver,
120
+ mailFrom: typeof opts.sender === 'string' ? opts.sender : undefined,
121
+ rcptTo: opts.rcptTo
122
+ })
123
+ );
124
+ // a rejection is handled by the await below, this keeps it from being reported as unhandled
125
+ // while SPF runs
126
+ dkim2Verification?.catch?.(() => false);
127
+
100
128
  const spfResult = await spf(opts);
129
+ const dkim2Result = await dkim2Verification;
101
130
 
102
131
  let arcResult;
103
132
  if (!opts.disableArc) {
@@ -118,6 +147,12 @@ const authenticate = async (input, opts) => {
118
147
  arHeader.push(`${libmime.foldLines(row.info, 160)}`);
119
148
  });
120
149
 
150
+ if (dkim2Result?.info) {
151
+ // draft-gondwana-dkim2-authres-00: the method is not registered yet. DKIM2 results do not
152
+ // take part in DMARC, that is an open question (draft-ietf-dkim-dkim2-bcp-01 section 7.2)
153
+ arHeader.push(libmime.foldLines(dkim2Result.info, 160));
154
+ }
155
+
121
156
  if (spfResult) {
122
157
  arHeader.push(libmime.foldLines(spfResult.info, 160));
123
158
  receivedSpfHeader = spfResult.header;
@@ -237,6 +272,7 @@ const authenticate = async (input, opts) => {
237
272
 
238
273
  const result = {
239
274
  dkim: dkimResult,
275
+ dkim2: dkim2Result || false,
240
276
  spf: spfResult,
241
277
  dmarc: dmarcResult || false,
242
278
  arc: arcResult || false,
@@ -260,6 +296,10 @@ module.exports = {
260
296
  dkimSign,
261
297
  DkimSignStream,
262
298
  dkimVerify,
299
+ dkim2Sign,
300
+ Dkim2SignStream,
301
+ dkim2Verify,
302
+ dkim2Hash,
263
303
  spf,
264
304
  dmarc,
265
305
  arc,
package/lib/tools.js CHANGED
@@ -1357,6 +1357,7 @@ module.exports = {
1357
1357
  addWarning,
1358
1358
  KEY_ERROR_COMMENTS,
1359
1359
  isValidDnsName,
1360
+ isBareRsaPublicKey,
1360
1361
 
1361
1362
  defaultDKIMFieldNames,
1362
1363
  defaultARCFieldNames,