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
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
getSigningHeaderLines,
|
|
6
|
+
parseDkimHeaders,
|
|
7
|
+
formatAuthHeaderRow,
|
|
8
|
+
getAlignment,
|
|
9
|
+
getCurTime,
|
|
10
|
+
toALabel,
|
|
11
|
+
normalizeDomainName,
|
|
12
|
+
isSameOrSubdomain,
|
|
13
|
+
addWarning,
|
|
14
|
+
KEY_ERROR_COMMENTS,
|
|
15
|
+
splitAlgorithm,
|
|
16
|
+
isSupportedAlgorithm,
|
|
17
|
+
createKeyCache
|
|
18
|
+
} = require('../../lib/tools');
|
|
19
|
+
const { DOMAIN_NAME, SELECTOR, TIMESTAMP, BODY_LENGTH } = require('./syntax');
|
|
5
20
|
const { MessageParser } = require('./message-parser');
|
|
6
21
|
const { dkimBody } = require('./body');
|
|
7
22
|
const { generateCanonicalizedHeader } = require('./header');
|
|
@@ -10,6 +25,31 @@ const addressparser = require('nodemailer/lib/addressparser');
|
|
|
10
25
|
const crypto = require('node:crypto');
|
|
11
26
|
const libmime = require('libmime');
|
|
12
27
|
|
|
28
|
+
// RFC 6376 section 3.5 syntax, used by the strict mode
|
|
29
|
+
// sig-a-tag-alg = sig-a-tag-k "-" sig-a-tag-h, both ALPHA *(ALPHA / DIGIT)
|
|
30
|
+
const ALGORITHM = /^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$/;
|
|
31
|
+
// RFC 5322 dot-atom-text with the UTF-8 of RFC 6531, or a quoted-string, for the i= local-part
|
|
32
|
+
const LOCAL_PART = /^(?:(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+(?:\.(?:[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]|[^\x00-\x7f])+)*|"(?:[^"\\\r\n]|\\.)*")$/;
|
|
33
|
+
|
|
34
|
+
// Required tags of a DKIM-Signature (RFC 6376 section 3.5)
|
|
35
|
+
const REQUIRED_DKIM_TAGS = ['v', 'a', 'b', 'bh', 'd', 'h', 's'];
|
|
36
|
+
|
|
37
|
+
// Decodes dkim-quoted-printable (RFC 6376 section 2.11), used by the i= tag. FWS is not part
|
|
38
|
+
// of the value
|
|
39
|
+
const decodeDkimQuotedPrintable = value => {
|
|
40
|
+
let bytes = Buffer.from((value || '').toString().replace(/[ \t\r\n]+/g, ''), 'utf8');
|
|
41
|
+
let decoded = [];
|
|
42
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
43
|
+
if (bytes[i] === 0x3d && i + 2 < bytes.length && /^[0-9A-Fa-f]{2}$/.test(bytes.subarray(i + 1, i + 3).toString())) {
|
|
44
|
+
decoded.push(parseInt(bytes.subarray(i + 1, i + 3).toString(), 16));
|
|
45
|
+
i += 2;
|
|
46
|
+
} else {
|
|
47
|
+
decoded.push(bytes[i]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return Buffer.from(decoded).toString('utf8');
|
|
51
|
+
};
|
|
52
|
+
|
|
13
53
|
class DkimVerifier extends MessageParser {
|
|
14
54
|
constructor(options) {
|
|
15
55
|
super();
|
|
@@ -18,6 +58,12 @@ class DkimVerifier extends MessageParser {
|
|
|
18
58
|
this.resolver = this.options.resolver;
|
|
19
59
|
this.minBitLength = this.options.minBitLength;
|
|
20
60
|
|
|
61
|
+
// follow RFC 6376, RFC 8301 and RFC 8463 exactly instead of the lenient defaults
|
|
62
|
+
this.strict = !!this.options.strict;
|
|
63
|
+
|
|
64
|
+
// RFC 8301 section 3.1 on its own, without the rest of the strict mode
|
|
65
|
+
this.rejectRsaSha1 = this.strict || !!this.options.rejectRsaSha1;
|
|
66
|
+
|
|
21
67
|
this.curTime = getCurTime(this.options.curTime);
|
|
22
68
|
|
|
23
69
|
this.results = [];
|
|
@@ -26,10 +72,16 @@ class DkimVerifier extends MessageParser {
|
|
|
26
72
|
this.bodyHashes = new Map();
|
|
27
73
|
|
|
28
74
|
this.headerFrom = [];
|
|
75
|
+
this.fromFields = 0;
|
|
29
76
|
this.envelopeFrom = false;
|
|
30
77
|
|
|
31
|
-
// ARC verification info
|
|
32
|
-
this.arc = { chain: false };
|
|
78
|
+
// ARC verification info, `warnings` lists what the lenient mode accepted in the chain
|
|
79
|
+
this.arc = { chain: false, warnings: [] };
|
|
80
|
+
|
|
81
|
+
// one key lookup per key record for the message. The ARC validation reuses it through
|
|
82
|
+
// the (hidden) keyCache property of the ARC data
|
|
83
|
+
this.keyCache = createKeyCache(this.resolver);
|
|
84
|
+
Object.defineProperty(this.arc, 'keyCache', { value: this.keyCache, enumerable: false, configurable: true, writable: true });
|
|
33
85
|
|
|
34
86
|
// should we also seal this message using ARC
|
|
35
87
|
this.seal = this.options.seal;
|
|
@@ -47,7 +99,7 @@ class DkimVerifier extends MessageParser {
|
|
|
47
99
|
this.headers = headers;
|
|
48
100
|
|
|
49
101
|
try {
|
|
50
|
-
this.arc.chain = getARChain(headers);
|
|
102
|
+
this.arc.chain = getARChain(headers, { strict: this.strict, warnings: this.arc.warnings });
|
|
51
103
|
if (this.arc.chain?.length) {
|
|
52
104
|
this.arc.lastEntry = this.arc.chain[this.arc.chain.length - 1];
|
|
53
105
|
}
|
|
@@ -58,12 +110,15 @@ class DkimVerifier extends MessageParser {
|
|
|
58
110
|
this.signatureHeaders = headers.parsed
|
|
59
111
|
.filter(h => h.key === 'dkim-signature')
|
|
60
112
|
.map(h => {
|
|
61
|
-
|
|
113
|
+
// RFC 6376 section 3.2: tag names are case-sensitive, so in the strict mode "D="
|
|
114
|
+
// is an unknown tag and not the d= tag
|
|
115
|
+
const value = parseDkimHeaders(h.line, { strict: this.strict });
|
|
62
116
|
value.type = 'DKIM';
|
|
63
117
|
return value;
|
|
64
118
|
});
|
|
65
119
|
|
|
66
120
|
let fromHeaders = headers?.parsed?.filter(h => h.key === 'from');
|
|
121
|
+
this.fromFields = fromHeaders.length;
|
|
67
122
|
for (let fromHeader of fromHeaders) {
|
|
68
123
|
fromHeader = fromHeader.line.toString();
|
|
69
124
|
let splitterPos = fromHeader.indexOf(':');
|
|
@@ -72,8 +127,13 @@ class DkimVerifier extends MessageParser {
|
|
|
72
127
|
}
|
|
73
128
|
let from = addressparser(fromHeader.trim());
|
|
74
129
|
for (let addr of from) {
|
|
75
|
-
|
|
76
|
-
|
|
130
|
+
// RFC 6854 allows a group in From, and the mailboxes of the group are authors
|
|
131
|
+
// like any other. Leaving them out would leave DMARC without an Author Domain
|
|
132
|
+
for (let mailbox of [].concat(addr?.group || addr || [])) {
|
|
133
|
+
if (mailbox && mailbox.address) {
|
|
134
|
+
// the full addr-spec, the domain is what follows its last "@"
|
|
135
|
+
this.headerFrom.push(mailbox.address);
|
|
136
|
+
}
|
|
77
137
|
}
|
|
78
138
|
}
|
|
79
139
|
}
|
|
@@ -94,62 +154,38 @@ class DkimVerifier extends MessageParser {
|
|
|
94
154
|
}
|
|
95
155
|
}
|
|
96
156
|
|
|
97
|
-
// include newest ARC-Message-Signature as one of the signature headers to check for
|
|
157
|
+
// include newest ARC-Message-Signature as one of the signature headers to check for.
|
|
158
|
+
// The ARC-Seal is validated by the ARC module, it is not a DKIM signature, so it is
|
|
159
|
+
// not evaluated here (a c= tag on it used to make it one, and it ended up in the
|
|
160
|
+
// DKIM results)
|
|
98
161
|
if (this.arc.lastEntry) {
|
|
99
162
|
const signatureHeader = this.arc.lastEntry['arc-message-signature'];
|
|
100
163
|
signatureHeader.type = 'ARC';
|
|
101
164
|
this.signatureHeaders.push(signatureHeader);
|
|
102
165
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
166
|
+
if (!this.strict && !signatureHeader.parsed?.c) {
|
|
167
|
+
// Without c= the ARC-Message-Signature uses simple/simple, the DKIM-Signature
|
|
168
|
+
// default (RFC 8617 section 4.1.2, RFC 6376 section 3.5). Older versions of this
|
|
169
|
+
// library, and the ARC drafts, used relaxed/relaxed instead, so the lenient mode
|
|
170
|
+
// also tries that when simple/simple does not verify, and marks the result
|
|
171
|
+
signatureHeader.fallbackCanonicalization = 'relaxed/relaxed';
|
|
172
|
+
}
|
|
106
173
|
}
|
|
107
174
|
|
|
108
175
|
for (let signatureHeader of this.signatureHeaders) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
signatureHeader.
|
|
112
|
-
|
|
113
|
-
signatureHeader.canonicalization = signatureHeader.parsed?.c?.value || '';
|
|
114
|
-
signatureHeader.headerCanon = signatureHeader.canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
|
|
115
|
-
// if body canonicalization is not set, then defaults to 'simple'
|
|
116
|
-
signatureHeader.bodyCanon = (signatureHeader.canonicalization.split('/')[1] || 'simple').toLowerCase().trim();
|
|
117
|
-
|
|
118
|
-
signatureHeader.signingDomain = signatureHeader.parsed?.d?.value || '';
|
|
119
|
-
signatureHeader.selector = signatureHeader.parsed?.s?.value || '';
|
|
120
|
-
|
|
121
|
-
signatureHeader.timestamp =
|
|
122
|
-
signatureHeader.parsed?.t && !isNaN(signatureHeader.parsed?.t?.value) ? new Date(signatureHeader.parsed?.t?.value * 1000) : null;
|
|
123
|
-
|
|
124
|
-
signatureHeader.expiration =
|
|
125
|
-
signatureHeader.parsed?.x && !isNaN(signatureHeader.parsed?.x?.value) ? new Date(signatureHeader.parsed?.x?.value * 1000) : null;
|
|
126
|
-
|
|
127
|
-
// `l=0` is a valid sig-l-tag and means that no body byte is covered by the
|
|
128
|
-
// signature, so the value has to be tested for being a number, not for being
|
|
129
|
-
// truthy, or a zero length limit would be read as no limit at all
|
|
130
|
-
signatureHeader.maxBodyLength =
|
|
131
|
-
typeof signatureHeader.parsed?.l?.value === 'number' && !isNaN(signatureHeader.parsed?.l?.value) ? signatureHeader.parsed?.l?.value : '';
|
|
132
|
-
|
|
133
|
-
const validSignAlgo = ['rsa', 'ed25519'];
|
|
134
|
-
const validHeaderAlgo = signatureHeader.type === 'DKIM' ? ['sha256', 'sha1'] : ['sha256'];
|
|
135
|
-
const validHeaderCanon = signatureHeader.type !== 'AS' ? ['relaxed', 'simple'] : ['relaxed'];
|
|
136
|
-
const validBodyCanon = signatureHeader.type !== 'AS' ? ['relaxed', 'simple'] : ['relaxed'];
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
!validSignAlgo.includes(signatureHeader.signAlgo) ||
|
|
140
|
-
!validHeaderAlgo.includes(signatureHeader.hashAlgo) ||
|
|
141
|
-
!validHeaderCanon.includes(signatureHeader.headerCanon) ||
|
|
142
|
-
!validBodyCanon.includes(signatureHeader.bodyCanon) ||
|
|
143
|
-
!signatureHeader.signingDomain ||
|
|
144
|
-
!signatureHeader.selector
|
|
145
|
-
) {
|
|
146
|
-
signatureHeader.skip = true;
|
|
176
|
+
this.prepareSignature(signatureHeader);
|
|
177
|
+
|
|
178
|
+
if (signatureHeader.skip || signatureHeader.invalid) {
|
|
147
179
|
continue;
|
|
148
180
|
}
|
|
149
181
|
|
|
150
|
-
signatureHeader.bodyHashKey =
|
|
151
|
-
if (
|
|
152
|
-
|
|
182
|
+
signatureHeader.bodyHashKey = this.addBodyHash(signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength);
|
|
183
|
+
if (signatureHeader.fallbackCanonicalization) {
|
|
184
|
+
signatureHeader.fallbackBodyHashKey = this.addBodyHash(
|
|
185
|
+
getBodyCanon(signatureHeader.fallbackCanonicalization),
|
|
186
|
+
signatureHeader.hashAlgo,
|
|
187
|
+
signatureHeader.maxBodyLength
|
|
188
|
+
);
|
|
153
189
|
}
|
|
154
190
|
|
|
155
191
|
let headersArray = this.headers.parsed;
|
|
@@ -171,6 +207,197 @@ class DkimVerifier extends MessageParser {
|
|
|
171
207
|
}
|
|
172
208
|
}
|
|
173
209
|
|
|
210
|
+
// Registers a body hash, once for every canonicalization, hash and length combination
|
|
211
|
+
addBodyHash(bodyCanon, hashAlgo, maxBodyLength) {
|
|
212
|
+
let bodyHashKey = [bodyCanon, hashAlgo, maxBodyLength].join(':');
|
|
213
|
+
if (!this.bodyHashes.has(bodyHashKey)) {
|
|
214
|
+
this.bodyHashes.set(bodyHashKey, dkimBody(bodyCanon, hashAlgo, maxBodyLength));
|
|
215
|
+
}
|
|
216
|
+
return bodyHashKey;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reads the tags of a signature header and validates them (RFC 6376 section 6.1.1).
|
|
221
|
+
*
|
|
222
|
+
* Sets `skip` for a signature that is not processed at all: the lenient mode leaves those
|
|
223
|
+
* out of the results as it always has, the strict mode reports them as neutral. Sets
|
|
224
|
+
* `invalid` to a reason for a signature that is reported as neutral without looking up
|
|
225
|
+
* its key. Collects `warnings` for what the lenient mode accepts and the strict mode would
|
|
226
|
+
* not.
|
|
227
|
+
*/
|
|
228
|
+
prepareSignature(signatureHeader) {
|
|
229
|
+
const strict = this.strict;
|
|
230
|
+
const isDkim = signatureHeader.type === 'DKIM';
|
|
231
|
+
|
|
232
|
+
signatureHeader.warnings = [];
|
|
233
|
+
const warn = warning => addWarning(signatureHeader.warnings, warning);
|
|
234
|
+
// A rule the strict mode enforces: returns true when the signature is invalid for it,
|
|
235
|
+
// otherwise notes the warning (if any) and returns false
|
|
236
|
+
const violation = (reason, warning) => {
|
|
237
|
+
if (strict) {
|
|
238
|
+
signatureHeader.invalid = reason;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (warning) {
|
|
242
|
+
warn(warning);
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// the raw tag values, as they were written, for the syntax checks
|
|
248
|
+
const rawTags = new Map();
|
|
249
|
+
for (let tag of signatureHeader.tags || []) {
|
|
250
|
+
rawTags.set(tag.name, tag.value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let tagListErrors = signatureHeader.syntaxErrors || [];
|
|
254
|
+
let hasCaseFoldedTags = (signatureHeader.tags || []).some(tag => tag.name !== tag.name.toLowerCase());
|
|
255
|
+
|
|
256
|
+
const parsed = signatureHeader.parsed || {};
|
|
257
|
+
|
|
258
|
+
signatureHeader.algorithm = (parsed.a?.value || '').toString();
|
|
259
|
+
Object.assign(signatureHeader, splitAlgorithm(signatureHeader.algorithm));
|
|
260
|
+
|
|
261
|
+
signatureHeader.canonicalization = (parsed.c?.value || '').toString();
|
|
262
|
+
signatureHeader.headerCanon = signatureHeader.canonicalization.split('/').shift().toLowerCase().trim() || 'simple';
|
|
263
|
+
signatureHeader.bodyCanon = getBodyCanon(signatureHeader.canonicalization);
|
|
264
|
+
|
|
265
|
+
signatureHeader.signingDomain = (parsed.d?.value || '').toString();
|
|
266
|
+
signatureHeader.selector = (parsed.s?.value || '').toString();
|
|
267
|
+
|
|
268
|
+
signatureHeader.timestamp = parsed.t && !isNaN(parsed.t.value) ? new Date(parsed.t.value * 1000) : null;
|
|
269
|
+
|
|
270
|
+
signatureHeader.expiration = parsed.x && !isNaN(parsed.x.value) ? new Date(parsed.x.value * 1000) : null;
|
|
271
|
+
|
|
272
|
+
// `l=0` is a valid sig-l-tag and means that no body byte is covered by the
|
|
273
|
+
// signature, so the value has to be tested for being a number, not for being
|
|
274
|
+
// truthy, or a zero length limit would be read as no limit at all
|
|
275
|
+
signatureHeader.maxBodyLength = typeof parsed.l?.value === 'number' && !isNaN(parsed.l.value) ? parsed.l.value : '';
|
|
276
|
+
|
|
277
|
+
// AUID, "@d" when i= is not set (RFC 6376 section 3.5)
|
|
278
|
+
if (isDkim && parsed.i && typeof parsed.i.value === 'string') {
|
|
279
|
+
signatureHeader.identity = decodeDkimQuotedPrintable(parsed.i.value);
|
|
280
|
+
} else if (isDkim) {
|
|
281
|
+
signatureHeader.identity = signatureHeader.signingDomain ? `@${signatureHeader.signingDomain}` : '';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const validCanon = ['relaxed', 'simple'];
|
|
285
|
+
|
|
286
|
+
// rsa-sha1 is only a DKIM algorithm, ARC signatures always use sha256
|
|
287
|
+
if (!isSupportedAlgorithm(signatureHeader.signAlgo, signatureHeader.hashAlgo, isDkim) || (strict && !ALGORITHM.test(signatureHeader.algorithm))) {
|
|
288
|
+
signatureHeader.skip = 'unknown algorithm';
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!validCanon.includes(signatureHeader.headerCanon) || !validCanon.includes(signatureHeader.bodyCanon)) {
|
|
293
|
+
signatureHeader.skip = 'unknown canonicalization';
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!signatureHeader.signingDomain || !signatureHeader.selector) {
|
|
298
|
+
signatureHeader.skip = 'signature missing required tag';
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (isDkim) {
|
|
303
|
+
// RFC 6376 section 3.2 and 6.1.1
|
|
304
|
+
if (tagListErrors.length && violation('signature syntax error', 'tag-syntax')) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (hasCaseFoldedTags) {
|
|
308
|
+
warn('tag-syntax');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let missingTags = REQUIRED_DKIM_TAGS.filter(tag => !parsed[tag]);
|
|
312
|
+
if (missingTags.length) {
|
|
313
|
+
if (violation('signature missing required tag')) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// without h= the default header list is used, which does include From
|
|
317
|
+
missingTags.forEach(tag => warn(`missing-${tag}`));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// "Verifiers MUST return PERMFAIL (incompatible version)"
|
|
321
|
+
if (parsed.v && rawTags.get('v') !== '1' && violation('incompatible version', 'invalid-v')) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let numericError = [
|
|
326
|
+
['t', TIMESTAMP],
|
|
327
|
+
['x', TIMESTAMP],
|
|
328
|
+
['l', BODY_LENGTH]
|
|
329
|
+
].some(([tag, format]) => parsed[tag] && !format.test(rawTags.get(tag) || ''));
|
|
330
|
+
if (numericError && violation('signature syntax error', 'tag-syntax')) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// "This list MUST NOT be empty", the From check below covers the lenient mode
|
|
335
|
+
if (parsed.h && !normalizeFieldList(parsed.h.value).length && violation('signature syntax error')) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let invalidNames = !DOMAIN_NAME.test(toALabel(signatureHeader.signingDomain)) || !SELECTOR.test(toALabel(signatureHeader.selector));
|
|
340
|
+
if (invalidNames && violation('signature syntax error', 'tag-syntax')) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (parsed.q) {
|
|
345
|
+
// "dns/txt" is the only query method, unrecognized ones are ignored
|
|
346
|
+
let methods = (parsed.q.value || '')
|
|
347
|
+
.toString()
|
|
348
|
+
.split(':')
|
|
349
|
+
.map(method => method.trim().toLowerCase());
|
|
350
|
+
if (!methods.includes('dns/txt') && violation('unsupported query method', 'query-method')) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (parsed.i) {
|
|
356
|
+
// RFC 6376 section 6.1.1: the i= domain has to be d= or one of its subdomains
|
|
357
|
+
let identity = signatureHeader.identity;
|
|
358
|
+
let atPos = identity.lastIndexOf('@');
|
|
359
|
+
let localPart = atPos >= 0 ? identity.substring(0, atPos) : '';
|
|
360
|
+
let identityDomain = atPos >= 0 ? identity.substring(atPos + 1) : '';
|
|
361
|
+
|
|
362
|
+
let validSyntax = atPos >= 0 && (!localPart || LOCAL_PART.test(localPart)) && DOMAIN_NAME.test(toALabel(identityDomain));
|
|
363
|
+
let validDomain = atPos >= 0 && isSameOrSubdomain(identityDomain, signatureHeader.signingDomain);
|
|
364
|
+
|
|
365
|
+
if ((!validSyntax || !validDomain) && violation(validSyntax ? 'domain mismatch' : 'signature syntax error', 'identity-domain')) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
signatureHeader.identityDomain = identityDomain;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (signatureHeader.timestamp && signatureHeader.expiration && signatureHeader.expiration <= signatureHeader.timestamp) {
|
|
372
|
+
// "The value of the x= tag MUST be greater than the value of the t= tag". An x=
|
|
373
|
+
// before t= fails in both modes, an x= equal to t= only in the strict mode
|
|
374
|
+
if (violation('invalid expiration')) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (signatureHeader.expiration.getTime() === signatureHeader.timestamp.getTime()) {
|
|
378
|
+
warn('invalid-expiration');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (signatureHeader.hashAlgo === 'sha1') {
|
|
383
|
+
// RFC 8301 section 3.1: rsa-sha1 MUST NOT be used for verifying. Only the
|
|
384
|
+
// strict mode and rejectRsaSha1 reject it, the lenient mode lets the key owner
|
|
385
|
+
// opt out of it with the h= tag of the key record
|
|
386
|
+
warn('rsa-sha1');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// RFC 6376 section 6.1.1: a signature that does not cover From is ignored in every
|
|
391
|
+
// mode. It says nothing about the author the DMARC check is about, and the From
|
|
392
|
+
// header could have been replaced after signing. The same applies to the
|
|
393
|
+
// ARC-Message-Signature, which has the DKIM-Signature syntax (RFC 8617 section 4.1.2)
|
|
394
|
+
let signedFields = parsed.h ? normalizeFieldList(parsed.h.value) : ['from'];
|
|
395
|
+
if (!signedFields.includes('from')) {
|
|
396
|
+
signatureHeader.invalid = 'From field not signed';
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
174
401
|
async nextChunk(chunk) {
|
|
175
402
|
for (let bodyHash of this.bodyHashes.values()) {
|
|
176
403
|
bodyHash.update(chunk);
|
|
@@ -190,220 +417,328 @@ class DkimVerifier extends MessageParser {
|
|
|
190
417
|
return true;
|
|
191
418
|
}
|
|
192
419
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
420
|
+
getStatusHeader(signatureHeader) {
|
|
421
|
+
let header = {};
|
|
422
|
+
|
|
423
|
+
if (this.strict && signatureHeader.type === 'DKIM') {
|
|
424
|
+
// RFC 8601 section 2.7.1: header.d is the d= domain and header.i the AUID, which is
|
|
425
|
+
// the i= value or "@d" when there is no i=. Both use the domain-name syntax of
|
|
426
|
+
// RFC 6376 section 3.5, where an internationalized domain name is an A-label
|
|
427
|
+
let identity = signatureHeader.identity || '';
|
|
428
|
+
let atPos = identity.lastIndexOf('@');
|
|
429
|
+
header.d = signatureHeader.signingDomain ? toALabel(signatureHeader.signingDomain) : false;
|
|
430
|
+
header.i = identity ? (atPos >= 0 ? identity.substring(0, atPos + 1) + toALabel(identity.substring(atPos + 1)) : identity) : false;
|
|
431
|
+
} else {
|
|
432
|
+
// signing domain
|
|
433
|
+
header.i = signatureHeader.signingDomain ? `@${signatureHeader.signingDomain}` : false;
|
|
434
|
+
}
|
|
198
435
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
436
|
+
// dkim selector
|
|
437
|
+
header.s = signatureHeader.selector;
|
|
438
|
+
// algo
|
|
439
|
+
header.a = signatureHeader.parsed?.a?.value;
|
|
440
|
+
// signature value
|
|
441
|
+
header.b = signatureHeader.parsed?.b?.value ? `${signatureHeader.parsed?.b?.value.toString().substr(0, 8)}` : false;
|
|
204
442
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// TODO: add failing header line?
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
443
|
+
return header;
|
|
444
|
+
}
|
|
210
445
|
|
|
211
|
-
|
|
446
|
+
// The result properties every signature has, whether it was verified or not
|
|
447
|
+
baseResult(signatureHeader, format) {
|
|
448
|
+
return {
|
|
449
|
+
id: signatureHeader.parsed?.b?.value
|
|
450
|
+
? crypto.createHash('sha256').update(Buffer.from(signatureHeader.parsed.b.value.toString(), 'base64')).digest('hex')
|
|
451
|
+
: crypto.randomUUID(),
|
|
452
|
+
signingDomain: signatureHeader.signingDomain,
|
|
453
|
+
selector: signatureHeader.selector,
|
|
454
|
+
signature: signatureHeader.parsed?.b?.value,
|
|
455
|
+
algo: signatureHeader.parsed?.a?.value,
|
|
456
|
+
format
|
|
457
|
+
};
|
|
458
|
+
}
|
|
212
459
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Verifies a signature
|
|
462
|
+
*
|
|
463
|
+
* @param {Object} signatureHeader Signature prepared by prepareSignature()
|
|
464
|
+
* @param {Boolean} [fallback] If true, verify with `fallbackCanonicalization` instead of the
|
|
465
|
+
* canonicalization of the signature. The result is then built as if the signature
|
|
466
|
+
* had that c= value, and the signature header object is not updated
|
|
467
|
+
* @returns {Object} Signature result
|
|
468
|
+
*/
|
|
469
|
+
async verifySignature(signatureHeader, fallback) {
|
|
470
|
+
const canonicalization = fallback ? signatureHeader.fallbackCanonicalization : signatureHeader.canonicalization;
|
|
471
|
+
const bodyHashKey = fallback ? signatureHeader.fallbackBodyHashKey : signatureHeader.bodyHashKey;
|
|
472
|
+
|
|
473
|
+
let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, signatureHeader.parsed?.h?.value, true);
|
|
474
|
+
|
|
475
|
+
let { canonicalizedHeader } = generateCanonicalizedHeader(signatureHeader.type, signingHeaderLines, {
|
|
476
|
+
signatureHeaderLine: signatureHeader.original,
|
|
477
|
+
canonicalization,
|
|
478
|
+
instance: signatureHeader.type === 'ARC' ? signatureHeader.parsed?.i?.value : false,
|
|
479
|
+
strict: this.strict
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
let signingHeaders = {
|
|
483
|
+
keys: signingHeaderLines.keys,
|
|
484
|
+
headers: signingHeaderLines.headers.map(l => l.line.toString()),
|
|
485
|
+
canonicalizedHeader: canonicalizedHeader.toString('base64')
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
let publicKey, rr, modulusLength;
|
|
489
|
+
let status = {
|
|
490
|
+
result: 'neutral',
|
|
491
|
+
comment: false,
|
|
492
|
+
// ptype properties
|
|
493
|
+
header: this.getStatusHeader(signatureHeader)
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) {
|
|
497
|
+
status.aligned = getAlignment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain])?.domain || false;
|
|
498
|
+
}
|
|
218
499
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
500
|
+
const bodyHasher = this.bodyHashes.get(bodyHashKey);
|
|
501
|
+
const bodyHash = bodyHasher?.hash;
|
|
502
|
+
const mimeStructureStart = bodyHasher?.mimeStructureStart;
|
|
503
|
+
|
|
504
|
+
const warnings = [].concat(signatureHeader.warnings || []);
|
|
505
|
+
|
|
506
|
+
if (signatureHeader.invalid) {
|
|
507
|
+
// RFC 6376 section 6.1.1 PERMFAIL, reported as neutral, which RFC 8601 section
|
|
508
|
+
// 2.7.1 uses for a signature with syntax errors. The key is not looked up
|
|
509
|
+
status.result = 'neutral';
|
|
510
|
+
status.comment = signatureHeader.invalid;
|
|
511
|
+
} else if (signatureHeader.parsed?.bh?.value !== bodyHash) {
|
|
512
|
+
// RFC 6376 section 6.1.3 step 3 is a PERMFAIL, which RFC 8601 reports as "fail".
|
|
513
|
+
// The lenient mode keeps reporting neutral, as it always has
|
|
514
|
+
status.result = this.strict ? 'fail' : 'neutral';
|
|
515
|
+
status.comment = `body hash did not verify`;
|
|
516
|
+
} else if (this.rejectRsaSha1 && signatureHeader.type === 'DKIM' && signatureHeader.hashAlgo === 'sha1') {
|
|
517
|
+
// RFC 8301 section 3.1, "rsa-sha1 MUST NOT be used for signing or verifying"
|
|
518
|
+
status.result = 'policy';
|
|
519
|
+
status.comment = 'weak algorithm';
|
|
520
|
+
status.policy = { 'dkim-rules': 'weak-algorithm' };
|
|
521
|
+
} else {
|
|
522
|
+
try {
|
|
523
|
+
let res = await this.keyCache.get(
|
|
524
|
+
signatureHeader.type,
|
|
525
|
+
`${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`,
|
|
526
|
+
this.minBitLength,
|
|
527
|
+
{
|
|
528
|
+
strict: this.strict,
|
|
529
|
+
hashAlgo: signatureHeader.hashAlgo
|
|
239
530
|
}
|
|
240
|
-
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
publicKey = res?.publicKey;
|
|
534
|
+
rr = res?.rr;
|
|
535
|
+
modulusLength = res?.modulusLength;
|
|
536
|
+
|
|
537
|
+
addWarning(warnings, ...(res?.warnings || []));
|
|
241
538
|
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
: false;
|
|
539
|
+
if (res?.testing) {
|
|
540
|
+
// key t=y, the domain is testing DKIM (RFC 6376 section 3.6.1)
|
|
541
|
+
status.testing = true;
|
|
246
542
|
}
|
|
247
543
|
|
|
248
|
-
|
|
249
|
-
|
|
544
|
+
let keyError = false;
|
|
545
|
+
if (this.strict && res?.keyType && res.keyType !== signatureHeader.signAlgo) {
|
|
546
|
+
// RFC 6376 section 6.1.2 step 8. The lenient mode lets the crypto library
|
|
547
|
+
// report the mismatch, as it always has
|
|
548
|
+
keyError = 'inappropriate key algorithm';
|
|
549
|
+
} else if (
|
|
550
|
+
res?.flags?.includes('s') &&
|
|
551
|
+
signatureHeader.identityDomain &&
|
|
552
|
+
normalizeDomainName(signatureHeader.identityDomain) !== normalizeDomainName(signatureHeader.signingDomain)
|
|
553
|
+
) {
|
|
554
|
+
// key t=s: "the i= domain MUST NOT be a subdomain of d="
|
|
555
|
+
if (this.strict) {
|
|
556
|
+
keyError = 'domain mismatch';
|
|
557
|
+
} else {
|
|
558
|
+
addWarning(warnings, 'identity-domain');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
250
561
|
|
|
251
|
-
if (
|
|
562
|
+
if (keyError) {
|
|
252
563
|
status.result = 'neutral';
|
|
253
|
-
status.comment =
|
|
564
|
+
status.comment = keyError;
|
|
254
565
|
} else {
|
|
255
566
|
try {
|
|
256
|
-
|
|
257
|
-
signatureHeader.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
status.result = crypto.verify(
|
|
269
|
-
signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null,
|
|
270
|
-
signatureHeader.signAlgo === 'rsa' ? canonicalizedHeader : crypto.createHash('sha256').update(canonicalizedHeader).digest(),
|
|
271
|
-
publicKey,
|
|
272
|
-
Buffer.from(signatureHeader.parsed?.b?.value, 'base64')
|
|
273
|
-
)
|
|
274
|
-
? 'pass'
|
|
275
|
-
: 'fail';
|
|
276
|
-
|
|
277
|
-
if (status.result === 'fail') {
|
|
278
|
-
status.comment = 'bad signature';
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (status.result === 'pass') {
|
|
282
|
-
if (signatureHeader.expiration && signatureHeader.timestamp && signatureHeader.expiration < signatureHeader.timestamp) {
|
|
283
|
-
status.result = 'neutral';
|
|
284
|
-
status.comment = 'invalid expiration';
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (signatureHeader.expiration && signatureHeader.expiration < this.curTime) {
|
|
288
|
-
status.result = 'neutral';
|
|
289
|
-
status.comment = 'expired';
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
} catch (err) {
|
|
293
|
-
status.result = 'neutral';
|
|
294
|
-
status.comment = err.message;
|
|
295
|
-
}
|
|
296
|
-
} catch (err) {
|
|
297
|
-
if (err.rr) {
|
|
298
|
-
rr = err.rr;
|
|
567
|
+
status.result = crypto.verify(
|
|
568
|
+
signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null,
|
|
569
|
+
signatureHeader.signAlgo === 'rsa' ? canonicalizedHeader : crypto.createHash('sha256').update(canonicalizedHeader).digest(),
|
|
570
|
+
publicKey,
|
|
571
|
+
Buffer.from(signatureHeader.parsed?.b?.value || '', 'base64')
|
|
572
|
+
)
|
|
573
|
+
? 'pass'
|
|
574
|
+
: 'fail';
|
|
575
|
+
|
|
576
|
+
if (status.result === 'fail') {
|
|
577
|
+
status.comment = 'bad signature';
|
|
299
578
|
}
|
|
300
579
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
case 'ENODATA':
|
|
304
|
-
status.result = 'neutral';
|
|
305
|
-
status.comment = `no key`;
|
|
306
|
-
break;
|
|
307
|
-
|
|
308
|
-
case 'EINVALIDVER':
|
|
580
|
+
if (status.result === 'pass') {
|
|
581
|
+
if (signatureHeader.expiration && signatureHeader.timestamp && signatureHeader.expiration < signatureHeader.timestamp) {
|
|
309
582
|
status.result = 'neutral';
|
|
310
|
-
status.comment =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
case 'EINVALIDTYPE':
|
|
314
|
-
status.result = 'neutral';
|
|
315
|
-
status.comment = `unknown key type`;
|
|
316
|
-
break;
|
|
583
|
+
status.comment = 'invalid expiration';
|
|
584
|
+
}
|
|
317
585
|
|
|
318
|
-
|
|
586
|
+
if (signatureHeader.expiration && signatureHeader.expiration < this.curTime) {
|
|
319
587
|
status.result = 'neutral';
|
|
320
|
-
status.comment =
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
case 'ESHORTKEY':
|
|
324
|
-
status.result = 'policy';
|
|
325
|
-
if (!status.policy) {
|
|
326
|
-
status.policy = {};
|
|
327
|
-
}
|
|
328
|
-
status.policy['dkim-rules'] = `weak-key`;
|
|
329
|
-
break;
|
|
330
|
-
|
|
331
|
-
default:
|
|
332
|
-
status.result = 'temperror';
|
|
333
|
-
status.comment = `DNS failure: ${err.code || err.message}`;
|
|
588
|
+
status.comment = 'expired';
|
|
589
|
+
}
|
|
334
590
|
}
|
|
591
|
+
} catch (err) {
|
|
592
|
+
status.result = 'neutral';
|
|
593
|
+
status.comment = err.message;
|
|
335
594
|
}
|
|
336
595
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
signatureHeader.sourceBodyLength = this.bodyHashes.get(signatureHeader.bodyHashKey)?.byteLength;
|
|
341
|
-
|
|
342
|
-
let result = {
|
|
343
|
-
id: signatureHeader.parsed?.b?.value
|
|
344
|
-
? crypto.createHash('sha256').update(Buffer.from(signatureHeader.parsed?.b?.value, 'base64')).digest('hex')
|
|
345
|
-
: crypto.randomUUID(),
|
|
346
|
-
signingDomain: signatureHeader.signingDomain,
|
|
347
|
-
selector: signatureHeader.selector,
|
|
348
|
-
signature: signatureHeader.parsed?.b?.value,
|
|
349
|
-
algo: signatureHeader.parsed?.a?.value,
|
|
350
|
-
format: signatureHeader.parsed?.c?.value,
|
|
351
|
-
bodyHash,
|
|
352
|
-
bodyHashExpecting: signatureHeader.parsed?.bh?.value,
|
|
353
|
-
signingHeaders,
|
|
354
|
-
status,
|
|
355
|
-
signTime: signatureHeader.timestamp ? signatureHeader.timestamp.toISOString() : null,
|
|
356
|
-
expiresAfter: signatureHeader.expiration ? signatureHeader.expiration.toISOString() : null,
|
|
357
|
-
signatureTimeValid: this.getSignatureTimeValid(signatureHeader)
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
if (typeof signatureHeader.sourceBodyLength === 'number') {
|
|
361
|
-
result.sourceBodyLength = signatureHeader.sourceBodyLength;
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (err.rr) {
|
|
598
|
+
rr = err.rr;
|
|
362
599
|
}
|
|
363
600
|
|
|
364
|
-
if (
|
|
365
|
-
result
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (typeof signatureHeader.maxBodyLength === 'number') {
|
|
373
|
-
result.canonBodyLengthLimited = true;
|
|
374
|
-
result.canonBodyLengthLimit = signatureHeader.maxBodyLength;
|
|
375
|
-
if (result.canonBodyLengthTotal > result.canonBodyLength) {
|
|
376
|
-
status.underSized = result.canonBodyLengthTotal - result.canonBodyLength;
|
|
601
|
+
if (KEY_ERROR_COMMENTS.has(err.code)) {
|
|
602
|
+
status.result = 'neutral';
|
|
603
|
+
status.comment = KEY_ERROR_COMMENTS.get(err.code);
|
|
604
|
+
} else if (err.code === 'ESHORTKEY') {
|
|
605
|
+
status.result = 'policy';
|
|
606
|
+
if (!status.policy) {
|
|
607
|
+
status.policy = {};
|
|
377
608
|
}
|
|
609
|
+
status.policy['dkim-rules'] = `weak-key`;
|
|
378
610
|
} else {
|
|
379
|
-
result
|
|
611
|
+
status.result = 'temperror';
|
|
612
|
+
status.comment = `DNS failure: ${err.code || err.message}`;
|
|
380
613
|
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
381
616
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
617
|
+
if (warnings.length) {
|
|
618
|
+
// what the lenient mode accepted that the strict mode would not, never rendered
|
|
619
|
+
// into the Authentication-Results header
|
|
620
|
+
status.warnings = warnings;
|
|
621
|
+
}
|
|
385
622
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
623
|
+
const bodyHashedBytes = bodyHasher?.bodyHashedBytes;
|
|
624
|
+
const canonicalizedLength = bodyHasher?.canonicalizedLength;
|
|
625
|
+
const sourceBodyLength = bodyHasher?.byteLength;
|
|
389
626
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
627
|
+
if (!fallback) {
|
|
628
|
+
signatureHeader.bodyHashedBytes = bodyHashedBytes;
|
|
629
|
+
signatureHeader.canonicalizedLength = canonicalizedLength;
|
|
630
|
+
signatureHeader.sourceBodyLength = sourceBodyLength;
|
|
631
|
+
}
|
|
393
632
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
633
|
+
let result = Object.assign(this.baseResult(signatureHeader, fallback ? canonicalization : signatureHeader.parsed?.c?.value), {
|
|
634
|
+
bodyHash,
|
|
635
|
+
bodyHashExpecting: signatureHeader.parsed?.bh?.value,
|
|
636
|
+
signingHeaders,
|
|
637
|
+
status,
|
|
638
|
+
signTime: signatureHeader.timestamp && !isNaN(signatureHeader.timestamp) ? signatureHeader.timestamp.toISOString() : null,
|
|
639
|
+
expiresAfter: signatureHeader.expiration && !isNaN(signatureHeader.expiration) ? signatureHeader.expiration.toISOString() : null,
|
|
640
|
+
signatureTimeValid: this.getSignatureTimeValid(signatureHeader)
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (typeof sourceBodyLength === 'number') {
|
|
644
|
+
result.sourceBodyLength = sourceBodyLength;
|
|
645
|
+
}
|
|
397
646
|
|
|
398
|
-
|
|
399
|
-
|
|
647
|
+
if (typeof bodyHashedBytes === 'number') {
|
|
648
|
+
result.canonBodyLength = bodyHashedBytes;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (typeof canonicalizedLength === 'number') {
|
|
652
|
+
result.canonBodyLengthTotal = canonicalizedLength;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (typeof signatureHeader.maxBodyLength === 'number') {
|
|
656
|
+
result.canonBodyLengthLimited = true;
|
|
657
|
+
result.canonBodyLengthLimit = signatureHeader.maxBodyLength;
|
|
658
|
+
if (result.canonBodyLengthTotal > result.canonBodyLength) {
|
|
659
|
+
status.underSized = result.canonBodyLengthTotal - result.canonBodyLength;
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
result.canonBodyLengthLimited = false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (typeof mimeStructureStart === 'number') {
|
|
666
|
+
result.mimeStructureStart = mimeStructureStart;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (publicKey) {
|
|
670
|
+
result.publicKey = publicKey.toString();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (modulusLength) {
|
|
674
|
+
result.modulusLength = modulusLength;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (rr) {
|
|
678
|
+
result.rr = rr;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (typeof result.status.comment === 'boolean') {
|
|
682
|
+
delete result.status.comment;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return result;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// A signature that could not be processed at all. The strict mode reports it as neutral
|
|
689
|
+
// (RFC 8601 section 2.7.1: "the signature or signatures contained syntax errors or were
|
|
690
|
+
// not otherwise able to be processed"), as "none" means that the message was not signed
|
|
691
|
+
skippedSignatureResult(signatureHeader) {
|
|
692
|
+
let status = {
|
|
693
|
+
result: 'neutral',
|
|
694
|
+
comment: signatureHeader.skip,
|
|
695
|
+
header: this.getStatusHeader(signatureHeader)
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
return Object.assign(this.baseResult(signatureHeader, signatureHeader.parsed?.c?.value), { status });
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async finalChunk() {
|
|
702
|
+
try {
|
|
703
|
+
if (!this.headers || (!this.bodyHashes.size && !this.signatureHeaders.some(signatureHeader => signatureHeader.invalid || signatureHeader.skip))) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// convert bodyHashes from hash objects to base64 strings
|
|
708
|
+
for (let [key, bodyHash] of this.bodyHashes.entries()) {
|
|
709
|
+
this.bodyHashes.get(key).hash = bodyHash.digest('base64');
|
|
710
|
+
this.bodyHashes.get(key).mimeStructureStart = bodyHash.getMimeStructureStart();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
for (let signatureHeader of this.signatureHeaders) {
|
|
714
|
+
let result;
|
|
715
|
+
|
|
716
|
+
if (signatureHeader.skip) {
|
|
717
|
+
if (!this.strict || signatureHeader.type !== 'DKIM') {
|
|
718
|
+
// the lenient mode does not report what it can not process
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
result = this.skippedSignatureResult(signatureHeader);
|
|
722
|
+
} else {
|
|
723
|
+
result = await this.verifySignature(signatureHeader);
|
|
724
|
+
|
|
725
|
+
if (signatureHeader.fallbackCanonicalization && !signatureHeader.invalid && result.status.result !== 'pass') {
|
|
726
|
+
// only used when it verifies, the result of the default canonicalization
|
|
727
|
+
// is kept otherwise. The key lookup is cached, so it is not repeated
|
|
728
|
+
let fallbackResult = await this.verifySignature(signatureHeader, true);
|
|
729
|
+
if (fallbackResult.status.result === 'pass') {
|
|
730
|
+
fallbackResult.status.warnings = addWarning([].concat(fallbackResult.status.warnings || []), 'ams-c-default');
|
|
731
|
+
result = fallbackResult;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
400
734
|
}
|
|
401
735
|
|
|
402
736
|
switch (signatureHeader.type) {
|
|
403
737
|
case 'ARC':
|
|
404
|
-
if (this.arc.lastEntry) {
|
|
405
|
-
|
|
738
|
+
if (!this.arc.lastEntry) {
|
|
739
|
+
break;
|
|
406
740
|
}
|
|
741
|
+
this.arc.lastEntry.messageSignature = result;
|
|
407
742
|
break;
|
|
408
743
|
case 'DKIM':
|
|
409
744
|
default:
|
|
@@ -422,7 +757,7 @@ class DkimVerifier extends MessageParser {
|
|
|
422
757
|
}
|
|
423
758
|
|
|
424
759
|
this.results.forEach(result => {
|
|
425
|
-
result.info = formatAuthHeaderRow('dkim', result.status);
|
|
760
|
+
result.info = formatAuthHeaderRow('dkim', result.status, { strict: this.strict });
|
|
426
761
|
});
|
|
427
762
|
}
|
|
428
763
|
|
|
@@ -432,4 +767,15 @@ class DkimVerifier extends MessageParser {
|
|
|
432
767
|
}
|
|
433
768
|
}
|
|
434
769
|
|
|
770
|
+
// the body canonicalization of a c= value, 'simple' when it is not set (RFC 6376 section 3.5)
|
|
771
|
+
const getBodyCanon = canonicalization => (canonicalization.split('/')[1] || 'simple').toLowerCase().trim();
|
|
772
|
+
|
|
773
|
+
// the lower case header field names of an h= tag
|
|
774
|
+
const normalizeFieldList = value =>
|
|
775
|
+
(value || '')
|
|
776
|
+
.toString()
|
|
777
|
+
.split(':')
|
|
778
|
+
.map(name => name.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '').toLowerCase())
|
|
779
|
+
.filter(name => name);
|
|
780
|
+
|
|
435
781
|
module.exports = { DkimVerifier };
|