mailauth 6.0.0 → 7.0.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +111 -39
- package/bin/mailauth.js +14 -6
- package/cli.md +8 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +80 -33
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +495 -43
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +2 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +574 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +140 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +75 -36
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +522 -116
- package/package.json +1 -1
package/lib/dkim/dkim-signer.js
CHANGED
|
@@ -6,28 +6,66 @@ const {
|
|
|
6
6
|
defaultDKIMFieldNames,
|
|
7
7
|
defaultARCFieldNames,
|
|
8
8
|
validateAlgorithm,
|
|
9
|
-
getPrivateKey
|
|
9
|
+
getPrivateKey,
|
|
10
|
+
getCurTime,
|
|
11
|
+
toALabel,
|
|
12
|
+
isSameOrSubdomain,
|
|
13
|
+
addWarning,
|
|
14
|
+
createError,
|
|
15
|
+
splitAlgorithm
|
|
10
16
|
} = require('../../lib/tools');
|
|
17
|
+
const { DOMAIN_NAME, SELECTOR, MAX_ARC_INSTANCE, ARC_HEADER_KEYS } = require('./syntax');
|
|
11
18
|
const { MessageParser } = require('./message-parser');
|
|
12
19
|
const { dkimBody } = require('./body');
|
|
13
20
|
const { generateCanonicalizedHeader } = require('./header');
|
|
14
21
|
const crypto = require('node:crypto');
|
|
15
22
|
|
|
23
|
+
// Characters that can never be in a tag value (RFC 6376 section 3.2): the semicolon ends the
|
|
24
|
+
// tag, and whitespace, CR and LF would change the header itself. Anything that is not
|
|
25
|
+
// printable ASCII once converted to an A-label is not a DNS name either
|
|
26
|
+
const UNSAFE_TAG_VALUE = /[^\x21-\x3a\x3c-\x7e]/;
|
|
27
|
+
// t= and x= are 1*12DIGIT (RFC 6376 section 3.5)
|
|
28
|
+
const MAX_TIMESTAMP = 999999999999;
|
|
29
|
+
|
|
30
|
+
// RFC 8617 section 4.1.2: header fields an ARC-Message-Signature never covers
|
|
31
|
+
const ARC_EXCLUDED_FIELDS = [...ARC_HEADER_KEYS, 'authentication-results'];
|
|
32
|
+
|
|
33
|
+
// RFC 8301 section 3.2: signers MUST use RSA keys of at least 1024 bits
|
|
34
|
+
const MIN_RSA_KEY_SIZE = 1024;
|
|
35
|
+
|
|
36
|
+
// Turns a header list option (a colon separated string or an array of names) into the
|
|
37
|
+
// colon separated string getSigningHeaderLines expects, or false if there is none
|
|
38
|
+
const normalizeHeaderList = headerList => {
|
|
39
|
+
if (Array.isArray(headerList)) {
|
|
40
|
+
headerList = headerList
|
|
41
|
+
.map(entry => (entry || '').toString().trim())
|
|
42
|
+
.filter(entry => entry)
|
|
43
|
+
.join(':');
|
|
44
|
+
}
|
|
45
|
+
return typeof headerList === 'string' && headerList.trim() ? headerList : false;
|
|
46
|
+
};
|
|
47
|
+
|
|
16
48
|
class DkimSigner extends MessageParser {
|
|
17
49
|
constructor(options) {
|
|
18
50
|
super();
|
|
19
51
|
|
|
20
|
-
let { canonicalization, algorithm, signTime, headerList, signatureData, arc, bodyHash, headers, getARChain, expires } =
|
|
52
|
+
let { canonicalization, algorithm, signTime, headerList, signatureData, arc, bodyHash, headers, getARChain, getArcInstance, expires, strict } =
|
|
53
|
+
options || {};
|
|
21
54
|
|
|
22
55
|
this.algorithm = algorithm || false;
|
|
23
56
|
this.canonicalization = canonicalization || 'relaxed/relaxed';
|
|
24
57
|
|
|
58
|
+
// follow RFC 6376 and RFC 8301 exactly instead of the lenient defaults
|
|
59
|
+
this.strict = !!strict;
|
|
60
|
+
|
|
25
61
|
this.errors = [];
|
|
62
|
+
// what was signed although the strict mode would have refused it, as short codes
|
|
63
|
+
this.warnings = [];
|
|
26
64
|
|
|
27
65
|
this.expires = expires;
|
|
28
66
|
this.signTime = signTime;
|
|
29
67
|
|
|
30
|
-
this.headerList = headerList;
|
|
68
|
+
this.headerList = normalizeHeaderList(headerList);
|
|
31
69
|
|
|
32
70
|
this.signatureData = [].concat(signatureData || []).map(entry => {
|
|
33
71
|
entry.type = 'DKIM';
|
|
@@ -38,6 +76,8 @@ class DkimSigner extends MessageParser {
|
|
|
38
76
|
|
|
39
77
|
this.arc = Object.assign({ chain: false }, arc);
|
|
40
78
|
this.getARChain = getARChain;
|
|
79
|
+
// decides the instance of an ARC set that has none set, from the message headers
|
|
80
|
+
this.getArcInstance = getArcInstance;
|
|
41
81
|
if (this.arc.signingDomain && this.arc.selector && this.arc.privateKey) {
|
|
42
82
|
this.signatureData.push({
|
|
43
83
|
type: 'ARC',
|
|
@@ -45,7 +85,8 @@ class DkimSigner extends MessageParser {
|
|
|
45
85
|
selector: this.arc.selector, // s=
|
|
46
86
|
privateKey: this.arc.privateKey,
|
|
47
87
|
canonicalization: 'relaxed/relaxed',
|
|
48
|
-
|
|
88
|
+
// rsa-sha256 or ed25519-sha256, follows the key type when not set
|
|
89
|
+
algorithm: this.arc.algorithm || false
|
|
49
90
|
});
|
|
50
91
|
}
|
|
51
92
|
|
|
@@ -58,6 +99,18 @@ class DkimSigner extends MessageParser {
|
|
|
58
99
|
this.setupHashes();
|
|
59
100
|
}
|
|
60
101
|
|
|
102
|
+
warn(warning) {
|
|
103
|
+
addWarning(this.warnings, warning);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A rule the strict mode enforces: throws in the strict mode, notes the warning otherwise
|
|
107
|
+
violation(message, code, warning) {
|
|
108
|
+
if (this.strict) {
|
|
109
|
+
throw createError(message, code);
|
|
110
|
+
}
|
|
111
|
+
this.warn(warning);
|
|
112
|
+
}
|
|
113
|
+
|
|
61
114
|
getCanonicalization(signatureData) {
|
|
62
115
|
let canonicalization = signatureData?.canonicalization || this.canonicalization;
|
|
63
116
|
let headerCanon = canonicalization.split('/').shift().toLowerCase().trim();
|
|
@@ -66,11 +119,12 @@ class DkimSigner extends MessageParser {
|
|
|
66
119
|
}
|
|
67
120
|
|
|
68
121
|
getAlgorithm(signatureData) {
|
|
69
|
-
|
|
70
|
-
let
|
|
71
|
-
let hashAlgo = algorithm
|
|
122
|
+
// the shared algorithm option is for DKIM signatures, an ARC-Message-Signature has its own
|
|
123
|
+
let algorithm = (signatureData?.algorithm || (signatureData?.type === 'ARC' ? '' : this.algorithm) || '').toLowerCase().trim();
|
|
124
|
+
let { signAlgo, hashAlgo } = splitAlgorithm(algorithm);
|
|
72
125
|
|
|
73
|
-
|
|
126
|
+
// the signing algorithm defaults to the key type
|
|
127
|
+
return { algorithm, signAlgo: signAlgo || false, hashAlgo: hashAlgo || 'sha256' };
|
|
74
128
|
}
|
|
75
129
|
|
|
76
130
|
setupHashes() {
|
|
@@ -119,15 +173,89 @@ class DkimSigner extends MessageParser {
|
|
|
119
173
|
}
|
|
120
174
|
}
|
|
121
175
|
|
|
176
|
+
// Validates the d= and s= values (RFC 6376 section 3.5) and returns them as A-labels.
|
|
177
|
+
// A value that would break out of its tag or out of the header is refused in every mode
|
|
178
|
+
validateSigningIdentifiers(signatureData) {
|
|
179
|
+
let checks = [
|
|
180
|
+
['signingDomain', 'd=', DOMAIN_NAME, 'EINVALIDDOMAIN'],
|
|
181
|
+
['selector', 's=', SELECTOR, 'EINVALIDSELECTOR']
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (let [key, tag, format, code] of checks) {
|
|
185
|
+
let value = typeof signatureData[key] === 'string' ? signatureData[key].trim() : '';
|
|
186
|
+
let aLabel = toALabel(value);
|
|
187
|
+
|
|
188
|
+
if (!value || UNSAFE_TAG_VALUE.test(aLabel)) {
|
|
189
|
+
let err = new Error(value ? `Invalid ${tag} value ${JSON.stringify(signatureData[key])}` : `Missing ${tag} value`);
|
|
190
|
+
err.code = code;
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!format.test(aLabel)) {
|
|
195
|
+
this.violation(`Invalid ${tag} value ${JSON.stringify(signatureData[key])}`, code, key === 'signingDomain' ? 'd-syntax' : 's-syntax');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The i= value of a DKIM-Signature, from the `identity` option (RFC 6376 section 3.5)
|
|
201
|
+
getIdentity(signatureData) {
|
|
202
|
+
if (signatureData.type !== 'DKIM' || !signatureData.identity) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let identity = signatureData.identity.toString().trim();
|
|
207
|
+
let atPos = identity.lastIndexOf('@');
|
|
208
|
+
let domain = atPos >= 0 ? toALabel(identity.substring(atPos + 1)) : '';
|
|
209
|
+
|
|
210
|
+
if (atPos < 0 || !domain || /[\s;\x00-\x1f\x7f]/.test(identity)) {
|
|
211
|
+
let err = new Error(`Invalid identity ${JSON.stringify(signatureData.identity)}`);
|
|
212
|
+
err.code = 'EINVALIDIDENTITY';
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!isSameOrSubdomain(domain, signatureData.signingDomain)) {
|
|
217
|
+
// "The domain part of the address MUST be the same as, or a subdomain of, the value
|
|
218
|
+
// of the d= tag", or every verifier ignores the signature
|
|
219
|
+
this.violation(`Identity domain is not the same as or a subdomain of ${signatureData.signingDomain}`, 'EINVALIDIDENTITY', 'identity-domain');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return identity;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The t= and x= values in seconds (RFC 6376 section 3.5), false for a tag that is left out
|
|
226
|
+
getTimestamps() {
|
|
227
|
+
let timestamp = Math.floor(getCurTime(this.signTime).getTime() / 1000);
|
|
228
|
+
let expiration = this.expires ? Math.floor(getCurTime(this.expires).getTime() / 1000) : false;
|
|
229
|
+
|
|
230
|
+
let validTimestamp = value => Number.isInteger(value) && value >= 0 && value <= MAX_TIMESTAMP;
|
|
231
|
+
|
|
232
|
+
if (!validTimestamp(timestamp)) {
|
|
233
|
+
// 1*12DIGIT can not hold a time before 1970 or after the year 33658
|
|
234
|
+
this.violation('Signing time can not be represented in a t= tag', 'EINVALIDTIME', 'invalid-signtime');
|
|
235
|
+
timestamp = false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (expiration !== false && !validTimestamp(expiration)) {
|
|
239
|
+
this.violation('Expiration time can not be represented in an x= tag', 'EINVALIDTIME', 'invalid-expiration');
|
|
240
|
+
expiration = false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (expiration !== false && timestamp !== false && expiration <= timestamp) {
|
|
244
|
+
// "The value of the x= tag MUST be greater than the value of the t= tag"
|
|
245
|
+
this.violation('Expiration time must be later than the signing time', 'EINVALIDTIME', 'invalid-expiration');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { timestamp, expiration };
|
|
249
|
+
}
|
|
250
|
+
|
|
122
251
|
async messageHeaders(headers) {
|
|
123
252
|
this.headers = headers;
|
|
124
253
|
|
|
125
254
|
if (this.getARChain) {
|
|
126
255
|
try {
|
|
127
|
-
this.arc.chain = this
|
|
256
|
+
this.arc.chain = this.getARChain(headers);
|
|
128
257
|
if (this.arc.chain?.length) {
|
|
129
258
|
this.arc.lastEntry = this.arc.chain[this.arc.chain.length - 1];
|
|
130
|
-
this.arc.instance = this.arc.instance ? this.arc.instance : this.arc.lastEntry.i + 1;
|
|
131
259
|
}
|
|
132
260
|
} catch (err) {
|
|
133
261
|
this.arc.error = err;
|
|
@@ -162,27 +290,44 @@ class DkimSigner extends MessageParser {
|
|
|
162
290
|
return this.finalize();
|
|
163
291
|
}
|
|
164
292
|
|
|
293
|
+
// The header fields to sign for a signature: its own headerList, the shared one, or the
|
|
294
|
+
// default list of its type. An ARC-Message-Signature never covers ARC header fields or
|
|
295
|
+
// Authentication-Results (RFC 8617 section 4.1.2)
|
|
296
|
+
getFieldNames(signatureData) {
|
|
297
|
+
let fieldNames =
|
|
298
|
+
normalizeHeaderList(signatureData.headerList) || this.headerList || (signatureData.type === 'ARC' ? defaultARCFieldNames : defaultDKIMFieldNames);
|
|
299
|
+
|
|
300
|
+
if (signatureData.type === 'ARC') {
|
|
301
|
+
fieldNames = fieldNames
|
|
302
|
+
.split(':')
|
|
303
|
+
.filter(name => !ARC_EXCLUDED_FIELDS.includes(name.trim().toLowerCase()))
|
|
304
|
+
.join(':');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return fieldNames;
|
|
308
|
+
}
|
|
309
|
+
|
|
165
310
|
async finalize() {
|
|
166
311
|
for (let signatureData of this.signatureData || []) {
|
|
167
312
|
if (!signatureData.privateKey) {
|
|
168
313
|
continue;
|
|
169
314
|
}
|
|
170
315
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
316
|
+
const pushError = (err, extra) => {
|
|
317
|
+
this.errors.push(
|
|
318
|
+
Object.assign(
|
|
319
|
+
{
|
|
320
|
+
type: signatureData.type,
|
|
321
|
+
selector: signatureData.selector,
|
|
322
|
+
signingDomain: signatureData.signingDomain
|
|
323
|
+
},
|
|
324
|
+
extra || {},
|
|
325
|
+
{ err }
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
};
|
|
184
329
|
|
|
185
|
-
let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.
|
|
330
|
+
let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.getFieldNames(signatureData));
|
|
186
331
|
|
|
187
332
|
if (!signingHeaderLines.headers.some(header => header.key === 'from')) {
|
|
188
333
|
// RFC 6376 section 5.4: the From header field MUST be signed, and section
|
|
@@ -191,12 +336,29 @@ class DkimSigner extends MessageParser {
|
|
|
191
336
|
// empty h= tag, which is not even valid sig-h-tag syntax
|
|
192
337
|
let err = new Error('Can not sign a message that has no From header');
|
|
193
338
|
err.code = 'ENOFROM';
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
339
|
+
pushError(err);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let identity, timestamps, instance;
|
|
344
|
+
try {
|
|
345
|
+
// throws on a value that is not safe to put into the header
|
|
346
|
+
this.validateSigningIdentifiers(signatureData);
|
|
347
|
+
identity = this.getIdentity(signatureData);
|
|
348
|
+
timestamps = this.getTimestamps();
|
|
349
|
+
|
|
350
|
+
if (signatureData.type === 'ARC') {
|
|
351
|
+
// createSeal decides where a set without an explicit instance goes
|
|
352
|
+
instance = this.arc.instance || (this.getArcInstance ? this.getArcInstance(this.headers) : 1);
|
|
353
|
+
if (!Number.isInteger(instance) || instance < 1 || instance > MAX_ARC_INSTANCE) {
|
|
354
|
+
// RFC 8617 section 4.2.1, an ARC chain holds at most 50 sets
|
|
355
|
+
let err = new Error(`ARC instance ${instance} is out of range (1-${MAX_ARC_INSTANCE})`);
|
|
356
|
+
err.code = 'EINVALIDINSTANCE';
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
pushError(err);
|
|
200
362
|
continue;
|
|
201
363
|
}
|
|
202
364
|
|
|
@@ -252,6 +414,15 @@ class DkimSigner extends MessageParser {
|
|
|
252
414
|
}
|
|
253
415
|
|
|
254
416
|
algorithm = `${signAlgo}-${hashAlgo}`;
|
|
417
|
+
|
|
418
|
+
if (keyType === 'rsa' && privateKeyObj.asymmetricKeyDetails?.modulusLength < MIN_RSA_KEY_SIZE) {
|
|
419
|
+
// RFC 8301 section 3.2, verifiers MUST NOT accept such a signature
|
|
420
|
+
this.violation(
|
|
421
|
+
`RSA key too short (${privateKeyObj.asymmetricKeyDetails.modulusLength} bits, at least ${MIN_RSA_KEY_SIZE} required)`,
|
|
422
|
+
'ESHORTKEY',
|
|
423
|
+
'weak-key'
|
|
424
|
+
);
|
|
425
|
+
}
|
|
255
426
|
} catch (err) {
|
|
256
427
|
this.errors.push({
|
|
257
428
|
selector: signatureData.selector,
|
|
@@ -262,8 +433,20 @@ class DkimSigner extends MessageParser {
|
|
|
262
433
|
}
|
|
263
434
|
|
|
264
435
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
436
|
+
if (this.strict && hashAlgo === 'sha1') {
|
|
437
|
+
// RFC 8301 section 3.1, "rsa-sha1 MUST NOT be used for signing"
|
|
438
|
+
let err = new Error('rsa-sha1 is not allowed for signing');
|
|
439
|
+
err.code = 'EINVALIDALGO';
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// throws if invalid, also for ed25519-sha1, which is not an algorithm. ARC
|
|
444
|
+
// signatures are never made with sha1, no ARC validator accepts them
|
|
445
|
+
validateAlgorithm(algorithm, signatureData.type === 'ARC');
|
|
446
|
+
|
|
447
|
+
if (hashAlgo === 'sha1') {
|
|
448
|
+
this.warn('rsa-sha1');
|
|
449
|
+
}
|
|
267
450
|
} catch (err) {
|
|
268
451
|
this.errors.push({
|
|
269
452
|
algorithm,
|
|
@@ -282,12 +465,15 @@ class DkimSigner extends MessageParser {
|
|
|
282
465
|
{},
|
|
283
466
|
signatureData,
|
|
284
467
|
{
|
|
285
|
-
instance: '
|
|
468
|
+
instance: signatureData.type === 'ARC' ? instance : false, // ARC only
|
|
469
|
+
identity, // DKIM only
|
|
286
470
|
algorithm,
|
|
287
471
|
canonicalization: this.getCanonicalization(signatureData).canonicalization,
|
|
288
472
|
|
|
289
473
|
signTime: this.signTime,
|
|
290
474
|
expires: this.expires,
|
|
475
|
+
timestamp: timestamps.timestamp,
|
|
476
|
+
expiration: timestamps.expiration,
|
|
291
477
|
|
|
292
478
|
bodyHash: this.bodyHashes.has(hashKey) ? this.bodyHashes.get(hashKey).hash : null
|
|
293
479
|
},
|