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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "5.0.1"
2
+ ".": "5.0.3"
3
3
  }
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
@@ -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.round(signTime.getTime() / 1000);
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': dkimSigner.arc?.messageSignature
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, dkimSigner?.arc?.messageSignature, authResults].map(v => v)
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 };
@@ -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 = 0x0d;
10
- const CHAR_LF = 0x0a;
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
- this.emptyLinesQueue = [];
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
- if (this.emptyLinesQueue.length) {
89
- for (let emptyLine of this.emptyLinesQueue) {
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
- // remove line endings
102
- let foundNonLn = false;
103
-
104
- // buffer line endings and empty lines
105
- for (let i = chunk.length - 1; i >= 0; i--) {
106
- if (chunk[i] !== CHAR_LF && chunk[i] !== CHAR_CR) {
107
- this._drainPendingEmptyLines();
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 (!foundNonLn) {
118
- this.emptyLinesQueue.push(chunk);
108
+ if (!end) {
109
+ this.pendingLineBreaks += chunk.length / 2;
119
110
  return;
120
111
  }
121
112
 
122
- this._updateBodyHash(chunk);
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--] = line[i];
146
- if (i === 0 || line[i - 1] !== CHAR_CR) {
147
- // add missing carriage return
148
- lineBuf[writePos--] = CHAR_CR;
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
- continue;
151
- }
152
-
153
- if (line[i] === CHAR_CR) {
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
- this._pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart);
282
- lineNeedsFixing = false;
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(Buffer.from([CHAR_CR, CHAR_LF]));
284
+ this._updateBodyHash(CRLF);
288
285
  }
289
286
  }
290
287
  }
@@ -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
- this.byteLength += (chunk && chunk.length) || 0;
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
- if (this.remainder.length) {
85
- // see if we can release the last remainder
86
- for (let i = 0; i < chunk.length; i++) {
87
- let c = chunk[i];
88
- if (c !== 0x0a && c !== 0x0d) {
89
- // found non-line terminator byte, can release previous chunk
90
- for (let remainderChunk of this.remainder) {
91
- this._updateBodyHash(remainderChunk);
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
- // find line terminators from the end of chunk
99
- let matchStart = false;
100
- for (let i = chunk.length - 1; i >= 0; i--) {
101
- let c = chunk[i];
102
- if (c === 0x0a || c === 0x0d) {
103
- // stop looking
104
- matchStart = i;
105
- } else {
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 (matchStart === 0) {
111
- // nothing but newlines in this chunk
112
- this.remainder.push(chunk);
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
- this._updateBodyHash(chunk);
120
- this.lastNewline = chunk[chunk.length - 1] === 0x0a;
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
- if (!this.lastNewline || !this.bodyHashedBytes) {
125
- // emit empty line buffer to keep the stream flowing
126
- this._updateBodyHash(Buffer.from('\r\n'));
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
 
@@ -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
- if (signTime) {
38
- opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
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
- if (signTime) {
40
- opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
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.headerChunks.push(chunk);
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.headerChunks.push(chunk.slice(0, i + 1));
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)) {
@@ -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, opts)
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, opts)
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, opts);
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, opts);
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, opts);
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
- let ptrDomain;
424
- if (val) {
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
- for (let i = rows.length - 1; i >= 0; i--) {
86
- if (i > 0 && /^\s/.test(rows[i][0])) {
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
- casedKey = key[0].trim();
98
- key = casedKey.toLowerCase();
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
- const getSigningHeaderLines = (parsedHeaders, fieldNames, verify) => {
108
- fieldNames = (typeof fieldNames === 'string' ? fieldNames : defaultDKIMFieldNames)
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.trim().toLowerCase())
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.round(Date.now() / 1000),
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.round(Date.now() / 1000),
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.round(Date.now() / 1000)
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
- let val = values[key] || '';
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
- ?.toString('binary')
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(/^([^:]*):\s*/, (m, k) => k.toLowerCase().trim() + ':')
518
+ .replace(/^([^:]*):[ \t]*/, (m, k) => lowerCaseASCII(k).replace(/^[ \t]+|[ \t]+$/g, '') + ':')
460
519
  // single WSP
461
- .replace(/\s+/g, ' ')
462
- .trim() + (suffix ? suffix : '');
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "types": "index.d.ts",