mailauth 6.0.0 → 7.1.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 +25 -0
- package/README.md +113 -39
- package/bin/mailauth.js +15 -6
- package/cli.md +9 -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 +508 -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 +3 -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 +577 -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 +154 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +77 -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/tools.js
CHANGED
|
@@ -10,8 +10,10 @@ const crypto = require('node:crypto');
|
|
|
10
10
|
const https = require('node:https');
|
|
11
11
|
const packageData = require('../package');
|
|
12
12
|
const parseDkimHeaders = require('./parse-dkim-headers');
|
|
13
|
+
const { parseTagList } = parseDkimHeaders;
|
|
13
14
|
const tldts = require('tldts');
|
|
14
15
|
const Joi = require('joi');
|
|
16
|
+
const { DOMAIN_NAME } = require('./dkim/syntax');
|
|
15
17
|
const base64Schema = Joi.string().base64({ paddingRequired: false });
|
|
16
18
|
|
|
17
19
|
const defaultDKIMFieldNames =
|
|
@@ -34,6 +36,51 @@ const TLDTS_OPTS = {
|
|
|
34
36
|
allowPrivateDomains: true
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Converts the U-labels of a domain name to A-labels (RFC 5890 section 2.3.2.1). Only the
|
|
41
|
+
* labels that have non-ASCII characters in them are touched: they are normalized to NFC and
|
|
42
|
+
* lower cased first, because the Punycode encoding of an upper case letter is a different
|
|
43
|
+
* label than the encoding of its lower case form, and IDNA2008 has no upper case U-labels.
|
|
44
|
+
* ASCII labels are returned as they are, so a value that is already an A-label (or plain
|
|
45
|
+
* ASCII) never changes.
|
|
46
|
+
*
|
|
47
|
+
* @param {String} domain Domain name
|
|
48
|
+
* @returns {String} Domain name with A-labels, or the input if it can not be converted
|
|
49
|
+
*/
|
|
50
|
+
const toALabel = domain => {
|
|
51
|
+
domain = (domain || '').toString();
|
|
52
|
+
if (!NON_ASCII.test(domain)) {
|
|
53
|
+
return domain;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return domain
|
|
57
|
+
.split('.')
|
|
58
|
+
.map(label => {
|
|
59
|
+
if (!NON_ASCII.test(label)) {
|
|
60
|
+
return label;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return punycode.toASCII(label.normalize('NFC').toLowerCase());
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return label;
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.join('.');
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// lower case A-label form of a domain without the root dot, for comparing domains
|
|
72
|
+
const normalizeDomainName = domain =>
|
|
73
|
+
toALabel((domain || '').toString().trim())
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/\.$/, '');
|
|
76
|
+
|
|
77
|
+
// Whether `child` is `parent` or one of its subdomains
|
|
78
|
+
const isSameOrSubdomain = (child, parent) => {
|
|
79
|
+
child = normalizeDomainName(child);
|
|
80
|
+
parent = normalizeDomainName(parent);
|
|
81
|
+
return !!child && !!parent && (child === parent || child.endsWith(`.${parent}`));
|
|
82
|
+
};
|
|
83
|
+
|
|
37
84
|
const writeToStream = async (stream, input, chunkSize) => {
|
|
38
85
|
chunkSize = chunkSize || 64 * 1024;
|
|
39
86
|
|
|
@@ -253,26 +300,17 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
253
300
|
}
|
|
254
301
|
|
|
255
302
|
if (['d', 's'].includes(key)) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
val = punycode.toASCII(val);
|
|
259
|
-
} catch (err) {
|
|
260
|
-
// ignore
|
|
261
|
-
}
|
|
303
|
+
// convert to A-label if needed
|
|
304
|
+
val = toALabel(val);
|
|
262
305
|
}
|
|
263
306
|
|
|
264
307
|
if (key === 'i' && type === 'DKIM') {
|
|
265
308
|
val = val.toString();
|
|
266
|
-
|
|
309
|
+
// the domain is what follows the last "@", a quoted local-part may hold one
|
|
310
|
+
let atPos = val.lastIndexOf('@');
|
|
267
311
|
if (atPos >= 0) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// convert to A-label if needed
|
|
271
|
-
domainPart = punycode.toASCII(domainPart);
|
|
272
|
-
} catch (err) {
|
|
273
|
-
// ignore
|
|
274
|
-
}
|
|
275
|
-
val = val.substr(0, atPos + 1) + domainPart;
|
|
312
|
+
// convert to A-label if needed
|
|
313
|
+
val = val.substr(0, atPos + 1) + toALabel(val.substr(atPos + 1));
|
|
276
314
|
}
|
|
277
315
|
}
|
|
278
316
|
|
|
@@ -287,11 +325,116 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
287
325
|
return header;
|
|
288
326
|
};
|
|
289
327
|
|
|
290
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Creates an Error with a code, and any extra properties copied onto it
|
|
330
|
+
*
|
|
331
|
+
* @param {String} message Error message
|
|
332
|
+
* @param {String} code Value for err.code
|
|
333
|
+
* @param {Object} [extra] Properties to add to the error
|
|
334
|
+
* @returns {Error}
|
|
335
|
+
*/
|
|
336
|
+
const createError = (message, code, extra) => {
|
|
337
|
+
let err = new Error(message);
|
|
338
|
+
err.code = code;
|
|
339
|
+
return extra ? Object.assign(err, extra) : err;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Appends every item that is not in the list yet, keeping the list free of duplicates
|
|
344
|
+
*
|
|
345
|
+
* @param {Array} list List to add to
|
|
346
|
+
* @param {...*} items Items to add
|
|
347
|
+
* @returns {Array} The same list
|
|
348
|
+
*/
|
|
349
|
+
const addWarning = (list, ...items) => {
|
|
350
|
+
for (let item of items) {
|
|
351
|
+
if (!list.includes(item)) {
|
|
352
|
+
list.push(item);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return list;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// The result comment for a key lookup that failed with one of these codes. The DKIM verifier
|
|
359
|
+
// reports them as they are, the ARC validator adds "for <query domain>"
|
|
360
|
+
const KEY_ERROR_COMMENTS = new Map([
|
|
361
|
+
['ENOTFOUND', 'no key'],
|
|
362
|
+
['ENODATA', 'no key'],
|
|
363
|
+
['EINVALIDVER', 'unknown key version'],
|
|
364
|
+
['EINVALIDTYPE', 'unknown key type'],
|
|
365
|
+
['EINVALIDVAL', 'invalid public key'],
|
|
366
|
+
// the key record h= does not list the hash algorithm (RFC 6376 section 6.1.2)
|
|
367
|
+
['EINVALIDHASH', 'inappropriate hash algorithm'],
|
|
368
|
+
// the key record s= does not list email (RFC 6376 section 3.6.1)
|
|
369
|
+
['EINVALIDSERVICE', 'key not for email']
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
// an error about a key record, with the record itself as err.rr when there is one
|
|
373
|
+
const keyError = (message, code, rr, extra) => createError(message, code, Object.assign(rr ? { rr } : {}, extra));
|
|
374
|
+
|
|
375
|
+
// splits a colon separated key record list (h=, s=, t=) into lower case entries
|
|
376
|
+
const splitKeyList = value =>
|
|
377
|
+
(typeof value === 'string' ? value : '')
|
|
378
|
+
.split(':')
|
|
379
|
+
.map(entry => entry.trim().toLowerCase())
|
|
380
|
+
.filter(entry => entry);
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Exports a public key as PEM. RSA keys have always been returned in the PKCS#1 form, but some
|
|
384
|
+
* OpenSSL versions (Node.js 24.21) can not write a key that was read from a bare RSAPublicKey in
|
|
385
|
+
* that form, so SubjectPublicKeyInfo is used then. Both forms verify the same signatures
|
|
386
|
+
*
|
|
387
|
+
* @param {KeyObject} keyObj Public key
|
|
388
|
+
* @returns {String} PEM encoded key
|
|
389
|
+
*/
|
|
390
|
+
const exportPublicKey = keyObj => {
|
|
391
|
+
if (keyObj.asymmetricKeyType !== 'ed25519') {
|
|
392
|
+
try {
|
|
393
|
+
return keyObj.export({ type: 'pkcs1', format: 'pem' });
|
|
394
|
+
} catch (err) {
|
|
395
|
+
// fall back to SubjectPublicKeyInfo
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return keyObj.export({ type: 'spki', format: 'pem' });
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Tells a bare RSAPublicKey (RFC 3447 A.1.1, SEQUENCE of two INTEGERs) apart from a
|
|
403
|
+
* SubjectPublicKeyInfo (a SEQUENCE that starts with the AlgorithmIdentifier SEQUENCE) by the
|
|
404
|
+
* tag of the first element of the outer DER SEQUENCE
|
|
405
|
+
*
|
|
406
|
+
* @param {Buffer} der DER encoded key
|
|
407
|
+
* @returns {Boolean} true for a bare RSAPublicKey
|
|
408
|
+
*/
|
|
409
|
+
const isBareRsaPublicKey = der => {
|
|
410
|
+
if (der.length < 3 || der[0] !== 0x30) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
let headerLength = der[1] < 0x80 ? 2 : 2 + (der[1] & 0x7f);
|
|
414
|
+
return der[headerLength] === 0x02;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Fetches and validates a DKIM public key record (RFC 6376 section 3.6.1, RFC 8463)
|
|
419
|
+
*
|
|
420
|
+
* @param {String} type 'DKIM', 'ARC' (for an ARC-Message-Signature) or 'AS' (for an ARC-Seal)
|
|
421
|
+
* @param {String} name DNS name of the key record, "selector._domainkey.domain"
|
|
422
|
+
* @param {Number} [minBitLength=1024] Minimum RSA key size, smaller keys throw ESHORTKEY
|
|
423
|
+
* @param {Function} [resolver] DNS resolver
|
|
424
|
+
* @param {Object} [options]
|
|
425
|
+
* @param {Boolean} [options.strict=false] Apply the RFC 6376 key record syntax rules exactly
|
|
426
|
+
* @param {String} [options.hashAlgo] Hash algorithm of the signature ('sha256', 'sha1'). When set,
|
|
427
|
+
* a key record h= tag that does not list it throws EINVALIDHASH (RFC 6376 section 6.1.2)
|
|
428
|
+
* @returns {Object} { publicKey, rr, modulusLength, keyType, flags, testing, warnings }
|
|
429
|
+
*/
|
|
430
|
+
const getPublicKey = async (type, name, minBitLength, resolver, options) => {
|
|
291
431
|
minBitLength = minBitLength || 1024;
|
|
292
432
|
resolver = resolver || dns.resolve;
|
|
433
|
+
options = options || {};
|
|
434
|
+
const strict = !!options.strict;
|
|
293
435
|
|
|
294
|
-
|
|
436
|
+
// RFC 8616 section 3: a U-label is converted to an A-label before it is looked up
|
|
437
|
+
let list = await resolver(toALabel(name), 'TXT');
|
|
295
438
|
let rr =
|
|
296
439
|
list &&
|
|
297
440
|
[]
|
|
@@ -299,97 +442,177 @@ const getPublicKey = async (type, name, minBitLength, resolver) => {
|
|
|
299
442
|
.join('')
|
|
300
443
|
.replace(/\s+/g, '');
|
|
301
444
|
|
|
302
|
-
if (rr) {
|
|
303
|
-
|
|
304
|
-
|
|
445
|
+
if (!rr) {
|
|
446
|
+
throw keyError('Missing key value', 'EINVALIDVAL');
|
|
447
|
+
}
|
|
305
448
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
throw err;
|
|
449
|
+
const warnings = [];
|
|
450
|
+
// A rule the strict mode enforces: throws in the strict mode, notes the warning otherwise
|
|
451
|
+
const violation = (message, code, warning) => {
|
|
452
|
+
if (strict) {
|
|
453
|
+
throw keyError(message, code, rr);
|
|
312
454
|
}
|
|
455
|
+
addWarning(warnings, warning);
|
|
456
|
+
};
|
|
313
457
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
err.code = 'EINVALIDVAL';
|
|
318
|
-
err.rr = rr;
|
|
319
|
-
err.details = validation.error;
|
|
320
|
-
throw err;
|
|
321
|
-
}
|
|
458
|
+
// tag names are case-sensitive, so strict mode reads them as they are
|
|
459
|
+
let entry = parseTagList(rr, { strict });
|
|
460
|
+
const tags = entry.parsed;
|
|
322
461
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
462
|
+
if (entry.syntaxErrors.length) {
|
|
463
|
+
// RFC 6376 section 6.1.2 step 5: a key record that does not adhere to the format is ignored
|
|
464
|
+
violation(`Invalid key record syntax: ${entry.syntaxErrors[0]}`, 'EINVALIDVAL', 'key-syntax');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const publicKeyValue = tags.p?.value;
|
|
468
|
+
if (!publicKeyValue) {
|
|
469
|
+
// an empty p= means the key has been revoked
|
|
470
|
+
throw keyError('Missing key value', 'EINVALIDVAL', rr);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let validation = base64Schema.validate(publicKeyValue);
|
|
474
|
+
if (validation.error) {
|
|
475
|
+
throw keyError('Invalid base64 format for public key', 'EINVALIDVAL', rr, { details: validation.error });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ARC signatures use the same DKIM key records (RFC 8617 section 4.1), so the version
|
|
479
|
+
// rules apply to them as well
|
|
480
|
+
if (tags.v) {
|
|
481
|
+
let version = (tags.v.value || '').toString();
|
|
482
|
+
if (version.toLowerCase().trim() !== 'dkim1') {
|
|
483
|
+
throw keyError('Unknown key version', 'EINVALIDVER', rr);
|
|
328
484
|
}
|
|
329
485
|
|
|
330
|
-
|
|
331
|
-
|
|
486
|
+
// key-v-tag is %x44.4B.49.4D.31, an exact string, and "MUST be the first tag"
|
|
487
|
+
if (version !== 'DKIM1' || entry.tags[0]?.name !== 'v') {
|
|
488
|
+
violation(version !== 'DKIM1' ? 'Unknown key version' : 'Key version tag is not the first tag', 'EINVALIDVER', 'key-v-syntax');
|
|
489
|
+
}
|
|
490
|
+
}
|
|
332
491
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
492
|
+
// RFC 6376 section 3.6.1 s=: "Verifiers for a given service type MUST ignore this record
|
|
493
|
+
// if the appropriate type is not listed", applied in every mode, it is the key owner's choice
|
|
494
|
+
if (tags.s) {
|
|
495
|
+
let services = splitKeyList(tags.s.value);
|
|
496
|
+
if (!services.includes('email') && !services.includes('*')) {
|
|
497
|
+
throw keyError('Key is not meant for email', 'EINVALIDSERVICE', rr);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
336
500
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
type: 'spki'
|
|
344
|
-
};
|
|
345
|
-
} else {
|
|
346
|
-
const publicKeyPem = Buffer.from(`-----BEGIN PUBLIC KEY-----\n${paddedPublicKey.replace(/.{64}/g, '$&\n').trim()}\n-----END PUBLIC KEY-----`);
|
|
347
|
-
publicKeyOpts = {
|
|
348
|
-
key: publicKeyPem,
|
|
349
|
-
format: 'pem'
|
|
350
|
-
};
|
|
501
|
+
// RFC 6376 section 6.1.2 step 6: the key owner can limit the hash algorithms the key
|
|
502
|
+
// is used with, which is also how a domain opts out of rsa-sha1 in the lenient mode
|
|
503
|
+
if (tags.h && options.hashAlgo) {
|
|
504
|
+
let hashes = splitKeyList(tags.h.value);
|
|
505
|
+
if (!hashes.includes(options.hashAlgo.toLowerCase())) {
|
|
506
|
+
throw keyError(`Key does not allow ${options.hashAlgo}`, 'EINVALIDHASH', rr);
|
|
351
507
|
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let declaredKeyType = tags.k ? (tags.k.value || '').toString().toLowerCase().trim() : false;
|
|
511
|
+
|
|
512
|
+
let rawPublicKey = Buffer.from(publicKeyValue, 'base64');
|
|
513
|
+
let publicKeyObj;
|
|
514
|
+
|
|
515
|
+
let candidates = [];
|
|
516
|
+
if (rawPublicKey.length === 32 && (!strict || declaredKeyType === 'ed25519')) {
|
|
517
|
+
// RFC 8463 section 4: the raw 32 byte ed25519 key
|
|
518
|
+
candidates.push({
|
|
519
|
+
key: Buffer.concat([Buffer.from('302A300506032B6570032100', 'hex'), rawPublicKey]),
|
|
520
|
+
format: 'der',
|
|
521
|
+
type: 'spki'
|
|
522
|
+
});
|
|
523
|
+
} else if (isBareRsaPublicKey(rawPublicKey)) {
|
|
524
|
+
// the bare RSAPublicKey of RFC 6376 section 3.6.1 (k=rsa), picked by its structure
|
|
525
|
+
candidates.push({ key: rawPublicKey, format: 'der', type: 'pkcs1' });
|
|
526
|
+
} else {
|
|
527
|
+
// a SubjectPublicKeyInfo structure is what is published in practice
|
|
528
|
+
candidates.push({ key: rawPublicKey, format: 'der', type: 'spki' });
|
|
529
|
+
}
|
|
352
530
|
|
|
531
|
+
let lastError;
|
|
532
|
+
let exportedKey;
|
|
533
|
+
for (let candidate of candidates) {
|
|
353
534
|
try {
|
|
354
|
-
|
|
535
|
+
let keyObj = crypto.createPublicKey(candidate);
|
|
536
|
+
exportedKey = exportPublicKey(keyObj);
|
|
537
|
+
publicKeyObj = keyObj;
|
|
538
|
+
break;
|
|
355
539
|
} catch (err) {
|
|
356
|
-
|
|
357
|
-
error.code = 'EINVALIDTYPE';
|
|
358
|
-
error.rr = rr;
|
|
359
|
-
throw error;
|
|
540
|
+
lastError = err;
|
|
360
541
|
}
|
|
542
|
+
}
|
|
361
543
|
|
|
362
|
-
|
|
544
|
+
if (!publicKeyObj) {
|
|
545
|
+
let error = new Error('Unknown key type', { cause: lastError });
|
|
546
|
+
error.code = 'EINVALIDTYPE';
|
|
547
|
+
error.rr = rr;
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
363
550
|
|
|
364
|
-
|
|
365
|
-
let err = new Error('Unknown key type (${keyType})');
|
|
366
|
-
err.code = 'EINVALIDTYPE';
|
|
367
|
-
err.rr = rr;
|
|
368
|
-
throw err;
|
|
369
|
-
}
|
|
551
|
+
let keyType = publicKeyObj.asymmetricKeyType;
|
|
370
552
|
|
|
371
|
-
|
|
553
|
+
if (!['rsa', 'ed25519'].includes(keyType) || (declaredKeyType && declaredKeyType !== keyType)) {
|
|
554
|
+
throw keyError(`Unknown key type (${keyType})`, 'EINVALIDTYPE', rr);
|
|
555
|
+
}
|
|
372
556
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
throw err;
|
|
378
|
-
}
|
|
557
|
+
if (!declaredKeyType && keyType !== 'rsa') {
|
|
558
|
+
// k= defaults to "rsa", an ed25519 key is only found by looking at the key data
|
|
559
|
+
violation(`Unknown key type (${keyType})`, 'EINVALIDTYPE', 'key-type-inferred');
|
|
560
|
+
}
|
|
379
561
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
562
|
+
if (strict && keyType === 'ed25519' && rawPublicKey.length !== 32) {
|
|
563
|
+
// RFC 8463 section 4 publishes the bare key, not a DER structure
|
|
564
|
+
throw keyError('Invalid ed25519 public key', 'EINVALIDVAL', rr);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let modulusLength = publicKeyObj.asymmetricKeyDetails.modulusLength;
|
|
568
|
+
|
|
569
|
+
if (keyType === 'rsa' && modulusLength < minBitLength) {
|
|
570
|
+
throw keyError('RSA key too short', 'ESHORTKEY', rr);
|
|
388
571
|
}
|
|
389
572
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
573
|
+
// t= flags. "y" is the testing mode, "s" forbids an i= domain that is a subdomain of d=
|
|
574
|
+
let flags = splitKeyList(tags.t?.value);
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
publicKey: exportedKey,
|
|
578
|
+
rr,
|
|
579
|
+
modulusLength,
|
|
580
|
+
keyType,
|
|
581
|
+
flags,
|
|
582
|
+
testing: flags.includes('y'),
|
|
583
|
+
warnings
|
|
584
|
+
};
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Creates a key lookup cache for one message. DKIM-Signatures, the ARC-Message-Signature and the
|
|
589
|
+
* ARC-Seals that name the same key record share one getPublicKey() call, and so one DNS query.
|
|
590
|
+
* Everything that changes the outcome of the lookup is part of the cache key, so a result is
|
|
591
|
+
* never used for a different question
|
|
592
|
+
*
|
|
593
|
+
* @param {Function} [resolver] DNS resolver, used for every lookup of the cache
|
|
594
|
+
* @returns {Object} { resolver, get(type, name, minBitLength, options) }, `get` takes the
|
|
595
|
+
* arguments of getPublicKey() without the resolver and returns the same promise for
|
|
596
|
+
* the same question
|
|
597
|
+
*/
|
|
598
|
+
const createKeyCache = resolver => {
|
|
599
|
+
const cache = new Map();
|
|
600
|
+
return {
|
|
601
|
+
resolver,
|
|
602
|
+
get(type, name, minBitLength, options) {
|
|
603
|
+
options = options || {};
|
|
604
|
+
let key = [!!options.strict, minBitLength || '', options.hashAlgo || '', name].join(':');
|
|
605
|
+
let lookup = cache.get(key);
|
|
606
|
+
if (!lookup) {
|
|
607
|
+
lookup = getPublicKey(type, name, minBitLength, resolver, options);
|
|
608
|
+
// a prefetched lookup can fail before anything waits for it. Every caller that
|
|
609
|
+
// awaits the promise still gets the error
|
|
610
|
+
lookup.catch(() => false);
|
|
611
|
+
cache.set(key, lookup);
|
|
612
|
+
}
|
|
613
|
+
return lookup;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
393
616
|
};
|
|
394
617
|
|
|
395
618
|
const getPrivateKey = privateKeyBuf => {
|
|
@@ -447,37 +670,158 @@ const fetch = url =>
|
|
|
447
670
|
.on('error', reject);
|
|
448
671
|
});
|
|
449
672
|
|
|
450
|
-
|
|
451
|
-
|
|
673
|
+
// RFC 2045 section 5.1 token: printable US-ASCII except SPACE and the tspecials ()<>@,;:\"/[]?=
|
|
674
|
+
const RFC2045_TOKEN = /^[!#$%&'*+\-.0-9A-Z^_`a-z{|}~]+$/;
|
|
675
|
+
// What the older formatter left unquoted: a token, or a token with "@" signs in it
|
|
676
|
+
const LEGACY_UNQUOTED = /^[!#$%&'*+\-.0-9A-Z^_`a-z{|}~@]+$/;
|
|
677
|
+
// RFC 5322 section 3.2.3 dot-atom-text
|
|
678
|
+
const DOT_ATOM_TEXT = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+(?:\.[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
|
|
679
|
+
// RFC 5322 section 3.2.4 quoted-string, without CFWS around it
|
|
680
|
+
const QUOTED_STRING = /^"(?:[^"\\\r\n]|\\[\x20-\x7e\t])*"$/;
|
|
681
|
+
// DOMAIN_NAME is the RFC 6376 section 3.5 domain-name, which RFC 8601 uses for pvalue
|
|
682
|
+
|
|
683
|
+
// A generated header line has to stay under the 998 character limit of RFC 5322 section
|
|
684
|
+
// 2.1.1, and a value with no whitespace in it can not be folded. A property value longer than
|
|
685
|
+
// this is left out (RFC 8601 section 2.2 lets a propspec be omitted) rather than truncated
|
|
686
|
+
// into a different identity, and a word of a comment is cut short, which only loses text
|
|
687
|
+
const MAX_HEADER_VALUE_LENGTH = 900;
|
|
688
|
+
const MAX_COMMENT_WORD_LENGTH = 400;
|
|
689
|
+
|
|
690
|
+
const cleanHeaderText = value =>
|
|
691
|
+
(value || '')
|
|
452
692
|
.toString()
|
|
453
|
-
|
|
693
|
+
// CTLs and DEL are not allowed anywhere in a structured field body, not even escaped
|
|
694
|
+
.replace(/[\x00-\x1F\x7F]+/g, ' ')
|
|
454
695
|
.replace(/\s+/g, ' ')
|
|
455
696
|
.trim();
|
|
456
697
|
|
|
457
|
-
|
|
698
|
+
const quoteString = value => `"${value.replace(/["\\]/g, c => `\\${c}`)}"`;
|
|
699
|
+
|
|
700
|
+
// A header value that is either an RFC 5322 dot-atom-text or, for anything else, a
|
|
701
|
+
// quoted-string, such as the key-value-pair values of Received-SPF (RFC 7208 section 9.1)
|
|
702
|
+
const formatDotAtomOrQuoted = value => {
|
|
703
|
+
value = cleanHeaderText(value);
|
|
704
|
+
return DOT_ATOM_TEXT.test(value) ? value : quoteString(value);
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Formats a property value for an Authentication-Results header (RFC 8601 section 2.2):
|
|
709
|
+
* pvalue = value / [ [ local-part ] "@" ] domain-name, where value is a token or a
|
|
710
|
+
* quoted-string. Returns an empty string when there is nothing to report.
|
|
711
|
+
*
|
|
712
|
+
* @param {String} value Property value
|
|
713
|
+
* @param {Boolean} [strict] If true, an email identity is always written in the
|
|
714
|
+
* local-part@domain form that RFC 8601 section 2.2 requires, with the local-part quoted
|
|
715
|
+
* when it is not a dot-atom. Otherwise the output is kept as it was in older versions
|
|
716
|
+
* wherever that output was already valid, which quotes such identities as a whole
|
|
717
|
+
*/
|
|
718
|
+
const escapePropValue = (value, strict) => {
|
|
719
|
+
value = cleanHeaderText(value);
|
|
720
|
+
|
|
721
|
+
if (!value) {
|
|
722
|
+
return '';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (RFC2045_TOKEN.test(value)) {
|
|
458
726
|
// return token value
|
|
459
727
|
return value;
|
|
460
728
|
}
|
|
461
729
|
|
|
730
|
+
let atPos = value.lastIndexOf('@');
|
|
731
|
+
if (atPos >= 0) {
|
|
732
|
+
let localPart = value.substring(0, atPos);
|
|
733
|
+
let domain = value.substring(atPos + 1);
|
|
734
|
+
|
|
735
|
+
if (DOMAIN_NAME.test(domain)) {
|
|
736
|
+
if (!localPart || DOT_ATOM_TEXT.test(localPart)) {
|
|
737
|
+
// a valid [ [ local-part ] "@" ] domain-name as it is
|
|
738
|
+
if (strict || LEGACY_UNQUOTED.test(value)) {
|
|
739
|
+
return value;
|
|
740
|
+
}
|
|
741
|
+
} else if (strict) {
|
|
742
|
+
return `${QUOTED_STRING.test(localPart) ? localPart : quoteString(localPart)}@${domain}`;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
462
747
|
// return quoted string with escaped quotes
|
|
463
|
-
return
|
|
748
|
+
return quoteString(value);
|
|
464
749
|
};
|
|
465
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Makes a value safe to use as the content of an RFC 5322 comment. Both parentheses and the
|
|
753
|
+
* backslash are escaped, so the comment always stays balanced and ends where it should.
|
|
754
|
+
*
|
|
755
|
+
* @param {String} value Comment text
|
|
756
|
+
* @returns {String} Escaped comment text, without the parentheses around it
|
|
757
|
+
*/
|
|
466
758
|
const escapeCommentValue = value => {
|
|
467
|
-
value = (value
|
|
468
|
-
.
|
|
469
|
-
.
|
|
470
|
-
|
|
471
|
-
|
|
759
|
+
value = cleanHeaderText(value)
|
|
760
|
+
.split(' ')
|
|
761
|
+
.map(word => {
|
|
762
|
+
if (Buffer.byteLength(word) <= MAX_COMMENT_WORD_LENGTH) {
|
|
763
|
+
return word;
|
|
764
|
+
}
|
|
765
|
+
// the limit is in octets, a line of UTF-8 text is measured in octets on the wire
|
|
766
|
+
let shortened = '';
|
|
767
|
+
for (let char of word) {
|
|
768
|
+
if (Buffer.byteLength(shortened + char) > MAX_COMMENT_WORD_LENGTH) {
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
shortened += char;
|
|
772
|
+
}
|
|
773
|
+
return shortened + '...';
|
|
774
|
+
})
|
|
775
|
+
.join(' ');
|
|
472
776
|
|
|
473
|
-
return
|
|
777
|
+
return value.replace(/[\\()]/g, c => `\\${c}`);
|
|
474
778
|
};
|
|
475
779
|
|
|
476
|
-
|
|
780
|
+
/**
|
|
781
|
+
* Formats the authserv-id of an Authentication-Results header (RFC 8601 section 2.5). It is
|
|
782
|
+
* usually the host name of the MTA, which is a token, and an internationalized host name is
|
|
783
|
+
* converted to its A-label form. Any other value is written as a quoted-string, which RFC 8601
|
|
784
|
+
* allows (authserv-id = value). With strict set such a value throws instead, as a reader that
|
|
785
|
+
* splits the header on semicolons would still take a value such as "mx.example; dkim=pass"
|
|
786
|
+
* for a result
|
|
787
|
+
*
|
|
788
|
+
* @param {String} authservId Usually the host name of the MTA
|
|
789
|
+
* @param {Boolean} [strict] If true, throws for a value that is not a token
|
|
790
|
+
* @returns {String} The authserv-id
|
|
791
|
+
*/
|
|
792
|
+
const formatAuthservId = (authservId, strict) => {
|
|
793
|
+
let value = toALabel(cleanHeaderText(authservId));
|
|
794
|
+
|
|
795
|
+
if (value && value.length <= MAX_HEADER_VALUE_LENGTH && RFC2045_TOKEN.test(value)) {
|
|
796
|
+
return value;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (!value || value.length > MAX_HEADER_VALUE_LENGTH || strict) {
|
|
800
|
+
let err = new TypeError(`Invalid authserv-id ${JSON.stringify((authservId || '').toString())}, expecting a host name`);
|
|
801
|
+
err.code = 'EINVALIDAUTHSERVID';
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return quoteString(value);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Formats a single resinfo of an Authentication-Results header (RFC 8601 section 2.2)
|
|
810
|
+
*
|
|
811
|
+
* @param {String} method Authentication method, eg. "dkim"
|
|
812
|
+
* @param {Object} status Result status: result, comment, and the policy/smtp/body/header ptypes
|
|
813
|
+
* @param {Object} [options]
|
|
814
|
+
* @param {Boolean} [options.strict] Format email identities in the RFC 8601 section 2.2 form
|
|
815
|
+
* @returns {String} Formatted resinfo, without the leading semicolon
|
|
816
|
+
*/
|
|
817
|
+
const formatAuthHeaderRow = (method, status, options) => {
|
|
477
818
|
status = status || {};
|
|
819
|
+
options = options || {};
|
|
478
820
|
let parts = [];
|
|
479
821
|
|
|
480
|
-
|
|
822
|
+
// the result is a Keyword (RFC 8601 section 2.2)
|
|
823
|
+
let result = (status.result || '').toString().replace(/[^A-Za-z0-9-]+/g, '') || 'none';
|
|
824
|
+
parts.push(`${method}=${result}`);
|
|
481
825
|
|
|
482
826
|
if (status.underSized) {
|
|
483
827
|
parts.push(`(${escapeCommentValue(`undersized signature: ${status.underSized} bytes unsigned`)})`);
|
|
@@ -493,9 +837,17 @@ const formatAuthHeaderRow = (method, status) => {
|
|
|
493
837
|
}
|
|
494
838
|
|
|
495
839
|
for (let prop of Object.keys(status[ptype])) {
|
|
496
|
-
|
|
497
|
-
|
|
840
|
+
let propValue = status[ptype][prop];
|
|
841
|
+
if (!propValue || !['string', 'number', 'boolean'].includes(typeof propValue)) {
|
|
842
|
+
continue;
|
|
498
843
|
}
|
|
844
|
+
|
|
845
|
+
let escaped = escapePropValue(propValue, options.strict);
|
|
846
|
+
if (!escaped || Buffer.byteLength(escaped) > MAX_HEADER_VALUE_LENGTH) {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
parts.push(`${ptype}.${prop}=${escaped}`);
|
|
499
851
|
}
|
|
500
852
|
}
|
|
501
853
|
|
|
@@ -524,20 +876,43 @@ const formatRelaxedLine = (line, suffix) => {
|
|
|
524
876
|
return Buffer.from(result, 'binary');
|
|
525
877
|
};
|
|
526
878
|
|
|
527
|
-
// RFC 6376 section 3.7: when the signature header is hashed, its own b= value
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
879
|
+
// RFC 6376 section 3.7: when the signature header is hashed, its own b= value "(including
|
|
880
|
+
// all surrounding whitespace)" is treated as empty. The segment emptied here has to be the
|
|
881
|
+
// exact one the tag-list parser (lib/parse-dkim-headers.js) takes the signature from, so the
|
|
882
|
+
// line is read the same way: split on the semicolons, which can not occur inside a tag value,
|
|
883
|
+
// and each tag name decoded as UTF-8 with the whitespace around it removed, as buildTagMap
|
|
884
|
+
// does. Matching a "b=" anywhere in the line instead would strip the wrong value when it
|
|
885
|
+
// appears inside another tag, such as a z= copy of a header or an unknown tag.
|
|
886
|
+
//
|
|
887
|
+
// The parser keeps the last value of a repeated tag. The lenient parser folds tag names to
|
|
888
|
+
// lower case, so there the last tag named "b" or "B" is the signature, the strict parser reads
|
|
889
|
+
// names case-sensitively (RFC 6376 section 3.2), so there it is the last tag named exactly "b".
|
|
890
|
+
const stripSignatureValue = (line, strict) => {
|
|
891
|
+
let str = line.toString('binary');
|
|
892
|
+
|
|
893
|
+
let colonPos = str.indexOf(':');
|
|
894
|
+
let prefix = colonPos >= 0 ? str.substring(0, colonPos + 1) : '';
|
|
895
|
+
let segments = (colonPos >= 0 ? str.substring(colonPos + 1) : str).split(';');
|
|
896
|
+
|
|
897
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
898
|
+
let eqPos = segments[i].indexOf('=');
|
|
899
|
+
if (eqPos < 0) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
let name = Buffer.from(segments[i].substring(0, eqPos), 'binary').toString().replace(/\s+/g, ' ').trim();
|
|
903
|
+
if ((strict ? name : name.toLowerCase()) === 'b') {
|
|
904
|
+
segments[i] = segments[i].substring(0, eqPos + 1);
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return Buffer.from(prefix + segments.join(';'), 'binary');
|
|
910
|
+
};
|
|
533
911
|
|
|
534
912
|
const formatDomain = domain => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
} catch (err) {
|
|
539
|
-
// ignore punycode errors
|
|
540
|
-
}
|
|
913
|
+
// toALabel normalizes a U-label to NFC first, so this matches the d= and s= values the
|
|
914
|
+
// signer writes and the verifier reports for the same name
|
|
915
|
+
domain = toALabel(domain.toLowerCase().trim()).toLowerCase().trim();
|
|
541
916
|
// the root label is not part of the name, so "example.com." and "example.com" are the same domain
|
|
542
917
|
return domain.replace(/\.+$/, '');
|
|
543
918
|
};
|
|
@@ -589,6 +964,22 @@ const getAlignment = (fromDomain, domainList, strict) => {
|
|
|
589
964
|
return false;
|
|
590
965
|
};
|
|
591
966
|
|
|
967
|
+
// The signing and the hashing algorithm of an a= value such as "rsa-sha256", in lower case.
|
|
968
|
+
// A value without a dash gives the same string for both
|
|
969
|
+
const splitAlgorithm = algorithm => {
|
|
970
|
+
let parts = (algorithm || '').toString().split('-');
|
|
971
|
+
return {
|
|
972
|
+
signAlgo: parts[0].toLowerCase().trim(),
|
|
973
|
+
hashAlgo: parts[parts.length - 1].toLowerCase().trim()
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// Whether a signing and hashing algorithm pair is one this library signs and verifies with:
|
|
978
|
+
// rsa-sha256 and ed25519-sha256, and rsa-sha1 when `allowSha1` is set. RFC 8463 defines
|
|
979
|
+
// ed25519-sha256 only, "ed25519-sha1" is not an algorithm
|
|
980
|
+
const isSupportedAlgorithm = (signAlgo, hashAlgo, allowSha1) =>
|
|
981
|
+
['rsa', 'ed25519'].includes(signAlgo) && (hashAlgo === 'sha256' || (!!allowSha1 && signAlgo === 'rsa' && hashAlgo === 'sha1'));
|
|
982
|
+
|
|
592
983
|
const validateAlgorithm = (algorithm, strict) => {
|
|
593
984
|
try {
|
|
594
985
|
if (!algorithm || !/^[^-]+-[^-]+$/.test(algorithm)) {
|
|
@@ -603,7 +994,7 @@ const validateAlgorithm = (algorithm, strict) => {
|
|
|
603
994
|
throw error;
|
|
604
995
|
}
|
|
605
996
|
|
|
606
|
-
if (!
|
|
997
|
+
if (!isSupportedAlgorithm(signAlgo, hashAlgo, !strict)) {
|
|
607
998
|
let error = new Error('Unknown hashing algorithm');
|
|
608
999
|
error.hashAlgo = hashAlgo;
|
|
609
1000
|
throw error;
|
|
@@ -827,6 +1218,10 @@ module.exports = {
|
|
|
827
1218
|
writeToStream,
|
|
828
1219
|
parseHeaders,
|
|
829
1220
|
|
|
1221
|
+
createError,
|
|
1222
|
+
addWarning,
|
|
1223
|
+
KEY_ERROR_COMMENTS,
|
|
1224
|
+
|
|
830
1225
|
defaultDKIMFieldNames,
|
|
831
1226
|
defaultARCFieldNames,
|
|
832
1227
|
defaultASFieldNames,
|
|
@@ -834,19 +1229,30 @@ module.exports = {
|
|
|
834
1229
|
getSigningHeaderLines,
|
|
835
1230
|
formatSignatureHeaderLine,
|
|
836
1231
|
parseDkimHeaders,
|
|
1232
|
+
parseTagList,
|
|
837
1233
|
getPublicKey,
|
|
838
1234
|
getPrivateKey,
|
|
839
1235
|
formatAuthHeaderRow,
|
|
840
1236
|
escapeCommentValue,
|
|
1237
|
+
escapePropValue,
|
|
1238
|
+
formatAuthservId,
|
|
1239
|
+
MAX_HEADER_VALUE_LENGTH,
|
|
1240
|
+
formatDotAtomOrQuoted,
|
|
841
1241
|
fetch,
|
|
842
1242
|
|
|
843
1243
|
validateAlgorithm,
|
|
1244
|
+
splitAlgorithm,
|
|
1245
|
+
isSupportedAlgorithm,
|
|
1246
|
+
createKeyCache,
|
|
844
1247
|
|
|
845
1248
|
getAlignment,
|
|
846
1249
|
|
|
847
1250
|
formatRelaxedLine,
|
|
848
1251
|
stripSignatureValue,
|
|
849
1252
|
formatDomain,
|
|
1253
|
+
toALabel,
|
|
1254
|
+
normalizeDomainName,
|
|
1255
|
+
isSameOrSubdomain,
|
|
850
1256
|
|
|
851
1257
|
getPtrHostname,
|
|
852
1258
|
|