mailauth 7.1.1 → 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.
- package/.c8rc.json +11 -0
- package/.prettierignore +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +131 -31
- package/bin/mailauth.js +62 -0
- package/cli.md +150 -8
- package/docs/arc.md +62 -54
- package/docs/bimi.md +153 -54
- package/docs/dkim.md +37 -36
- package/docs/dkim2.md +154 -0
- package/docs/dmarc.md +4 -1
- package/docs/mta-sts.md +12 -9
- package/docs/spf.md +14 -13
- package/index.d.ts +583 -42
- package/lib/arc/index.d.ts +6 -2
- package/lib/arc/index.js +45 -15
- package/lib/bimi/index.js +362 -102
- package/lib/bimi/validate-svg.js +493 -60
- package/lib/commands/dkim2-hash.js +25 -0
- package/lib/commands/dkim2-sign.js +77 -0
- package/lib/commands/dkim2-verify.js +59 -0
- package/lib/commands/report.js +10 -35
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +10 -38
- package/lib/commands/spf.js +4 -35
- package/lib/dkim/dkim-signer.js +50 -30
- package/lib/dkim/dkim-verifier.js +79 -27
- package/lib/dkim/header/simple.js +2 -2
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim/syntax.js +11 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dkim2/dkim2-parser.js +52 -0
- package/lib/dkim2/dkim2-signer.js +334 -0
- package/lib/dkim2/dkim2-verifier.js +572 -0
- package/lib/dkim2/fields.js +570 -0
- package/lib/dkim2/hash.d.ts +3 -0
- package/lib/dkim2/hash.js +51 -0
- package/lib/dkim2/key.js +203 -0
- package/lib/dkim2/message-state.js +181 -0
- package/lib/dkim2/recipe.js +134 -0
- package/lib/dkim2/sign.d.ts +3 -0
- package/lib/dkim2/sign.js +38 -0
- package/lib/dkim2/verify.d.ts +3 -0
- package/lib/dkim2/verify.js +19 -0
- package/lib/dmarc/author-domain.js +270 -5
- package/lib/dmarc/get-dmarc-record.js +24 -11
- package/lib/dmarc/verify.js +7 -3
- package/lib/mailauth.js +57 -14
- package/lib/mta-sts.js +37 -11
- package/lib/parse-received.js +214 -25
- package/lib/spf/index.js +21 -4
- package/lib/spf/macro.js +5 -3
- package/lib/spf/spf-verify.js +14 -5
- package/lib/spf/syntax.js +5 -0
- package/lib/tools.js +160 -21
- package/package.json +15 -6
- package/stryker.config.json +16 -0
|
@@ -8,28 +8,34 @@ const {
|
|
|
8
8
|
getAlignment,
|
|
9
9
|
getCurTime,
|
|
10
10
|
toALabel,
|
|
11
|
+
isHostNameDomain,
|
|
11
12
|
normalizeDomainName,
|
|
12
13
|
isSameOrSubdomain,
|
|
13
14
|
addWarning,
|
|
14
15
|
KEY_ERROR_COMMENTS,
|
|
16
|
+
isValidDnsName,
|
|
15
17
|
splitAlgorithm,
|
|
16
18
|
isSupportedAlgorithm,
|
|
17
19
|
createKeyCache
|
|
18
20
|
} = require('../../lib/tools');
|
|
19
|
-
const { DOMAIN_NAME, SELECTOR, TIMESTAMP, BODY_LENGTH } = require('./syntax');
|
|
21
|
+
const { DOMAIN_NAME, SELECTOR, LOCAL_PART, TIMESTAMP, BODY_LENGTH } = require('./syntax');
|
|
20
22
|
const { MessageParser } = require('./message-parser');
|
|
21
23
|
const { dkimBody } = require('./body');
|
|
22
24
|
const { generateCanonicalizedHeader } = require('./header');
|
|
23
|
-
const { getARChain } = require('../arc');
|
|
25
|
+
const { getARChain, getHighestArcInstance } = require('../arc');
|
|
24
26
|
const addressparser = require('nodemailer/lib/addressparser');
|
|
27
|
+
const { parseFromHeader, getAuthorDomain } = require('../dmarc/author-domain');
|
|
25
28
|
const crypto = require('node:crypto');
|
|
26
29
|
const libmime = require('libmime');
|
|
27
30
|
|
|
28
31
|
// RFC 6376 section 3.5 syntax, used by the strict mode
|
|
29
32
|
// sig-a-tag-alg = sig-a-tag-k "-" sig-a-tag-h, both ALPHA *(ALPHA / DIGIT)
|
|
30
33
|
const ALGORITHM = /^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$/;
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
|
|
35
|
+
// sig-c-tag-alg is "simple", "relaxed" or a hyphenated-word, sig-c-tag one or two of them
|
|
36
|
+
// separated by "/" (RFC 6376 section 3.5). An empty c= is not an omitted one (section 3.2)
|
|
37
|
+
const C_TAG_ALG = '[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
|
|
38
|
+
const CANONICALIZATION = new RegExp(`^${C_TAG_ALG}(?:/${C_TAG_ALG})?$`);
|
|
33
39
|
|
|
34
40
|
// Required tags of a DKIM-Signature (RFC 6376 section 3.5)
|
|
35
41
|
const REQUIRED_DKIM_TAGS = ['v', 'a', 'b', 'bh', 'd', 'h', 's'];
|
|
@@ -73,6 +79,10 @@ class DkimVerifier extends MessageParser {
|
|
|
73
79
|
|
|
74
80
|
this.headerFrom = [];
|
|
75
81
|
this.fromFields = 0;
|
|
82
|
+
// "valid", "lax" or "invalid", see parseFromHeader
|
|
83
|
+
this.fromSyntax = 'valid';
|
|
84
|
+
// the Author Domain result of getAuthorDomain, set when there is a From address
|
|
85
|
+
this.author = false;
|
|
76
86
|
this.envelopeFrom = false;
|
|
77
87
|
|
|
78
88
|
// ARC verification info, `warnings` lists what the lenient mode accepted in the chain
|
|
@@ -83,8 +93,9 @@ class DkimVerifier extends MessageParser {
|
|
|
83
93
|
this.keyCache = createKeyCache(this.resolver);
|
|
84
94
|
Object.defineProperty(this.arc, 'keyCache', { value: this.keyCache, enumerable: false, configurable: true, writable: true });
|
|
85
95
|
|
|
86
|
-
// should we also seal this message using ARC
|
|
87
|
-
|
|
96
|
+
// should we also seal this message using ARC. A copy, so the body hash stored on it
|
|
97
|
+
// below never ends up on the caller's object, which may be shared by concurrent calls
|
|
98
|
+
this.seal = this.options.seal ? Object.assign({}, this.options.seal) : false;
|
|
88
99
|
|
|
89
100
|
if (this.seal) {
|
|
90
101
|
// calculate body hash for the seal
|
|
@@ -106,6 +117,9 @@ class DkimVerifier extends MessageParser {
|
|
|
106
117
|
} catch (err) {
|
|
107
118
|
this.arc.error = err;
|
|
108
119
|
}
|
|
120
|
+
// the cv= of the newest ARC-Seal in the header fields, also when the chain could not be
|
|
121
|
+
// parsed. arc() uses it to decide whether a failed chain is sealed with cv=fail
|
|
122
|
+
this.arc.latestCv = getHighestArcInstance(headers).latestCv;
|
|
109
123
|
|
|
110
124
|
this.signatureHeaders = headers.parsed
|
|
111
125
|
.filter(h => h.key === 'dkim-signature')
|
|
@@ -125,19 +139,22 @@ class DkimVerifier extends MessageParser {
|
|
|
125
139
|
if (splitterPos >= 0) {
|
|
126
140
|
fromHeader = fromHeader.substr(splitterPos + 1);
|
|
127
141
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
this.headerFrom.push(mailbox.address);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
142
|
+
// the full addr-specs, including the mailboxes of a group (RFC 6854). A general
|
|
143
|
+
// purpose address parser guesses at malformed input, which could hand DMARC a domain
|
|
144
|
+
// that is not the one a reader sees, so the RFC 5322 grammar is followed instead
|
|
145
|
+
let { addresses, syntax } = parseFromHeader(fromHeader);
|
|
146
|
+
this.headerFrom.push(...addresses);
|
|
147
|
+
if (syntax === 'invalid' || this.fromSyntax === 'valid') {
|
|
148
|
+
this.fromSyntax = syntax;
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
if (this.headerFrom.length) {
|
|
153
|
+
// the same Author Domain rules as the DMARC check: a From header field that does
|
|
154
|
+
// not yield a single Author Domain aligns with no signature
|
|
155
|
+
this.author = getAuthorDomain(this.headerFrom, this.fromFields, { fromSyntax: this.fromSyntax, strict: this.strict });
|
|
156
|
+
}
|
|
157
|
+
|
|
141
158
|
if (this.options.sender) {
|
|
142
159
|
let returnPath = addressparser(this.options.sender);
|
|
143
160
|
this.envelopeFrom = returnPath.length && returnPath[0].address ? returnPath[0].address : false;
|
|
@@ -258,9 +275,12 @@ class DkimVerifier extends MessageParser {
|
|
|
258
275
|
signatureHeader.algorithm = (parsed.a?.value || '').toString();
|
|
259
276
|
Object.assign(signatureHeader, splitAlgorithm(signatureHeader.algorithm));
|
|
260
277
|
|
|
261
|
-
|
|
262
|
-
signatureHeader.headerCanon =
|
|
263
|
-
signatureHeader.bodyCanon = getBodyCanon(
|
|
278
|
+
let canonicalization = (parsed.c?.value || '').toString();
|
|
279
|
+
signatureHeader.headerCanon = canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
|
|
280
|
+
signatureHeader.bodyCanon = getBodyCanon(canonicalization);
|
|
281
|
+
// the canonicalization as read here, for the header canonicalizer. The c= text itself
|
|
282
|
+
// can be malformed, "/relaxed" has no header part
|
|
283
|
+
signatureHeader.canonicalization = `${signatureHeader.headerCanon}/${signatureHeader.bodyCanon}`;
|
|
264
284
|
|
|
265
285
|
signatureHeader.signingDomain = (parsed.d?.value || '').toString();
|
|
266
286
|
signatureHeader.selector = (parsed.s?.value || '').toString();
|
|
@@ -331,12 +351,31 @@ class DkimVerifier extends MessageParser {
|
|
|
331
351
|
return;
|
|
332
352
|
}
|
|
333
353
|
|
|
354
|
+
// the lenient mode reads a missing part as "simple" and ignores a third one
|
|
355
|
+
let canonError = parsed.c && !CANONICALIZATION.test((rawTags.get('c') || '').replace(/[ \t\r\n]+/g, ''));
|
|
356
|
+
if (canonError && violation('signature syntax error', 'tag-syntax')) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
334
360
|
// "This list MUST NOT be empty", the From check below covers the lenient mode
|
|
335
361
|
if (parsed.h && !normalizeFieldList(parsed.h.value).length && violation('signature syntax error')) {
|
|
336
362
|
return;
|
|
337
363
|
}
|
|
338
364
|
|
|
339
|
-
|
|
365
|
+
// toALabel converts each label on its own, so these also make up the key record name
|
|
366
|
+
let signingDomainALabel = toALabel(signatureHeader.signingDomain);
|
|
367
|
+
let selectorALabel = toALabel(signatureHeader.selector);
|
|
368
|
+
if (!isHostNameDomain(signingDomainALabel)) {
|
|
369
|
+
signatureHeader.invalid = 'signature syntax error';
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// DOMAIN_NAME limits the length of each label, the key record name has to fit in
|
|
374
|
+
// the 253 octets of a DNS name as well
|
|
375
|
+
let invalidNames =
|
|
376
|
+
!DOMAIN_NAME.test(signingDomainALabel) ||
|
|
377
|
+
!SELECTOR.test(selectorALabel) ||
|
|
378
|
+
!isValidDnsName(`${selectorALabel}._domainkey.${signingDomainALabel}`);
|
|
340
379
|
if (invalidNames && violation('signature syntax error', 'tag-syntax')) {
|
|
341
380
|
return;
|
|
342
381
|
}
|
|
@@ -467,6 +506,7 @@ class DkimVerifier extends MessageParser {
|
|
|
467
506
|
* @returns {Object} Signature result
|
|
468
507
|
*/
|
|
469
508
|
async verifySignature(signatureHeader, fallback) {
|
|
509
|
+
// the canonicalization as prepareSignature() read it
|
|
470
510
|
const canonicalization = fallback ? signatureHeader.fallbackCanonicalization : signatureHeader.canonicalization;
|
|
471
511
|
const bodyHashKey = fallback ? signatureHeader.fallbackBodyHashKey : signatureHeader.bodyHashKey;
|
|
472
512
|
|
|
@@ -493,8 +533,8 @@ class DkimVerifier extends MessageParser {
|
|
|
493
533
|
header: this.getStatusHeader(signatureHeader)
|
|
494
534
|
};
|
|
495
535
|
|
|
496
|
-
if (signatureHeader.type === 'DKIM' && this.
|
|
497
|
-
status.aligned =
|
|
536
|
+
if (signatureHeader.type === 'DKIM' && this.author) {
|
|
537
|
+
status.aligned = (this.author.authorDomain && getAlignment(this.author.authorDomain, [signatureHeader.signingDomain])?.domain) || false;
|
|
498
538
|
}
|
|
499
539
|
|
|
500
540
|
const bodyHasher = this.bodyHashes.get(bodyHashKey);
|
|
@@ -503,11 +543,22 @@ class DkimVerifier extends MessageParser {
|
|
|
503
543
|
|
|
504
544
|
const warnings = [].concat(signatureHeader.warnings || []);
|
|
505
545
|
|
|
506
|
-
|
|
546
|
+
// RFC 6376 section 3.5 l=: "This value MUST NOT be larger than the actual number of
|
|
547
|
+
// octets in the canonicalized message body"
|
|
548
|
+
let invalidBodyLength =
|
|
549
|
+
signatureHeader.type === 'DKIM' &&
|
|
550
|
+
typeof signatureHeader.maxBodyLength === 'number' &&
|
|
551
|
+
typeof bodyHasher?.canonicalizedLength === 'number' &&
|
|
552
|
+
signatureHeader.maxBodyLength > bodyHasher.canonicalizedLength;
|
|
553
|
+
if (invalidBodyLength && !this.strict) {
|
|
554
|
+
addWarning(warnings, 'tag-syntax');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (signatureHeader.invalid || (invalidBodyLength && this.strict)) {
|
|
507
558
|
// RFC 6376 section 6.1.1 PERMFAIL, reported as neutral, which RFC 8601 section
|
|
508
559
|
// 2.7.1 uses for a signature with syntax errors. The key is not looked up
|
|
509
560
|
status.result = 'neutral';
|
|
510
|
-
status.comment = signatureHeader.invalid;
|
|
561
|
+
status.comment = signatureHeader.invalid || 'signature syntax error';
|
|
511
562
|
} else if (signatureHeader.parsed?.bh?.value !== bodyHash) {
|
|
512
563
|
// RFC 6376 section 6.1.3 step 3 is a PERMFAIL, which RFC 8601 reports as "fail".
|
|
513
564
|
// The lenient mode keeps reporting neutral, as it always has
|
|
@@ -542,9 +593,10 @@ class DkimVerifier extends MessageParser {
|
|
|
542
593
|
}
|
|
543
594
|
|
|
544
595
|
let keyError = false;
|
|
545
|
-
if (
|
|
546
|
-
// RFC 6376 section 6.1.2 step 8
|
|
547
|
-
//
|
|
596
|
+
if (res?.keyType && res.keyType !== signatureHeader.signAlgo) {
|
|
597
|
+
// RFC 6376 section 6.1.2 step 8, in every mode. The crypto library does not
|
|
598
|
+
// catch every mismatch: an RSA key verifies a PKCS#1 v1.5 signature over the
|
|
599
|
+
// digest that an ed25519-sha256 signature is made over
|
|
548
600
|
keyError = 'inappropriate key algorithm';
|
|
549
601
|
} else if (
|
|
550
602
|
res?.flags?.includes('s') &&
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const { stripSignatureValue } = require('../../../lib/tools');
|
|
4
|
+
const { stripSignatureValue, toLineBytes } = require('../../../lib/tools');
|
|
5
5
|
const { buildSignatureOpts } = require('./signature-opts');
|
|
6
6
|
|
|
7
|
-
const formatSimpleLine = (line, suffix) => Buffer.
|
|
7
|
+
const formatSimpleLine = (line, suffix) => Buffer.concat([toLineBytes(line), Buffer.from(suffix ? suffix : '', 'binary')]);
|
|
8
8
|
|
|
9
9
|
// generate headers for signing
|
|
10
10
|
const simpleHeaders = (type, signingHeaderLines, options) => {
|
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 =
|
|
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 };
|
package/lib/dkim/syntax.js
CHANGED
|
@@ -13,6 +13,15 @@ const SELECTOR = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
|
|
|
13
13
|
// sig-t-tag and sig-x-tag are 1*12DIGIT, sig-l-tag is 1*76DIGIT
|
|
14
14
|
const TIMESTAMP = /^[0-9]{1,12}$/;
|
|
15
15
|
const BODY_LENGTH = /^[0-9]{1,76}$/;
|
|
16
|
+
// the Local-part of an i= value (RFC 6376 section 3.5): RFC 5322 dot-atom-text with the UTF-8
|
|
17
|
+
// of RFC 6531, or a quoted-string
|
|
18
|
+
const LOCAL_PART = /^(?:(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+(?:\.(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+)*|"(?:[^"\\\r\n]|\\.)*")$/;
|
|
19
|
+
|
|
20
|
+
// The characters of a host name, after U-labels are converted to A-labels. A d= with anything
|
|
21
|
+
// else (such as "/", "?", "#", "@" or ":") can not name the zone of its key record, so a
|
|
22
|
+
// DKIM-Signature, ARC-Seal or ARC-Message-Signature with it is a syntax error in every mode
|
|
23
|
+
// (RFC 6376 section 3.5)
|
|
24
|
+
const HOST_NAME_CHARS = /^[A-Za-z0-9_.-]+$/;
|
|
16
25
|
|
|
17
26
|
// RFC 8617 section 4.2.1: instance values range from 1 to 50
|
|
18
27
|
const MAX_ARC_INSTANCE = 50;
|
|
@@ -24,6 +33,8 @@ module.exports = {
|
|
|
24
33
|
DOMAIN_LABEL,
|
|
25
34
|
DOMAIN_NAME,
|
|
26
35
|
SELECTOR,
|
|
36
|
+
LOCAL_PART,
|
|
37
|
+
HOST_NAME_CHARS,
|
|
27
38
|
TIMESTAMP,
|
|
28
39
|
BODY_LENGTH,
|
|
29
40
|
MAX_ARC_INSTANCE,
|
package/lib/dkim/verify.js
CHANGED
|
@@ -11,6 +11,7 @@ const dkimVerify = async (input, options) => {
|
|
|
11
11
|
//headers: dkimVerifier.headers,
|
|
12
12
|
headerFrom: dkimVerifier.headerFrom,
|
|
13
13
|
fromFields: dkimVerifier.fromFields,
|
|
14
|
+
fromSyntax: dkimVerifier.fromSyntax,
|
|
14
15
|
envelopeFrom: dkimVerifier.envelopeFrom,
|
|
15
16
|
results: dkimVerifier.results
|
|
16
17
|
};
|
|
@@ -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 };
|