mailauth 5.0.2 → 6.0.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "5.0.2"
2
+ ".": "6.0.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [6.0.0](https://github.com/postalsys/mailauth/compare/v5.0.3...v6.0.0) (2026-09-26)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **dmarc:** DMARC verdicts can change. A subdomain whose parent publishes no record is now its own Organizational Domain, so a signature or SPF domain of the parent no longer aligns with it. Subdomains of names the Public Suffix List lists as suffixes but that publish a DMARC record (for example blogspot.com) now inherit that policy. The result's domain is the Tree Walk Organizational Domain, and is the author domain when no record is found.
9
+
10
+ ### Features
11
+
12
+ * **dmarc:** find policies and organizational domains with the RFC 9989 DNS Tree Walk ([8ab1da2](https://github.com/postalsys/mailauth/commit/8ab1da24aa5f1be7939393bd79a9834802aa911d))
13
+
14
+ ## [5.0.3](https://github.com/postalsys/mailauth/compare/v5.0.2...v5.0.3) (2026-09-03)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **dkim:** canonicalize a CR that is not a line ending as body content ([68dfb42](https://github.com/postalsys/mailauth/commit/68dfb4262fdae1bedb17019ab8c3c59bc2f4cf8d))
20
+ * **dkim:** canonicalize a lone CR as body content in the simple algorithm ([aa54b0e](https://github.com/postalsys/mailauth/commit/aa54b0e75f65a274d2f56ea4df6f58bc86c080fc))
21
+ * **dkim:** fold a header line that is not a well formed field ([227cf48](https://github.com/postalsys/mailauth/commit/227cf48fb42dbb93bcebfd1acbc8484032e03db9))
22
+ * **dkim:** read the signature timestamp once per signature ([c8c6cd8](https://github.com/postalsys/mailauth/commit/c8c6cd8d41fd176d59522bde85022b7ab0f1e324))
23
+ * **dkim:** sign and verify a message that has no body ([0b7c6ed](https://github.com/postalsys/mailauth/commit/0b7c6ed6723a8cdf2f65334af7d0783a373ab5e8))
24
+ * **dkim:** stop emitting signatures and seals that cover nothing ([4eefa97](https://github.com/postalsys/mailauth/commit/4eefa9793fa6755d2e11b1fcaa4581b7f1b32515))
25
+ * **dkim:** treat only SP and HTAB as whitespace in header canonicalization ([734db62](https://github.com/postalsys/mailauth/commit/734db62d09376c631e548f6c8b44a56ba38659c5))
26
+
3
27
  ## [5.0.2](https://github.com/postalsys/mailauth/compare/v5.0.1...v5.0.2) (2026-08-19)
4
28
 
5
29
 
package/README.md CHANGED
@@ -433,7 +433,7 @@ See [DMARC Result Reference](docs/dmarc.md) for details on the result object str
433
433
 
434
434
  ##### `getDmarcRecord(domain [, resolver])`
435
435
 
436
- Fetches and parses the DMARC DNS record for a domain or subdomain. Returns `false` if no record exists.
436
+ Fetches and parses the DMARC record that applies to a domain, found with the DNS Tree Walk of RFC 9989 section 4.10. That is the domain's own record, or else the record of its Organizational Domain, or else a record published with `psd=y` above it. Returns `false` if no record applies.
437
437
 
438
438
  ###### Syntax
439
439
 
@@ -469,10 +469,14 @@ console.log(dmarcRecord);
469
469
  "sp": "none",
470
470
  "aspf": "r",
471
471
  "rr": "v=DMARC1; p=none; pct=100; rua=mailto:re+joqy8fpatm3@dmarc.postmarkapp.com; sp=none; aspf=r;",
472
- "isOrgRecord": false
472
+ "isOrgRecord": false,
473
+ "recordDomain": "ethereal.email",
474
+ "orgDomain": "ethereal.email"
473
475
  }
474
476
  ```
475
477
 
478
+ `isOrgRecord` is `true` when the record was inherited from the Organizational Domain or a PSD, and `recordDomain` is where it was published. `orgDomain` is `null` when the record was found at the domain itself but the rest of the Tree Walk failed.
479
+
476
480
  ### BIMI
477
481
 
478
482
  Brand Indicators for Message Identification (BIMI) support is based on [draft-blank-ietf-bimi-02](https://tools.ietf.org/html/draft-blank-ietf-bimi-02). BIMI information is resolved during the authentication step, provided the message passes DMARC validation with a policy other than "none".
package/docs/dmarc.md CHANGED
@@ -6,6 +6,8 @@ This document describes the result object returned by DMARC verification.
6
6
 
7
7
  DMARC (Domain-based Message Authentication, Reporting, and Conformance) is verified during the authentication process. The DMARC result depends on both SPF and DKIM verification results.
8
8
 
9
+ Policy discovery and Organizational Domains follow the DNS Tree Walk of RFC 9989 section 4.10, not the Public Suffix List. The policy is taken from the record of the author domain, or else from the record of its Organizational Domain, or else from a record published with `psd=y` above it. Relaxed alignment compares the Organizational Domains of the author domain and of each authenticated identifier, which can take further walks. A walk makes at most eight queries, lookups are shared within a message, only identifiers at or below the author's Organizational Domain are walked, and at most ten identifiers per message are walked.
10
+
9
11
  ```javascript
10
12
  const { authenticate } = require('mailauth');
11
13
 
@@ -21,7 +23,7 @@ const { dmarc } = await authenticate(message, {
21
23
  | Field | Type | Presence | Description |
22
24
  | ----------- | -------- | ------------ | ---------------------------------------------------------- |
23
25
  | `status` | `object` | Always | Verification status object (see below) |
24
- | `domain` | `string` | Always | Organizational domain used for DMARC lookup |
26
+ | `domain` | `string` | Always | Organizational Domain of the author domain (Tree Walk) |
25
27
  | `policy` | `string` | Record found | Effective policy (`"reject"`, `"quarantine"`, or `"none"`) |
26
28
  | `p` | `string` | Record found | Policy from `p=` tag |
27
29
  | `sp` | `string` | Record found | Subdomain policy from `sp=` tag (defaults to `p` value) |
@@ -29,7 +31,9 @@ const { dmarc } = await authenticate(message, {
29
31
  | `rr` | `string` | Record found | Raw DMARC DNS TXT record |
30
32
  | `alignment` | `object` | Record found | SPF and DKIM alignment details (see below) |
31
33
  | `error` | `string` | On temperror | Error message |
32
- | `info` | `string` | Always | Formatted Authentication-Results header value |
34
+
35
+ On a `temperror` that happens after a record was found, because a Tree Walk needed for relaxed alignment failed and no other identifier aligned, `policy`, `p`, `sp`, `rr` and `alignment` are still set.
36
+ | `info` | `string` | Always | Formatted Authentication-Results header value |
33
37
 
34
38
  ## status Object
35
39
 
@@ -41,10 +45,10 @@ const { dmarc } = await authenticate(message, {
41
45
 
42
46
  ### status.header Object
43
47
 
44
- | Field | Type | Description |
45
- | ------ | -------- | ----------------------------------- |
46
- | `from` | `string` | Domain of the From header address |
47
- | `d` | `string` | Domain where DMARC record was found |
48
+ | Field | Type | Description |
49
+ | ------ | -------- | --------------------------------------------------------------------------------------- |
50
+ | `from` | `string` | Domain of the From header address |
51
+ | `d` | `string` | Domain where the applied DMARC record was found (author, Organizational Domain, or PSD) |
48
52
 
49
53
  ## alignment Object
50
54
 
@@ -72,12 +76,12 @@ const { dmarc } = await authenticate(message, {
72
76
 
73
77
  ## Result Values
74
78
 
75
- | Result | Description |
76
- | ----------- | ----------------------------------------------------- |
77
- | `pass` | Message passed DMARC (SPF or DKIM aligned and passed) |
78
- | `fail` | Message failed DMARC (neither SPF nor DKIM aligned) |
79
- | `none` | No DMARC record found |
80
- | `temperror` | Temporary error during DNS lookup |
79
+ | Result | Description |
80
+ | ----------- | -------------------------------------------------------------- |
81
+ | `pass` | Message passed DMARC (SPF or DKIM aligned and passed) |
82
+ | `fail` | Message failed DMARC (neither SPF nor DKIM aligned) |
83
+ | `none` | No DMARC record found |
84
+ | `temperror` | Temporary error during a DNS lookup that the result depends on |
81
85
 
82
86
  ## Policy Values
83
87
 
@@ -168,7 +172,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
168
172
  "from": "no-dmarc.example.com"
169
173
  }
170
174
  },
171
- "domain": "example.com",
175
+ "domain": "no-dmarc.example.com",
172
176
  "info": "dmarc=none header.from=no-dmarc.example.com"
173
177
  }
174
178
  ```
package/index.d.ts CHANGED
@@ -180,7 +180,9 @@ export interface DKIMResult {
180
180
  */
181
181
  status: AuthStatus & {
182
182
  /**
183
- * Signing domain if it aligns with the From domain, false otherwise
183
+ * Signing domain if it aligns with the From domain, false otherwise. For a passing
184
+ * signature this follows the DMARC verdict once DMARC found a record, before that it
185
+ * only guesses relaxed alignment from the Public Suffix List.
184
186
  */
185
187
  aligned?: string | false;
186
188
 
@@ -348,6 +350,12 @@ export interface ARCResult {
348
350
  */
349
351
  authResults?: string;
350
352
 
353
+ /**
354
+ * Why sealing failed, when a seal was requested but could not be created.
355
+ * The message is left unsealed in that case
356
+ */
357
+ sealErrors?: Error[];
358
+
351
359
  /**
352
360
  * ARC chain entries (non-enumerable property)
353
361
  * Access with result.chain or Object.getOwnPropertyDescriptor()
@@ -360,7 +368,7 @@ export interface ARCResult {
360
368
  */
361
369
  export interface DMARCResult {
362
370
  /**
363
- * Organization domain
371
+ * Organizational Domain of the author domain, found with the DNS Tree Walk (RFC 9989 4.10)
364
372
  */
365
373
  domain: string;
366
374
 
@@ -1089,9 +1097,9 @@ export function verifyASChain(data: ARCData, opts: ARCOptions): Promise<boolean>
1089
1097
  *
1090
1098
  * @param input - RFC822 formatted message or false for pre-calculated data
1091
1099
  * @param data - Seal creation data
1092
- * @returns Seal headers
1100
+ * @returns Seal headers, empty if the ARC-Message-Signature could not be signed, and any errors
1093
1101
  */
1094
- export function createSeal(input: MessageInput | false, data: any): Promise<{ headers: string[] }>;
1102
+ export function createSeal(input: MessageInput | false, data: any): Promise<{ headers: string[]; errors: Error[] }>;
1095
1103
 
1096
1104
  // ============================================================================
1097
1105
  // 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')