mailauth 5.0.1 → 5.0.3
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 +20 -0
- package/index.d.ts +8 -2
- package/lib/arc/index.d.ts +2 -2
- package/lib/arc/index.js +16 -21
- package/lib/dkim/body/line-endings.js +34 -0
- package/lib/dkim/body/relaxed.js +35 -38
- package/lib/dkim/body/simple.js +72 -36
- package/lib/dkim/dkim-signer.js +16 -0
- package/lib/dkim/dkim-verifier.js +4 -7
- package/lib/dkim/header/relaxed.js +7 -13
- package/lib/dkim/header/simple.js +7 -13
- package/lib/dkim/message-parser.js +21 -12
- package/lib/mailauth.js +6 -0
- package/lib/spf/macro.js +7 -2
- package/lib/spf/spf-verify.js +10 -11
- package/lib/tools.js +90 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.0.3](https://github.com/postalsys/mailauth/compare/v5.0.2...v5.0.3) (2026-09-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **dkim:** canonicalize a CR that is not a line ending as body content ([68dfb42](https://github.com/postalsys/mailauth/commit/68dfb4262fdae1bedb17019ab8c3c59bc2f4cf8d))
|
|
9
|
+
* **dkim:** canonicalize a lone CR as body content in the simple algorithm ([aa54b0e](https://github.com/postalsys/mailauth/commit/aa54b0e75f65a274d2f56ea4df6f58bc86c080fc))
|
|
10
|
+
* **dkim:** fold a header line that is not a well formed field ([227cf48](https://github.com/postalsys/mailauth/commit/227cf48fb42dbb93bcebfd1acbc8484032e03db9))
|
|
11
|
+
* **dkim:** read the signature timestamp once per signature ([c8c6cd8](https://github.com/postalsys/mailauth/commit/c8c6cd8d41fd176d59522bde85022b7ab0f1e324))
|
|
12
|
+
* **dkim:** sign and verify a message that has no body ([0b7c6ed](https://github.com/postalsys/mailauth/commit/0b7c6ed6723a8cdf2f65334af7d0783a373ab5e8))
|
|
13
|
+
* **dkim:** stop emitting signatures and seals that cover nothing ([4eefa97](https://github.com/postalsys/mailauth/commit/4eefa9793fa6755d2e11b1fcaa4581b7f1b32515))
|
|
14
|
+
* **dkim:** treat only SP and HTAB as whitespace in header canonicalization ([734db62](https://github.com/postalsys/mailauth/commit/734db62d09376c631e548f6c8b44a56ba38659c5))
|
|
15
|
+
|
|
16
|
+
## [5.0.2](https://github.com/postalsys/mailauth/compare/v5.0.1...v5.0.2) (2026-08-19)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **spf:** expand %{d} macro to the domain currently being evaluated ([#126](https://github.com/postalsys/mailauth/issues/126)) ([53c4522](https://github.com/postalsys/mailauth/commit/53c45220a1af41febb5b901c251ed4cc66de511e))
|
|
22
|
+
|
|
3
23
|
## [5.0.1](https://github.com/postalsys/mailauth/compare/v5.0.0...v5.0.1) (2026-08-07)
|
|
4
24
|
|
|
5
25
|
|
package/index.d.ts
CHANGED
|
@@ -348,6 +348,12 @@ export interface ARCResult {
|
|
|
348
348
|
*/
|
|
349
349
|
authResults?: string;
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Why sealing failed, when a seal was requested but could not be created.
|
|
353
|
+
* The message is left unsealed in that case
|
|
354
|
+
*/
|
|
355
|
+
sealErrors?: Error[];
|
|
356
|
+
|
|
351
357
|
/**
|
|
352
358
|
* ARC chain entries (non-enumerable property)
|
|
353
359
|
* Access with result.chain or Object.getOwnPropertyDescriptor()
|
|
@@ -1089,9 +1095,9 @@ export function verifyASChain(data: ARCData, opts: ARCOptions): Promise<boolean>
|
|
|
1089
1095
|
*
|
|
1090
1096
|
* @param input - RFC822 formatted message or false for pre-calculated data
|
|
1091
1097
|
* @param data - Seal creation data
|
|
1092
|
-
* @returns Seal headers
|
|
1098
|
+
* @returns Seal headers, empty if the ARC-Message-Signature could not be signed, and any errors
|
|
1093
1099
|
*/
|
|
1094
|
-
export function createSeal(input: MessageInput | false, data: any): Promise<{ headers: string[] }>;
|
|
1100
|
+
export function createSeal(input: MessageInput | false, data: any): Promise<{ headers: string[]; errors: Error[] }>;
|
|
1095
1101
|
|
|
1096
1102
|
// ============================================================================
|
|
1097
1103
|
// BIMI
|
package/lib/arc/index.d.ts
CHANGED
|
@@ -42,6 +42,6 @@ export function verifyASChain(data: ARCData, opts: ARCOptions): Promise<boolean>
|
|
|
42
42
|
*
|
|
43
43
|
* @param input - RFC822 formatted message or false for pre-calculated data
|
|
44
44
|
* @param data - Seal creation data
|
|
45
|
-
* @returns Seal headers
|
|
45
|
+
* @returns Seal headers, empty if the ARC-Message-Signature could not be signed, and any errors
|
|
46
46
|
*/
|
|
47
|
-
export function createSeal(input: MessageInput | false, data: any): Promise<{ headers: string[] }>;
|
|
47
|
+
export function createSeal(input: MessageInput | false, data: any): Promise<{ headers: string[]; errors: Error[] }>;
|
package/lib/arc/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const { Buffer } = require('node:buffer');
|
|
|
4
4
|
const {
|
|
5
5
|
parseDkimHeaders,
|
|
6
6
|
formatRelaxedLine,
|
|
7
|
+
stripSignatureValue,
|
|
7
8
|
getPublicKey,
|
|
8
9
|
formatAuthHeaderRow,
|
|
9
10
|
formatSignatureHeaderLine,
|
|
@@ -36,15 +37,7 @@ const verifyAS = async (chain, opts) => {
|
|
|
36
37
|
throw err;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
chunks.push(
|
|
40
|
-
Buffer.from(
|
|
41
|
-
formatRelaxedLine(link['arc-seal'].original)
|
|
42
|
-
.toString('binary')
|
|
43
|
-
// remove value from b= key
|
|
44
|
-
.replace(/([;:\s]+b=)[^;]+/, '$1'),
|
|
45
|
-
'binary'
|
|
46
|
-
)
|
|
47
|
-
);
|
|
40
|
+
chunks.push(stripSignatureValue(formatRelaxedLine(link['arc-seal'].original)));
|
|
48
41
|
}
|
|
49
42
|
}
|
|
50
43
|
|
|
@@ -118,7 +111,7 @@ const signAS = async (chain, entry, signatureData) => {
|
|
|
118
111
|
|
|
119
112
|
if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') {
|
|
120
113
|
// we need a unix timestamp value
|
|
121
|
-
signTime = Math.
|
|
114
|
+
signTime = Math.floor(signTime.getTime() / 1000);
|
|
122
115
|
headerOpts.t = signTime;
|
|
123
116
|
}
|
|
124
117
|
}
|
|
@@ -135,15 +128,7 @@ const signAS = async (chain, entry, signatureData) => {
|
|
|
135
128
|
true
|
|
136
129
|
);
|
|
137
130
|
|
|
138
|
-
chunks.push(
|
|
139
|
-
Buffer.from(
|
|
140
|
-
formatRelaxedLine(canonSignatureHeaderLine)
|
|
141
|
-
.toString('binary')
|
|
142
|
-
// remove value from b= key
|
|
143
|
-
.replace(/([;:\s]+b=)[^;]+/, '$1'),
|
|
144
|
-
'binary'
|
|
145
|
-
)
|
|
146
|
-
);
|
|
131
|
+
chunks.push(stripSignatureValue(formatRelaxedLine(canonSignatureHeaderLine)));
|
|
147
132
|
|
|
148
133
|
let canonicalizedHeader = Buffer.concat(chunks);
|
|
149
134
|
|
|
@@ -504,6 +489,15 @@ const createSeal = async (input, data) => {
|
|
|
504
489
|
|
|
505
490
|
seal.i = seal.i || 1;
|
|
506
491
|
|
|
492
|
+
const messageSignature = dkimSigner.arc?.messageSignature;
|
|
493
|
+
if (!messageSignature) {
|
|
494
|
+
// The ARC-Message-Signature is missing whenever signing it failed, an unusable key
|
|
495
|
+
// being the usual reason. There is no ARC set to seal then: sealing anyway hashes
|
|
496
|
+
// a header that does not exist and puts an empty line into the header block, which
|
|
497
|
+
// ends it early for everything downstream
|
|
498
|
+
return { headers: [], errors: dkimSigner.errors };
|
|
499
|
+
}
|
|
500
|
+
|
|
507
501
|
const authResults = `ARC-Authentication-Results: i=${seal.i}; ${seal.authResults}`;
|
|
508
502
|
|
|
509
503
|
// Step 2. Calculate ARC-Seal
|
|
@@ -511,7 +505,7 @@ const createSeal = async (input, data) => {
|
|
|
511
505
|
arc.chain,
|
|
512
506
|
{
|
|
513
507
|
'arc-authentication-results': authResults,
|
|
514
|
-
'arc-message-signature':
|
|
508
|
+
'arc-message-signature': messageSignature
|
|
515
509
|
},
|
|
516
510
|
{
|
|
517
511
|
instance: seal.i,
|
|
@@ -526,7 +520,8 @@ const createSeal = async (input, data) => {
|
|
|
526
520
|
);
|
|
527
521
|
|
|
528
522
|
return {
|
|
529
|
-
headers: [arcSeal,
|
|
523
|
+
headers: [arcSeal, messageSignature, authResults],
|
|
524
|
+
errors: dkimSigner.errors
|
|
530
525
|
};
|
|
531
526
|
};
|
|
532
527
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Buffer } = require('node:buffer');
|
|
4
|
+
|
|
5
|
+
const CHAR_CR = 0x0d;
|
|
6
|
+
const CHAR_LF = 0x0a;
|
|
7
|
+
|
|
8
|
+
// A run of line endings at the end of the body is held back until it is known whether any
|
|
9
|
+
// content follows it, because RFC 6376 drops it if none does. Both canonicalizations count
|
|
10
|
+
// the CRLF pairs in that run instead of keeping the buffers they arrived in: the message
|
|
11
|
+
// parser hands over one small buffer per line, and each of those pins the whole pool it
|
|
12
|
+
// was allocated from, so a body of nothing but line breaks was retained about two hundred
|
|
13
|
+
// times over. The pairs are written back out from this one shared buffer, which is built
|
|
14
|
+
// with Buffer.alloc because Buffer.from would pool it and pin a slab for the same reason
|
|
15
|
+
const EMPTY_LINE_BATCH = 512;
|
|
16
|
+
const CRLF_RUN = Buffer.alloc(EMPTY_LINE_BATCH * 2);
|
|
17
|
+
for (let i = 0; i < CRLF_RUN.length; i += 2) {
|
|
18
|
+
CRLF_RUN[i] = CHAR_CR;
|
|
19
|
+
CRLF_RUN[i + 1] = CHAR_LF;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CRLF = CRLF_RUN.subarray(0, 2);
|
|
23
|
+
|
|
24
|
+
// Calls back with every held back CRLF pair, in batches. The caller owns the counter and
|
|
25
|
+
// has to clear it
|
|
26
|
+
const eachHeldLineBreak = (count, fn) => {
|
|
27
|
+
while (count > 0) {
|
|
28
|
+
let batch = Math.min(count, EMPTY_LINE_BATCH);
|
|
29
|
+
fn(CRLF_RUN.subarray(0, batch * 2));
|
|
30
|
+
count -= batch;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
module.exports = { CHAR_CR, CHAR_LF, CRLF, eachHeldLineBreak };
|
package/lib/dkim/body/relaxed.js
CHANGED
|
@@ -6,8 +6,8 @@ const { Buffer } = require('node:buffer');
|
|
|
6
6
|
const crypto = require('node:crypto');
|
|
7
7
|
const { MimeStructureStartFinder } = require('../mime-structure-start-finder');
|
|
8
8
|
|
|
9
|
-
const CHAR_CR =
|
|
10
|
-
|
|
9
|
+
const { CHAR_CR, CHAR_LF, CRLF, eachHeldLineBreak } = require('./line-endings');
|
|
10
|
+
|
|
11
11
|
const CHAR_SPACE = 0x20;
|
|
12
12
|
const CHAR_TAB = 0x09;
|
|
13
13
|
|
|
@@ -40,7 +40,9 @@ class RelaxedHash {
|
|
|
40
40
|
|
|
41
41
|
this.maxSizeReached = maxBodyLength === 0;
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// number of empty lines seen at the end of the body so far. They are hashed only
|
|
44
|
+
// once it is known that more content follows them
|
|
45
|
+
this.pendingLineBreaks = 0;
|
|
44
46
|
|
|
45
47
|
this.mimeStructureStartFinder = new MimeStructureStartFinder();
|
|
46
48
|
}
|
|
@@ -85,12 +87,8 @@ class RelaxedHash {
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
_drainPendingEmptyLines() {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this._updateBodyHash(emptyLine);
|
|
91
|
-
}
|
|
92
|
-
this.emptyLinesQueue = [];
|
|
93
|
-
}
|
|
90
|
+
eachHeldLineBreak(this.pendingLineBreaks, lineBreaks => this._updateBodyHash(lineBreaks));
|
|
91
|
+
this.pendingLineBreaks = 0;
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
_pushBodyHash(chunk) {
|
|
@@ -98,28 +96,24 @@ class RelaxedHash {
|
|
|
98
96
|
return;
|
|
99
97
|
}
|
|
100
98
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (i < chunk.length - 1) {
|
|
109
|
-
this.emptyLinesQueue.push(chunk.subarray(i + 1));
|
|
110
|
-
chunk = chunk.subarray(0, i + 1);
|
|
111
|
-
}
|
|
112
|
-
foundNonLn = true;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
99
|
+
// Count the line endings at the end of the chunk: these are the empty lines that
|
|
100
|
+
// are dropped if no more content follows. Everything that reaches this method has
|
|
101
|
+
// already been canonicalized, so the run is always whole CRLF pairs, see
|
|
102
|
+
// ./line-endings for why they are counted rather than kept
|
|
103
|
+
let end = chunk.length;
|
|
104
|
+
while (end >= 2 && chunk[end - 2] === CHAR_CR && chunk[end - 1] === CHAR_LF) {
|
|
105
|
+
end -= 2;
|
|
115
106
|
}
|
|
116
107
|
|
|
117
|
-
if (!
|
|
118
|
-
this.
|
|
108
|
+
if (!end) {
|
|
109
|
+
this.pendingLineBreaks += chunk.length / 2;
|
|
119
110
|
return;
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
this.
|
|
113
|
+
this._drainPendingEmptyLines();
|
|
114
|
+
this.pendingLineBreaks = (chunk.length - end) / 2;
|
|
115
|
+
|
|
116
|
+
this._updateBodyHash(chunk.subarray(0, end));
|
|
123
117
|
}
|
|
124
118
|
|
|
125
119
|
/**
|
|
@@ -142,16 +136,16 @@ class RelaxedHash {
|
|
|
142
136
|
|
|
143
137
|
for (let i = line.length - 1; i >= 0; i--) {
|
|
144
138
|
if (line[i] === CHAR_LF) {
|
|
145
|
-
lineBuf[writePos--] =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
lineBuf[writePos--] = CHAR_LF;
|
|
140
|
+
lineBuf[writePos--] = CHAR_CR;
|
|
141
|
+
if (i > 0 && line[i - 1] === CHAR_CR) {
|
|
142
|
+
// the <CR> of the line ending, any other <CR> is content
|
|
143
|
+
i--;
|
|
149
144
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
lineBuf[writePos--] = line[i];
|
|
145
|
+
// the scan has crossed into the previous line, whose own trailing
|
|
146
|
+
// whitespace has to go the same way
|
|
147
|
+
nonWspFound = false;
|
|
148
|
+
prevWsp = false;
|
|
155
149
|
continue;
|
|
156
150
|
}
|
|
157
151
|
|
|
@@ -278,13 +272,16 @@ class RelaxedHash {
|
|
|
278
272
|
if (final) {
|
|
279
273
|
let chunkPart = (cursorPos && chunk && chunk.subarray(cursorPos)) || chunk;
|
|
280
274
|
if (chunkPart && chunkPart.length) {
|
|
281
|
-
|
|
282
|
-
|
|
275
|
+
// The last line has no line ending, so the scan above never looked at its
|
|
276
|
+
// trailing whitespace. A single trailing space is the only thing it can
|
|
277
|
+
// have missed: a run of them, and an HTAB anywhere, both set
|
|
278
|
+
// lineNeedsFixing, and nothing clears it again before the end of the line
|
|
279
|
+
this._pushBodyHash(lineNeedsFixing || chunkPart[chunkPart.length - 1] === CHAR_SPACE ? this.fixLineBuffer(chunkPart) : chunkPart);
|
|
283
280
|
}
|
|
284
281
|
|
|
285
282
|
if (this.bodyHashedBytes) {
|
|
286
283
|
// terminating line break for non-empty messages
|
|
287
|
-
this._updateBodyHash(
|
|
284
|
+
this._updateBodyHash(CRLF);
|
|
288
285
|
}
|
|
289
286
|
}
|
|
290
287
|
}
|
package/lib/dkim/body/simple.js
CHANGED
|
@@ -4,6 +4,8 @@ const { Buffer } = require('node:buffer');
|
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const { MimeStructureStartFinder } = require('../mime-structure-start-finder');
|
|
6
6
|
|
|
7
|
+
const { CHAR_CR, CHAR_LF, CRLF, eachHeldLineBreak } = require('./line-endings');
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Class for calculating body hash of an email message body stream
|
|
9
11
|
* using the "simple" canonicalization
|
|
@@ -19,7 +21,10 @@ class SimpleHash {
|
|
|
19
21
|
algorithm = (algorithm || 'sha256').split('-').pop();
|
|
20
22
|
this.bodyHash = crypto.createHash(algorithm);
|
|
21
23
|
|
|
24
|
+
// line endings at the end of the body, held back until it is known whether content
|
|
25
|
+
// follows them. Whole CRLF pairs are only counted, see ./line-endings
|
|
22
26
|
this.remainder = [];
|
|
27
|
+
this.pendingLineBreaks = 0;
|
|
23
28
|
|
|
24
29
|
// total body size
|
|
25
30
|
this.byteLength = 0;
|
|
@@ -31,8 +36,6 @@ class SimpleHash {
|
|
|
31
36
|
this.maxBodyLength = maxBodyLength;
|
|
32
37
|
this.maxSizeReached = maxBodyLength === 0;
|
|
33
38
|
|
|
34
|
-
this.lastNewline = false;
|
|
35
|
-
|
|
36
39
|
this.mimeStructureStartFinder = new MimeStructureStartFinder();
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -76,56 +79,89 @@ class SimpleHash {
|
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
update(chunk) {
|
|
79
|
-
|
|
82
|
+
if (!chunk || !chunk.length) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.byteLength += chunk.length;
|
|
80
87
|
if (this.maxSizeReached) {
|
|
81
88
|
return;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
this.remainder = [];
|
|
94
|
-
}
|
|
95
|
-
}
|
|
91
|
+
// Find the line endings at the end of the chunk. They may be the *CRLF that ends
|
|
92
|
+
// the body, which digest() collapses, and a trailing <CR> may still pair up with
|
|
93
|
+
// an <LF> that opens the next chunk, so whether they are line endings is only
|
|
94
|
+
// known once something follows them. Peeling whole CRLF pairs first is what tells
|
|
95
|
+
// _holdBack the run can be counted rather than kept, without a second pass over it
|
|
96
|
+
let pairEnd = chunk.length;
|
|
97
|
+
while (pairEnd >= 2 && chunk[pairEnd - 2] === CHAR_CR && chunk[pairEnd - 1] === CHAR_LF) {
|
|
98
|
+
pairEnd -= 2;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
101
|
+
let end = pairEnd;
|
|
102
|
+
while (end > 0 && (chunk[end - 1] === CHAR_LF || chunk[end - 1] === CHAR_CR)) {
|
|
103
|
+
end -= 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (end) {
|
|
107
|
+
this._drainHeldLineEndings();
|
|
108
|
+
this._updateBodyHash(chunk.subarray(0, end));
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
if (end < chunk.length) {
|
|
112
|
+
this._holdBack(chunk, end, pairEnd);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_holdBack(chunk, end, pairEnd) {
|
|
117
|
+
if (!this.remainder.length && end === pairEnd) {
|
|
118
|
+
// nothing but whole CRLF pairs, and nothing kept before them to stay behind
|
|
119
|
+
this.pendingLineBreaks += (chunk.length - pairEnd) / 2;
|
|
113
120
|
return;
|
|
114
|
-
} else if (matchStart !== false) {
|
|
115
|
-
this.remainder.push(chunk.slice(matchStart));
|
|
116
|
-
chunk = chunk.slice(0, matchStart);
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// a lone <CR> or <LF> the counter can not stand in for, so keep the exact bytes.
|
|
124
|
+
// Anything counted so far stays counted, it comes first either way
|
|
125
|
+
this.remainder.push(chunk.subarray(end));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_drainHeldLineEndings() {
|
|
129
|
+
eachHeldLineBreak(this.pendingLineBreaks, lineBreaks => this._updateBodyHash(lineBreaks));
|
|
130
|
+
this.pendingLineBreaks = 0;
|
|
131
|
+
|
|
132
|
+
for (let heldChunk of this.remainder) {
|
|
133
|
+
this._updateBodyHash(heldChunk);
|
|
134
|
+
}
|
|
135
|
+
this.remainder = [];
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
digest(encoding) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
// RFC 6376 section 3.4.3: "simple" makes no changes to the body beyond converting
|
|
140
|
+
// the *CRLF at its end to a single CRLF. A <CR> that no <LF> follows does not end
|
|
141
|
+
// a line, so it is body content and stays where it is. A lone <LF> is taken as a
|
|
142
|
+
// line ending, the same way RelaxedHash takes it, and mailauth's message parser
|
|
143
|
+
// has already rewritten those to CRLF for anything read off the wire
|
|
144
|
+
let trailer = this.remainder.length === 1 ? this.remainder[0] : Buffer.concat(this.remainder);
|
|
145
|
+
let end = trailer.length;
|
|
146
|
+
while (end > 0 && trailer[end - 1] === CHAR_LF) {
|
|
147
|
+
end -= 1;
|
|
148
|
+
if (end > 0 && trailer[end - 1] === CHAR_CR) {
|
|
149
|
+
end -= 1;
|
|
150
|
+
}
|
|
127
151
|
}
|
|
128
152
|
|
|
153
|
+
if (end) {
|
|
154
|
+
// content survived among the kept bytes, so the pairs counted before them are
|
|
155
|
+
// inside the body rather than at its end and have to be hashed after all
|
|
156
|
+
eachHeldLineBreak(this.pendingLineBreaks, lineBreaks => this._updateBodyHash(lineBreaks));
|
|
157
|
+
this._updateBodyHash(trailer.subarray(0, end));
|
|
158
|
+
}
|
|
159
|
+
// whatever is still held is the *CRLF at the end of the body, and collapses below
|
|
160
|
+
this.pendingLineBreaks = 0;
|
|
161
|
+
|
|
162
|
+
// a body with no trailing CRLF, and an empty body, both get one
|
|
163
|
+
this._updateBodyHash(CRLF);
|
|
164
|
+
|
|
129
165
|
return this.bodyHash.digest(encoding);
|
|
130
166
|
}
|
|
131
167
|
|
package/lib/dkim/dkim-signer.js
CHANGED
|
@@ -184,6 +184,22 @@ class DkimSigner extends MessageParser {
|
|
|
184
184
|
|
|
185
185
|
let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.headerList);
|
|
186
186
|
|
|
187
|
+
if (!signingHeaderLines.headers.some(header => header.key === 'from')) {
|
|
188
|
+
// RFC 6376 section 5.4: the From header field MUST be signed, and section
|
|
189
|
+
// 6.1.1 makes a signature that does not cover it a PERMFAIL for every
|
|
190
|
+
// verifier. Without this an input with no header at all is signed with an
|
|
191
|
+
// empty h= tag, which is not even valid sig-h-tag syntax
|
|
192
|
+
let err = new Error('Can not sign a message that has no From header');
|
|
193
|
+
err.code = 'ENOFROM';
|
|
194
|
+
this.errors.push({
|
|
195
|
+
type: signatureData.type,
|
|
196
|
+
selector: signatureData.selector,
|
|
197
|
+
signingDomain: signatureData.signingDomain,
|
|
198
|
+
err
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
let { algorithm, signAlgo, hashAlgo } = this.getAlgorithm(signatureData);
|
|
188
204
|
let { canonicalization, bodyCanon } = this.getCanonicalization(signatureData);
|
|
189
205
|
|
|
@@ -124,8 +124,11 @@ class DkimVerifier extends MessageParser {
|
|
|
124
124
|
signatureHeader.expiration =
|
|
125
125
|
signatureHeader.parsed?.x && !isNaN(signatureHeader.parsed?.x?.value) ? new Date(signatureHeader.parsed?.x?.value * 1000) : null;
|
|
126
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
|
|
127
130
|
signatureHeader.maxBodyLength =
|
|
128
|
-
signatureHeader.parsed?.l?.value && !isNaN(signatureHeader.parsed?.l?.value) ? signatureHeader.parsed?.l?.value : '';
|
|
131
|
+
typeof signatureHeader.parsed?.l?.value === 'number' && !isNaN(signatureHeader.parsed?.l?.value) ? signatureHeader.parsed?.l?.value : '';
|
|
129
132
|
|
|
130
133
|
const validSignAlgo = ['rsa', 'ed25519'];
|
|
131
134
|
const validHeaderAlgo = signatureHeader.type === 'DKIM' ? ['sha256', 'sha1'] : ['sha256'];
|
|
@@ -336,12 +339,6 @@ class DkimVerifier extends MessageParser {
|
|
|
336
339
|
signatureHeader.canonicalizedLength = this.bodyHashes.get(signatureHeader.bodyHashKey)?.canonicalizedLength;
|
|
337
340
|
signatureHeader.sourceBodyLength = this.bodyHashes.get(signatureHeader.bodyHashKey)?.byteLength;
|
|
338
341
|
|
|
339
|
-
if (typeof signatureHeader.maxBodyLength === 'number' && signatureHeader.maxBodyLength !== signatureHeader.bodyHashedBytes) {
|
|
340
|
-
console.log('TOTAL', signatureHeader.bodyHashedBytes, 'EXPECTING', signatureHeader.maxBodyLength);
|
|
341
|
-
//status.result = 'fail';
|
|
342
|
-
//status.comment = `invalid body length ${signatureHeader.bodyHashedBytes}`;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
342
|
let result = {
|
|
346
343
|
id: signatureHeader.parsed?.b?.value
|
|
347
344
|
? crypto.createHash('sha256').update(Buffer.from(signatureHeader.parsed?.b?.value, 'base64')).digest('hex')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const { formatSignatureHeaderLine, formatRelaxedLine, getCurTime } = require('../../../lib/tools');
|
|
4
|
+
const { formatSignatureHeaderLine, formatRelaxedLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
|
|
5
5
|
|
|
6
6
|
// generate headers for signing
|
|
7
7
|
const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
@@ -34,9 +34,11 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
|
34
34
|
opts.i = instance;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// The same opts object formats the line that gets signed here and, once the
|
|
38
|
+
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
39
|
+
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
40
|
+
// boundary falling between the two reads signs one timestamp and publishes another
|
|
41
|
+
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
40
42
|
|
|
41
43
|
if (expires) {
|
|
42
44
|
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
@@ -55,15 +57,7 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
|
55
57
|
);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
chunks.push(
|
|
59
|
-
Buffer.from(
|
|
60
|
-
formatRelaxedLine(signatureHeaderLine)
|
|
61
|
-
.toString('binary')
|
|
62
|
-
// remove value from b= key
|
|
63
|
-
.replace(/([;:\s]+b=)[^;]+/, '$1'),
|
|
64
|
-
'binary'
|
|
65
|
-
)
|
|
66
|
-
);
|
|
60
|
+
chunks.push(stripSignatureValue(formatRelaxedLine(signatureHeaderLine)));
|
|
67
61
|
|
|
68
62
|
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
|
|
69
63
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const { formatSignatureHeaderLine, getCurTime } = require('../../../lib/tools');
|
|
4
|
+
const { formatSignatureHeaderLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
|
|
5
5
|
|
|
6
6
|
const formatSimpleLine = (line, suffix) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary');
|
|
7
7
|
|
|
@@ -36,9 +36,11 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
|
|
|
36
36
|
opts.i = instance;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// The same opts object formats the line that gets signed here and, once the
|
|
40
|
+
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
41
|
+
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
42
|
+
// boundary falling between the two reads signs one timestamp and publishes another
|
|
43
|
+
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
42
44
|
|
|
43
45
|
if (expires) {
|
|
44
46
|
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
@@ -57,15 +59,7 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
|
|
|
57
59
|
);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
chunks.push(
|
|
61
|
-
Buffer.from(
|
|
62
|
-
formatSimpleLine(signatureHeaderLine)
|
|
63
|
-
.toString('binary')
|
|
64
|
-
// remove value from b= key
|
|
65
|
-
.replace(/([;:\s]+b=)[^;]+/, '$1'),
|
|
66
|
-
'binary'
|
|
67
|
-
)
|
|
68
|
-
);
|
|
62
|
+
chunks.push(stripSignatureValue(formatSimpleLine(signatureHeaderLine)));
|
|
69
63
|
|
|
70
64
|
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
|
|
71
65
|
};
|
|
@@ -37,6 +37,17 @@ class MessageParser extends Writable {
|
|
|
37
37
|
// Override in child class
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Parses everything collected so far as the header block and hands it over. The body
|
|
41
|
+
// hash and the signatures are only ever built after this has run
|
|
42
|
+
async emitHeaders(chunk) {
|
|
43
|
+
this.state = 'body';
|
|
44
|
+
if (chunk) {
|
|
45
|
+
this.headerChunks.push(chunk);
|
|
46
|
+
}
|
|
47
|
+
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
48
|
+
await this.messageHeaders(this.headers);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
async processChunk(chunk) {
|
|
41
52
|
if (!chunk || !chunk.length) {
|
|
42
53
|
return;
|
|
@@ -57,17 +68,12 @@ class MessageParser extends Writable {
|
|
|
57
68
|
|
|
58
69
|
if (b0 === 0x0a && (b1 === 0x0a || (b1 === 0x0d && b2 === 0x0a))) {
|
|
59
70
|
// found header ending
|
|
60
|
-
this.state = 'body';
|
|
61
71
|
if (i === chunk.length - 1) {
|
|
62
72
|
//end of chunk
|
|
63
|
-
this.
|
|
64
|
-
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
65
|
-
await this.messageHeaders(this.headers);
|
|
73
|
+
await this.emitHeaders(chunk);
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
68
|
-
this.
|
|
69
|
-
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
70
|
-
await this.messageHeaders(this.headers);
|
|
76
|
+
await this.emitHeaders(chunk.slice(0, i + 1));
|
|
71
77
|
chunk = chunk.slice(i + 1);
|
|
72
78
|
break;
|
|
73
79
|
}
|
|
@@ -132,13 +138,16 @@ class MessageParser extends Writable {
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
async finish() {
|
|
141
|
+
if (!this.headers) {
|
|
142
|
+
// no body, so no empty line ended the header block. Hand the headers out
|
|
143
|
+
// before the final chunk, which is where signatures are checked or created.
|
|
144
|
+
// An input with no bytes at all goes through here too, so that the rest of
|
|
145
|
+
// the pipeline always has a header set to look at
|
|
146
|
+
await this.emitHeaders();
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
// generate final hash and emit it
|
|
136
150
|
await this.finalChunk();
|
|
137
|
-
|
|
138
|
-
if (!this.headers && this.headerChunks.length) {
|
|
139
|
-
this.headers = parseHeaders(Buffer.concat(this.headerChunks));
|
|
140
|
-
await this.messageHeaders(this.headers);
|
|
141
|
-
}
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
_final(callback) {
|
package/lib/mailauth.js
CHANGED
|
@@ -184,6 +184,12 @@ const authenticate = async (input, opts) => {
|
|
|
184
184
|
seal
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
if (sealResult?.errors?.length) {
|
|
188
|
+
// sealing failed, usually an unusable key. The message goes out unsealed, so
|
|
189
|
+
// say why somewhere the caller can find it rather than dropping it silently
|
|
190
|
+
arcResult.sealErrors = sealResult.errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
187
193
|
sealResult?.headers?.reverse().forEach(header => headers.unshift(header));
|
|
188
194
|
}
|
|
189
195
|
|
package/lib/spf/macro.js
CHANGED
|
@@ -12,11 +12,12 @@ const os = require('node:os');
|
|
|
12
12
|
* @param {String} values.ip Sender IP address
|
|
13
13
|
* @param {String} values.helo Client's HELO/EHLO domain
|
|
14
14
|
* @param {String} [values.mta] Hostname of the MTA or MX server that processes the message
|
|
15
|
+
* @param {String} [values.domain] Domain currently being evaluated by check_host(), changes on include/redirect recursion (RFC 7208 sections 5.2, 6.1)
|
|
15
16
|
*/
|
|
16
17
|
const macro = (input, values) => {
|
|
17
18
|
input = (input || '').toString();
|
|
18
19
|
|
|
19
|
-
let { sender, ip, helo, mta } = values || {};
|
|
20
|
+
let { sender, ip, helo, mta, domain } = values || {};
|
|
20
21
|
|
|
21
22
|
sender = (sender || '').toString();
|
|
22
23
|
ip = (ip || '').toString();
|
|
@@ -64,11 +65,15 @@ const macro = (input, values) => {
|
|
|
64
65
|
break;
|
|
65
66
|
|
|
66
67
|
case 'o':
|
|
67
|
-
case 'd':
|
|
68
68
|
case 'p': // validated domain name is unsupported, instead we use the unvalidated sender domain
|
|
69
69
|
curval = senderDomain;
|
|
70
70
|
break;
|
|
71
71
|
|
|
72
|
+
case 'd':
|
|
73
|
+
// current <domain> of check_host(), not the sender domain (these only match at the top level)
|
|
74
|
+
curval = domain || senderDomain;
|
|
75
|
+
break;
|
|
76
|
+
|
|
72
77
|
case 'i':
|
|
73
78
|
curval = ipaddr.parse(ip).toNormalizedString();
|
|
74
79
|
if (net.isIPv6(ip)) {
|
package/lib/spf/spf-verify.js
CHANGED
|
@@ -72,6 +72,9 @@ const spfVerify = async (domain, opts) => {
|
|
|
72
72
|
|
|
73
73
|
let addr = ipaddr.parse(opts.ip);
|
|
74
74
|
|
|
75
|
+
// %{d} must expand to the domain currently being evaluated, which changes on include/redirect recursion
|
|
76
|
+
let macroValues = { sender: opts.sender, ip: opts.ip, helo: opts.helo, mta: opts.mta, domain };
|
|
77
|
+
|
|
75
78
|
let resolver = opts.resolver || dns.resolve;
|
|
76
79
|
|
|
77
80
|
let responses;
|
|
@@ -135,7 +138,7 @@ const spfVerify = async (domain, opts) => {
|
|
|
135
138
|
let modifier = part.substr(0, splitPos).toLowerCase();
|
|
136
139
|
let value = part.substr(splitPos + 1);
|
|
137
140
|
|
|
138
|
-
value = macro(value,
|
|
141
|
+
value = macro(value, macroValues)
|
|
139
142
|
// remove trailing dot
|
|
140
143
|
.replace(/\.$/, '');
|
|
141
144
|
|
|
@@ -254,7 +257,7 @@ const spfVerify = async (domain, opts) => {
|
|
|
254
257
|
case 'include':
|
|
255
258
|
{
|
|
256
259
|
try {
|
|
257
|
-
let redirect = macro(val,
|
|
260
|
+
let redirect = macro(val, macroValues)
|
|
258
261
|
// remove trailing dot
|
|
259
262
|
.replace(/\.$/, '');
|
|
260
263
|
let sub = await spfVerify(redirect, opts);
|
|
@@ -328,7 +331,7 @@ const spfVerify = async (domain, opts) => {
|
|
|
328
331
|
let { domain: a, cidr4, cidr6 } = parseCidrValue(val, domain, type);
|
|
329
332
|
let cidr = net.isIPv6(opts.ip) ? cidr6 : cidr4;
|
|
330
333
|
|
|
331
|
-
a = macro(a,
|
|
334
|
+
a = macro(a, macroValues);
|
|
332
335
|
|
|
333
336
|
try {
|
|
334
337
|
a = punycode.toASCII(a);
|
|
@@ -354,7 +357,7 @@ const spfVerify = async (domain, opts) => {
|
|
|
354
357
|
let { domain: mxDomain, cidr4, cidr6 } = parseCidrValue(val, domain, type);
|
|
355
358
|
let cidr = net.isIPv6(opts.ip) ? cidr6 : cidr4;
|
|
356
359
|
|
|
357
|
-
mxDomain = macro(mxDomain,
|
|
360
|
+
mxDomain = macro(mxDomain, macroValues);
|
|
358
361
|
|
|
359
362
|
try {
|
|
360
363
|
mxDomain = punycode.toASCII(mxDomain);
|
|
@@ -397,7 +400,7 @@ const spfVerify = async (domain, opts) => {
|
|
|
397
400
|
|
|
398
401
|
case 'exists':
|
|
399
402
|
{
|
|
400
|
-
let existDomain = macro(val,
|
|
403
|
+
let existDomain = macro(val, macroValues);
|
|
401
404
|
try {
|
|
402
405
|
existDomain = punycode.toASCII(existDomain);
|
|
403
406
|
} catch (err) {
|
|
@@ -420,12 +423,8 @@ const spfVerify = async (domain, opts) => {
|
|
|
420
423
|
throw err;
|
|
421
424
|
}
|
|
422
425
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
ptrDomain = macro(val, opts);
|
|
426
|
-
} else {
|
|
427
|
-
ptrDomain = macro('%{d}', opts);
|
|
428
|
-
}
|
|
426
|
+
// bare "ptr" defaults to %{d}, the currently evaluated domain
|
|
427
|
+
let ptrDomain = val ? macro(val, macroValues) : domain;
|
|
429
428
|
ptrDomain = formatDomain(ptrDomain);
|
|
430
429
|
|
|
431
430
|
// Step 1. Resolve PTR hostnames
|
package/lib/tools.js
CHANGED
|
@@ -76,14 +76,47 @@ const writeToStream = async (stream, input, chunkSize) => {
|
|
|
76
76
|
});
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
// Lowercases a 'binary' string the way RFC 6376 section 3.4.2 means it. String.prototype
|
|
80
|
+
// toLowerCase() is Unicode aware and maps the latin1 bytes 0xC0-0xDE to 0xE0-0xFE, which
|
|
81
|
+
// corrupts a UTF-8 sequence and folds two distinct header names onto one. Only a string
|
|
82
|
+
// that has such a byte needs the slower per character mapping
|
|
83
|
+
const NON_ASCII = /[^\x00-\x7f]/;
|
|
84
|
+
const lowerCaseASCII = str => (NON_ASCII.test(str) ? str.replace(/[A-Z]/g, c => c.toLowerCase()) : str.toLowerCase());
|
|
85
|
+
|
|
86
|
+
// RFC 5322 section 3.6.8: field-name = 1*ftext, and ftext is printable US-ASCII except
|
|
87
|
+
// the colon. A fold, and the obsolete syntax of section 4.5.8, may put whitespace between
|
|
88
|
+
// the name and the colon
|
|
89
|
+
const FIELD_START = /^[\x21-\x39\x3b-\x7e]+[ \t\r\n]*:/;
|
|
90
|
+
|
|
91
|
+
// Whether a row opens a header of its own. Anything else is appended to the row above,
|
|
92
|
+
// which is wider than the CRLF-then-WSP fold of RFC 5322 section 2.2.3 and deliberately
|
|
93
|
+
// so: a line that is not a well formed field is read differently by different parsers,
|
|
94
|
+
// and mailauth's own libmime, like most MUAs, appends any line it calls whitespace to the
|
|
95
|
+
// value above it. Asking what a field looks like instead of what whitespace looks like
|
|
96
|
+
// covers every spelling of it, including the multi-byte ones a leading byte test misses,
|
|
97
|
+
// such as UTF-8 NBSP (C2 A0) and the ideographic space (E3 80 80). Appending a line can
|
|
98
|
+
// only add bytes to a signed value, so it can only ever turn a pass into a fail; reading
|
|
99
|
+
// the line the other way round is what cannot be undone. formatRelaxedLine is not lenient
|
|
100
|
+
// in the same way, what a signature covers is RFC 6376 section 3.4.2 alone
|
|
101
|
+
const startsField = row =>
|
|
102
|
+
// only the start of the row decides this, and a fold can push the colon no further
|
|
103
|
+
// than onto the line after the name
|
|
104
|
+
FIELD_START.test(row.length > 1 ? `${row[0]}\r\n${row[1]}` : row[0]);
|
|
105
|
+
|
|
79
106
|
const parseHeaders = buf => {
|
|
80
107
|
let rows = buf
|
|
81
108
|
.toString('binary')
|
|
82
109
|
.replace(/[\r\n]+$/, '')
|
|
83
110
|
.split(/\r?\n/)
|
|
84
111
|
.map(row => [row]);
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
|
|
113
|
+
if (rows.length === 1 && !rows[0][0]) {
|
|
114
|
+
// nothing to parse, rather than one row that is not a header
|
|
115
|
+
return { parsed: [], original: buf };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (let i = rows.length - 1; i > 0; i--) {
|
|
119
|
+
if (!startsField(rows[i])) {
|
|
87
120
|
rows[i - 1] = rows[i - 1].concat(rows[i]);
|
|
88
121
|
rows.splice(i, 1);
|
|
89
122
|
}
|
|
@@ -94,8 +127,15 @@ const parseHeaders = buf => {
|
|
|
94
127
|
let key = row.match(/^[^:]+/);
|
|
95
128
|
let casedKey;
|
|
96
129
|
if (key) {
|
|
97
|
-
|
|
98
|
-
key
|
|
130
|
+
// a fold may sit between the field name and the colon, and the CRLF it leaves
|
|
131
|
+
// behind is not part of the name. Without this the key of a header folded that
|
|
132
|
+
// way matches nothing, and a From read by every MUA stops being a From here
|
|
133
|
+
casedKey = key[0];
|
|
134
|
+
if (casedKey.indexOf('\n') >= 0) {
|
|
135
|
+
casedKey = casedKey.replace(/\r?\n/g, '');
|
|
136
|
+
}
|
|
137
|
+
casedKey = casedKey.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
138
|
+
key = lowerCaseASCII(casedKey);
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
return { key, casedKey, line: Buffer.from(row, 'binary') };
|
|
@@ -104,12 +144,21 @@ const parseHeaders = buf => {
|
|
|
104
144
|
return { parsed: rows, original: buf };
|
|
105
145
|
};
|
|
106
146
|
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
// Normalizes an h= tag, or a configured header list, exactly as parseHeaders normalizes a
|
|
148
|
+
// field name. Anything else and an entry names a key no parsed row can ever equal
|
|
149
|
+
const normalizeFieldNames = fieldNames =>
|
|
150
|
+
fieldNames
|
|
109
151
|
.split(':')
|
|
110
|
-
.map(key => key.
|
|
152
|
+
.map(key => lowerCaseASCII(key.replace(/^[ \t]+|[ \t]+$/g, '')))
|
|
111
153
|
.filter(key => key);
|
|
112
154
|
|
|
155
|
+
const defaultDKIMFieldNamesNormalized = normalizeFieldNames(defaultDKIMFieldNames);
|
|
156
|
+
|
|
157
|
+
const getSigningHeaderLines = (parsedHeaders, fieldNames, verify) => {
|
|
158
|
+
// an h= tag is attacker controlled on inbound mail, so only the constant default list
|
|
159
|
+
// is normalized ahead of time
|
|
160
|
+
fieldNames = typeof fieldNames === 'string' ? normalizeFieldNames(fieldNames) : defaultDKIMFieldNamesNormalized;
|
|
161
|
+
|
|
113
162
|
let signingList = [];
|
|
114
163
|
|
|
115
164
|
if (verify) {
|
|
@@ -154,7 +203,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
154
203
|
values = Object.assign(
|
|
155
204
|
{
|
|
156
205
|
v: 1,
|
|
157
|
-
t: Math.
|
|
206
|
+
t: Math.floor(Date.now() / 1000),
|
|
158
207
|
q: 'dns/txt'
|
|
159
208
|
},
|
|
160
209
|
values
|
|
@@ -166,7 +215,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
166
215
|
keyOrdering = keyOrderingARC;
|
|
167
216
|
values = Object.assign(
|
|
168
217
|
{
|
|
169
|
-
t: Math.
|
|
218
|
+
t: Math.floor(Date.now() / 1000),
|
|
170
219
|
q: 'dns/txt'
|
|
171
220
|
},
|
|
172
221
|
values
|
|
@@ -178,7 +227,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
178
227
|
keyOrdering = keyOrderingAS;
|
|
179
228
|
values = Object.assign(
|
|
180
229
|
{
|
|
181
|
-
t: Math.
|
|
230
|
+
t: Math.floor(Date.now() / 1000)
|
|
182
231
|
},
|
|
183
232
|
values
|
|
184
233
|
);
|
|
@@ -194,7 +243,10 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
194
243
|
.filter(key => values[key] !== false && typeof values[key] !== 'undefined' && values[key] !== null && keyOrdering.includes(key))
|
|
195
244
|
.sort((a, b) => keyOrdering.indexOf(a) - keyOrdering.indexOf(b))
|
|
196
245
|
.map(key => {
|
|
197
|
-
|
|
246
|
+
// the filter above already dropped false, null and undefined, so a falsy
|
|
247
|
+
// value left here is a meaningful one. `l=0` is a valid sig-l-tag (RFC 6376
|
|
248
|
+
// section 3.5, 1*76DIGIT) and must not be serialized as a valueless `l=`
|
|
249
|
+
let val = values[key];
|
|
198
250
|
if (key === 'b' && folded && val) {
|
|
199
251
|
// fold signature value
|
|
200
252
|
return `${key}=${val}`.replace(/.{75}/g, '$& ').trim();
|
|
@@ -210,6 +262,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
210
262
|
}
|
|
211
263
|
|
|
212
264
|
if (key === 'i' && type === 'DKIM') {
|
|
265
|
+
val = val.toString();
|
|
213
266
|
let atPos = val.indexOf('@');
|
|
214
267
|
if (atPos >= 0) {
|
|
215
268
|
let domainPart = val.substr(atPos + 1);
|
|
@@ -449,21 +502,35 @@ const formatAuthHeaderRow = (method, status) => {
|
|
|
449
502
|
return parts.join(' ');
|
|
450
503
|
};
|
|
451
504
|
|
|
505
|
+
// RFC 6376 section 3.4.2. Only SP and HTAB are whitespace here: the line is a
|
|
506
|
+
// 'binary' string, so a byte such as 0xA0 (the second byte of a UTF-8
|
|
507
|
+
// non-breaking space) must not be collapsed or trimmed
|
|
452
508
|
const formatRelaxedLine = (line, suffix) => {
|
|
453
509
|
let result =
|
|
454
|
-
line
|
|
455
|
-
|
|
510
|
+
// a missing line is nothing to canonicalize. Optional chaining would resolve the
|
|
511
|
+
// whole chain to undefined and the concatenation below would then hash the
|
|
512
|
+
// 9 byte string "undefined" as if it were a header
|
|
513
|
+
(line || '')
|
|
514
|
+
.toString('binary')
|
|
456
515
|
// unfold
|
|
457
516
|
.replace(/\r?\n/g, '')
|
|
458
517
|
// key to lowercase, trim around :
|
|
459
|
-
.replace(/^([^:]*)
|
|
518
|
+
.replace(/^([^:]*):[ \t]*/, (m, k) => lowerCaseASCII(k).replace(/^[ \t]+|[ \t]+$/g, '') + ':')
|
|
460
519
|
// single WSP
|
|
461
|
-
.replace(
|
|
462
|
-
|
|
520
|
+
.replace(/[ \t]+/g, ' ')
|
|
521
|
+
// no WSP around the value
|
|
522
|
+
.replace(/^ | $/g, '') + (suffix ? suffix : '');
|
|
463
523
|
|
|
464
524
|
return Buffer.from(result, 'binary');
|
|
465
525
|
};
|
|
466
526
|
|
|
527
|
+
// RFC 6376 section 3.7: when the signature header is hashed, its own b= value is treated
|
|
528
|
+
// as empty. The tag can be preceded by WSP and, in a header that has not been unfolded
|
|
529
|
+
// yet, by the CRLF of a fold. Nothing else is whitespace here, for the same reason it is
|
|
530
|
+
// not in formatRelaxedLine: a byte such as 0xA0 in front of b= is content, and matching it
|
|
531
|
+
// would strip a decoy tag and leave the real signature inside the hashed header
|
|
532
|
+
const stripSignatureValue = line => Buffer.from(line.toString('binary').replace(/([;: \t\r\n]+b=)[^;]+/, '$1'), 'binary');
|
|
533
|
+
|
|
467
534
|
const formatDomain = domain => {
|
|
468
535
|
domain = domain.toLowerCase().trim();
|
|
469
536
|
try {
|
|
@@ -568,15 +635,19 @@ function getCurTime(timeValue) {
|
|
|
568
635
|
return timeValue;
|
|
569
636
|
}
|
|
570
637
|
|
|
638
|
+
// `curTime.toString` is the method, never the string, so the guard below used to
|
|
639
|
+
// pass for every value and handed back an Invalid Date. That silently became a
|
|
640
|
+
// NaN timestamp in a signature, and a comparison that is false either way in the
|
|
641
|
+
// verifier, so an unparseable value falls back to the current time instead
|
|
571
642
|
if (typeof timeValue === 'number' || !isNaN(timeValue)) {
|
|
572
643
|
let timestamp = Number(timeValue);
|
|
573
644
|
let curTime = new Date(timestamp);
|
|
574
|
-
if (curTime.toString !== 'Invalid Date') {
|
|
645
|
+
if (curTime.toString() !== 'Invalid Date') {
|
|
575
646
|
return curTime;
|
|
576
647
|
}
|
|
577
648
|
} else if (typeof timeValue === 'string') {
|
|
578
649
|
let curTime = new Date(timeValue);
|
|
579
|
-
if (curTime.toString !== 'Invalid Date') {
|
|
650
|
+
if (curTime.toString() !== 'Invalid Date') {
|
|
580
651
|
return curTime;
|
|
581
652
|
}
|
|
582
653
|
}
|
|
@@ -774,6 +845,7 @@ module.exports = {
|
|
|
774
845
|
getAlignment,
|
|
775
846
|
|
|
776
847
|
formatRelaxedLine,
|
|
848
|
+
stripSignatureValue,
|
|
777
849
|
formatDomain,
|
|
778
850
|
|
|
779
851
|
getPtrHostname,
|