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.
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spf } = require('../spf');
4
- const fs = require('node:fs');
5
- const { resolve } = require('node:dns').promises;
4
+ const { createResolver } = require('./resolver');
6
5
 
7
6
  const cmd = async argv => {
8
7
  let address = argv.sender;
@@ -46,37 +45,7 @@ const cmd = async argv => {
46
45
  }
47
46
  }
48
47
 
49
- if (argv.dnsCache) {
50
- let dnsCache = JSON.parse(await fs.promises.readFile(argv.dnsCache, 'utf-8'));
51
-
52
- opts.resolver = async (name, rr) => {
53
- let match = dnsCache?.[name]?.[rr];
54
-
55
- if (argv.verbose) {
56
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'} (using cache)`);
57
- }
58
-
59
- if (!match) {
60
- let err = new Error('Error');
61
- err.code = 'ENOTFOUND';
62
- throw err;
63
- }
64
-
65
- return match;
66
- };
67
- } else if (argv.verbose) {
68
- opts.resolver = async (name, rr) => {
69
- let match;
70
- try {
71
- match = await resolve(name, rr);
72
- console.error(`DNS query for ${rr} ${name}: ${match ? JSON.stringify(match) : 'not found'}`);
73
- return match;
74
- } catch (err) {
75
- console.error(`DNS query for ${rr} ${name}: ${err.message}${err.code ? ` [${err.code}]` : ''}`);
76
- throw err;
77
- }
78
- };
79
- }
48
+ opts.resolver = await createResolver(argv);
80
49
 
81
50
  let result;
82
51
  try {
package/lib/dkim/sign.js CHANGED
@@ -25,7 +25,7 @@ const dkimSign = async (input, options) => {
25
25
  class DkimSignStream extends Transform {
26
26
  constructor(options) {
27
27
  super(options);
28
- this.signer = new DkimSigner(options);
28
+ this.signer = this.createSigner(options);
29
29
 
30
30
  this.chunks = [];
31
31
  this.chunklen = 0;
@@ -43,6 +43,11 @@ class DkimSignStream extends Transform {
43
43
  });
44
44
  }
45
45
 
46
+ // the signer that collects the signature headers, a subclass can sign with a different protocol
47
+ createSigner(options) {
48
+ return new DkimSigner(options);
49
+ }
50
+
46
51
  finishStream() {
47
52
  if (this.finished || !this.finishCb) {
48
53
  return;
@@ -90,4 +95,4 @@ class DkimSignStream extends Transform {
90
95
  }
91
96
  }
92
97
 
93
- module.exports = { dkimSign, DkimSignStream };
98
+ module.exports = { dkimSign, DkimSignStream, formatSignatures };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // The part of message parsing the DKIM2 signer and verifier share: the body is hashed with every
4
+ // algorithm asked for, and kept when a body Recipe has to copy lines from it
5
+
6
+ const { Buffer } = require('node:buffer');
7
+ const { MessageParser } = require('../dkim/message-parser');
8
+ const { SimpleHash } = require('../dkim/body/simple');
9
+ const { MessageState, splitBodyLines } = require('./message-state');
10
+
11
+ class Dkim2Parser extends MessageParser {
12
+ constructor() {
13
+ super();
14
+ this.bodyHashers = new Map();
15
+ this.bodyChunks = null;
16
+ }
17
+
18
+ // hashes the body with `algorithm` too, called from messageHeaders()
19
+ hashBodyWith(algorithm) {
20
+ if (!this.bodyHashers.has(algorithm)) {
21
+ this.bodyHashers.set(algorithm, new SimpleHash(algorithm));
22
+ }
23
+ }
24
+
25
+ // keeps the body in memory, called from messageHeaders()
26
+ keepBody() {
27
+ this.bodyChunks = this.bodyChunks || [];
28
+ }
29
+
30
+ async nextChunk(chunk) {
31
+ for (let hasher of this.bodyHashers.values()) {
32
+ hasher.update(chunk);
33
+ }
34
+ if (this.bodyChunks) {
35
+ this.bodyChunks.push(chunk);
36
+ }
37
+ }
38
+
39
+ // The message as it is, once the whole body has been read
40
+ currentState() {
41
+ let hashes = new Map();
42
+ for (let [algorithm, hasher] of this.bodyHashers) {
43
+ hashes.set(algorithm, hasher.digest('base64'));
44
+ }
45
+ let lines = this.bodyChunks ? splitBodyLines(Buffer.concat(this.bodyChunks)) : null;
46
+ // the lines hold the body from now on
47
+ this.bodyChunks = null;
48
+ return MessageState.fromMessage(this.headers.parsed, { lines, hashes });
49
+ }
50
+ }
51
+
52
+ module.exports = { Dkim2Parser };
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+
3
+ // DKIM2 signer (draft-ietf-dkim-dkim2-spec-06 section 9)
4
+
5
+ const crypto = require('node:crypto');
6
+ const { getPrivateKey, getCurTime, isSameOrSubdomain } = require('../tools');
7
+ const {
8
+ MI_KEY,
9
+ SIG_KEY,
10
+ HASH_ALGORITHMS,
11
+ NONCE,
12
+ TEXTSTRING,
13
+ isDomain,
14
+ parseMessageInstance,
15
+ parseSignature,
16
+ canonicalSignatureField,
17
+ formatMessageInstance,
18
+ formatSignature,
19
+ parsePath,
20
+ continuesCustody,
21
+ buildSignatureInput
22
+ } = require('./fields');
23
+ const { parseRecipe } = require('./recipe');
24
+ const { Dkim2Parser } = require('./dkim2-parser');
25
+ const { ALGORITHM_KEY_TYPES, rsaKeyProblem, signatureData } = require('./key');
26
+
27
+ // the signing algorithm of each key type
28
+ const KEY_ALGORITHMS = new Map(Array.from(ALGORITHM_KEY_TYPES, ([algorithm, keyType]) => [keyType, algorithm]));
29
+
30
+ const signError = (message, code) => {
31
+ let err = new Error(message);
32
+ err.code = code || 'EDKIM2SIGN';
33
+ return err;
34
+ };
35
+
36
+ // Converts an address to an RFC 5321 path with the angle brackets (sections 8.5 and 8.6)
37
+ const toPath = (value, allowNull) => {
38
+ let path = String(value ?? '').trim();
39
+ if (path && !/^<.*>$/.test(path)) {
40
+ path = `<${path}>`;
41
+ }
42
+ if (!path || path === '<>') {
43
+ if (!allowNull) {
44
+ throw signError('A RCPT TO address can not be empty', 'EINVALIDPATH');
45
+ }
46
+ return '<>';
47
+ }
48
+ if (!/^<[^<>\r\n]+>$/.test(path)) {
49
+ throw signError(`Invalid SMTP path ${JSON.stringify(path)}`, 'EINVALIDPATH');
50
+ }
51
+ return path;
52
+ };
53
+
54
+ // Section 3: the signing algorithm follows the key type
55
+ const loadKey = ({ selector, privateKey, algorithm }) => {
56
+ if (!isDomain(selector)) {
57
+ throw signError(`Invalid selector ${JSON.stringify(selector)}`, 'EINVALIDSELECTOR');
58
+ }
59
+
60
+ let keyObject = getPrivateKey(privateKey);
61
+ let keyAlgorithm = KEY_ALGORITHMS.get(keyObject.asymmetricKeyType);
62
+ if (!keyAlgorithm) {
63
+ throw signError(`Unsupported key type ${keyObject.asymmetricKeyType}`, 'EINVALIDALGO');
64
+ }
65
+ if (algorithm && algorithm.toLowerCase() !== keyAlgorithm) {
66
+ throw signError(`Algorithm ${algorithm} does not match the ${keyObject.asymmetricKeyType} key of selector ${selector}`, 'EINVALIDALGO');
67
+ }
68
+
69
+ // section 3.2: RSA keys of at least 1024 bits with the public exponent 65537
70
+ let problem = keyObject.asymmetricKeyType === 'rsa' && rsaKeyProblem(keyObject);
71
+ if (problem) {
72
+ throw signError(`The RSA key of selector ${selector} ${problem}`, problem === 'is too short' ? 'ESHORTKEY' : 'EINVALIDKEY');
73
+ }
74
+
75
+ return { selector: selector.toLowerCase(), algorithm: keyAlgorithm, keyObject };
76
+ };
77
+
78
+ const createSignature = (algorithm, input, keyObject) => crypto.sign(...signatureData(algorithm, input), keyObject);
79
+
80
+ // Validates the signing options and puts them into the form the signer uses
81
+ const normalizeOptions = options => {
82
+ options = options || {};
83
+
84
+ let signingDomain = (options.signingDomain || '').toString().trim().toLowerCase();
85
+ if (!isDomain(signingDomain)) {
86
+ throw signError(`Invalid signing domain ${JSON.stringify(options.signingDomain)}`, 'EINVALIDDOMAIN');
87
+ }
88
+
89
+ let keyList =
90
+ options.signatureData || (options.privateKey ? [{ selector: options.selector, privateKey: options.privateKey, algorithm: options.algorithm }] : []);
91
+ if (!keyList.length) {
92
+ throw signError('No signing keys provided', 'ENOKEY');
93
+ }
94
+ let keys = keyList.map(loadKey);
95
+
96
+ // section 8.9: no selector twice, at most two signatures with the same algorithm
97
+ let selectors = new Set();
98
+ let algorithmCounts = new Map();
99
+ for (let { selector, algorithm } of keys) {
100
+ if (selectors.has(selector)) {
101
+ throw signError(`Selector ${selector} is listed more than once`, 'EINVALIDSELECTOR');
102
+ }
103
+ selectors.add(selector);
104
+ algorithmCounts.set(algorithm, (algorithmCounts.get(algorithm) || 0) + 1);
105
+ if (algorithmCounts.get(algorithm) > 2) {
106
+ throw signError(`More than two signatures use ${algorithm}`, 'EINVALIDALGO');
107
+ }
108
+ }
109
+
110
+ let result = { signingDomain, keys };
111
+
112
+ if (options.nextDomain) {
113
+ // section 8.7: nd= replaces both mf= and rt=
114
+ if (options.mailFrom !== undefined || options.rcptTo !== undefined) {
115
+ throw signError('nextDomain can not be used together with mailFrom or rcptTo', 'EINVALIDOPTS');
116
+ }
117
+ result.nextDomain = options.nextDomain.toString().trim().toLowerCase();
118
+ if (!isDomain(result.nextDomain)) {
119
+ throw signError(`Invalid next domain ${JSON.stringify(options.nextDomain)}`, 'EINVALIDDOMAIN');
120
+ }
121
+ } else {
122
+ // section 9.2: the envelope MUST be available to the signer
123
+ if (options.mailFrom === undefined || options.rcptTo === undefined) {
124
+ throw signError('mailFrom and rcptTo are required, unless nextDomain is set', 'EINVALIDOPTS');
125
+ }
126
+ result.mailFrom = toPath(options.mailFrom, true);
127
+ result.rcptTo = [].concat(options.rcptTo).map(value => toPath(value, false));
128
+ if (!result.rcptTo.length) {
129
+ throw signError('rcptTo needs at least one address', 'EINVALIDOPTS');
130
+ }
131
+
132
+ // section 8.8: the d= domain is the MAIL FROM domain or a parent of it
133
+ if (result.mailFrom !== '<>' && !isSameOrSubdomain(parsePath(result.mailFrom).domain, signingDomain)) {
134
+ throw signError(`The MAIL FROM domain does not match the signing domain ${signingDomain}`, 'EINVALIDDOMAIN');
135
+ }
136
+ }
137
+
138
+ if (options.nonce !== undefined) {
139
+ result.nonce = options.nonce.toString();
140
+ if (!NONCE.test(result.nonce)) {
141
+ throw signError('A nonce is at most 64 printable ASCII characters with no semicolon', 'EINVALIDOPTS');
142
+ }
143
+ }
144
+
145
+ result.flags = [].concat(options.flags || []).map(flag => flag.toString());
146
+ for (let flag of result.flags) {
147
+ if (!TEXTSTRING.test(flag)) {
148
+ throw signError(`Invalid flag ${JSON.stringify(flag)}`, 'EINVALIDOPTS');
149
+ }
150
+ }
151
+
152
+ result.hashAlgorithms = Array.from(new Set([].concat(options.hashAlgorithms || ['sha256']).map(algorithm => algorithm.toString().toLowerCase())));
153
+ for (let algorithm of result.hashAlgorithms) {
154
+ if (!HASH_ALGORITHMS.has(algorithm)) {
155
+ throw signError(`Unsupported hash algorithm ${algorithm}`, 'EINVALIDALGO');
156
+ }
157
+ }
158
+
159
+ if (options.recipe !== undefined && options.recipe !== null) {
160
+ let parsed = parseRecipe(JSON.stringify(options.recipe));
161
+ if (parsed.error) {
162
+ throw signError(`Invalid recipe: ${parsed.error}`, 'EINVALIDRECIPE');
163
+ }
164
+ result.recipe = options.recipe;
165
+ result.parsedRecipe = parsed.value;
166
+ }
167
+
168
+ // section 8.4, the time is read once so that the signed and the emitted t= are the same
169
+ result.t = Math.floor(getCurTime(options.signTime).getTime() / 1000);
170
+
171
+ return result;
172
+ };
173
+
174
+ // Reads the DKIM2 header fields that are already on the message. They have to be valid and
175
+ // numbered without gaps, otherwise the next numbers are not known
176
+ const readChain = (rows, key, parser, label, field) => {
177
+ let map = new Map();
178
+ for (let row of rows) {
179
+ if (row.key !== key) {
180
+ continue;
181
+ }
182
+ let parsed = parser(row.line);
183
+ if (parsed.error || map.has(parsed[field])) {
184
+ throw signError(`Can not sign a message with an invalid ${label} header field (${parsed.error || 'duplicate'})`, 'EINVALIDCHAIN');
185
+ }
186
+ map.set(parsed[field], Object.assign({ line: row.line }, parsed));
187
+ }
188
+ for (let pos = 1; pos <= map.size; pos++) {
189
+ if (!map.has(pos)) {
190
+ throw signError(`Can not sign a message where ${label} ${field}=${pos} is missing`, 'EINVALIDCHAIN');
191
+ }
192
+ }
193
+ return map;
194
+ };
195
+
196
+ class Dkim2Signer extends Dkim2Parser {
197
+ constructor(options) {
198
+ super();
199
+ this.options = normalizeOptions(options);
200
+
201
+ // the header fields to put on top of the message, DKIM2-Signature first
202
+ this.signatureHeaders = [];
203
+ }
204
+
205
+ async messageHeaders(headers) {
206
+ this.instances = readChain(headers.parsed, MI_KEY, parseMessageInstance, 'Message-Instance', 'm');
207
+ this.signatures = readChain(headers.parsed, SIG_KEY, parseSignature, 'DKIM2-Signature', 'i');
208
+
209
+ this.options.hashAlgorithms.forEach(algorithm => this.hashBodyWith(algorithm));
210
+ this.topInstance = this.instances.get(this.instances.size);
211
+ if (this.topInstance) {
212
+ // the hashes of the top instance are compared with the message as it is now
213
+ for (let { algorithm } of this.topInstance.hashes) {
214
+ if (HASH_ALGORITHMS.has(algorithm)) {
215
+ this.hashBodyWith(algorithm);
216
+ }
217
+ }
218
+ if (Array.isArray(this.options.parsedRecipe?.b)) {
219
+ // the recipe is checked against the previous instance, which needs the body lines
220
+ this.keepBody();
221
+ }
222
+ }
223
+ }
224
+
225
+ // Section 9.1: a new Message-Instance when the message has no instance yet, or when it has
226
+ // changed since the top one, with a Recipe that recreates the top one
227
+ buildInstance(current) {
228
+ let options = this.options;
229
+ let hashes = options.hashAlgorithms.map(algorithm => ({ algorithm, headerHash: current.headerHash(algorithm), bodyHash: current.bodyHash(algorithm) }));
230
+
231
+ if (!this.topInstance) {
232
+ return { m: 1, hashes, recipe: options.recipe };
233
+ }
234
+
235
+ let top = this.topInstance;
236
+ let comparable = top.hashes.filter(hash => HASH_ALGORITHMS.has(hash.algorithm));
237
+ if (!comparable.length) {
238
+ throw signError(`Message-Instance m=${top.m} has no supported hash algorithm`, 'EINVALIDCHAIN');
239
+ }
240
+
241
+ let changed = comparable.some(hash => current.headerHash(hash.algorithm) !== hash.headerHash || current.bodyHash(hash.algorithm) !== hash.bodyHash);
242
+ if (!changed) {
243
+ return null;
244
+ }
245
+
246
+ if (!options.parsedRecipe) {
247
+ throw signError(`The message has changed since Message-Instance m=${top.m}, a recipe is required`, 'ENORECIPE');
248
+ }
249
+
250
+ let previous;
251
+ try {
252
+ previous = current.previous(options.parsedRecipe);
253
+ } catch (err) {
254
+ throw signError(`Invalid recipe: ${err.message}`, 'EINVALIDRECIPE');
255
+ }
256
+
257
+ for (let { algorithm, headerHash, bodyHash } of comparable) {
258
+ let previousBodyHash = previous.bodyHash(algorithm);
259
+ // a null body Recipe declares that the previous body can not be recreated
260
+ if (previous.headerHash(algorithm) !== headerHash || (previousBodyHash !== null && previousBodyHash !== bodyHash)) {
261
+ throw signError(`The recipe does not recreate Message-Instance m=${top.m}`, 'EINVALIDRECIPE');
262
+ }
263
+ }
264
+
265
+ return { m: top.m + 1, hashes, recipe: options.recipe };
266
+ }
267
+
268
+ // Section 9.3: the new signature continues the chain of custody of the previous one
269
+ checkCustody() {
270
+ let options = this.options;
271
+ let previous = this.signatures.get(this.signatures.size);
272
+ if (!previous) {
273
+ return;
274
+ }
275
+
276
+ if (previous.nextDomain) {
277
+ if (previous.nextDomain !== options.signingDomain) {
278
+ throw signError(`DKIM2-Signature i=${previous.i} requires the next signing domain to be ${previous.nextDomain}`, 'ECUSTODY');
279
+ }
280
+ return;
281
+ }
282
+
283
+ if (!continuesCustody(previous, options.mailFrom, options.signingDomain)) {
284
+ throw signError(`The signature would break the chain of custody, it does not match a RCPT TO of DKIM2-Signature i=${previous.i}`, 'ECUSTODY');
285
+ }
286
+ }
287
+
288
+ async finalChunk() {
289
+ let options = this.options;
290
+
291
+ let current = this.currentState();
292
+
293
+ let instance = this.buildInstance(current);
294
+ this.checkCustody();
295
+
296
+ let instanceHeader = instance ? formatMessageInstance(instance) : null;
297
+ let signature = {
298
+ i: this.signatures.size + 1,
299
+ m: instance ? instance.m : this.instances.size,
300
+ t: options.t,
301
+ signingDomain: options.signingDomain,
302
+ mailFrom: options.mailFrom,
303
+ rcptTo: options.rcptTo,
304
+ nextDomain: options.nextDomain,
305
+ nonce: options.nonce,
306
+ flags: options.flags,
307
+ signatures: options.keys.map(({ selector, algorithm }) => ({ selector, algorithm, signature: '' }))
308
+ };
309
+
310
+ // section 9.6: every Message-Instance by m=, every DKIM2-Signature by i=, and the new
311
+ // signature with empty signature values
312
+ let inOrder = map => Array.from({ length: map.size }, (v, index) => map.get(index + 1).line);
313
+ let instanceLines = inOrder(this.instances).concat(instanceHeader || []);
314
+ let signatureLines = inOrder(this.signatures);
315
+ let unsignedHeader = formatSignature(signature);
316
+ let input = buildSignatureInput(instanceLines, signatureLines, unsignedHeader);
317
+
318
+ options.keys.forEach(({ algorithm, keyObject }, index) => {
319
+ signature.signatures[index].signature = createSignature(algorithm, input, keyObject).toString('base64');
320
+ });
321
+
322
+ let signatureHeader = formatSignature(signature);
323
+ if (!canonicalSignatureField(signatureHeader, true).equals(canonicalSignatureField(unsignedHeader, true))) {
324
+ // the emitted header has to be the one that was signed
325
+ throw signError('The signed and the emitted DKIM2-Signature differ', 'EINTERNAL');
326
+ }
327
+
328
+ this.signatureHeaders = [signatureHeader].concat(instanceHeader || []);
329
+ this.i = signature.i;
330
+ this.m = signature.m;
331
+ }
332
+ }
333
+
334
+ module.exports = { Dkim2Signer };