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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +6 -2
- package/docs/dmarc.md +17 -13
- package/index.d.ts +12 -4
- 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/dmarc/get-dmarc-record.js +159 -38
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +108 -32
- package/lib/mailauth.js +20 -7
- package/lib/tools.js +90 -18
- package/package.json +12 -12
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
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')
|