mailauth 6.0.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +113 -39
- package/bin/mailauth.js +15 -6
- package/cli.md +9 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +80 -33
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +508 -43
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +3 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +577 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +154 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +77 -36
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +522 -116
- package/package.json +1 -1
package/lib/mailauth.js
CHANGED
|
@@ -7,8 +7,8 @@ const { dmarc, evaluateDmarc } = require('./dmarc');
|
|
|
7
7
|
const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
|
|
8
8
|
const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
|
|
9
9
|
const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
|
|
10
|
-
const { parseReceived } = require('./parse-received');
|
|
11
|
-
const { formatDomain, getAlignment } = require('./tools');
|
|
10
|
+
const { parseReceived, getClientAddress } = require('./parse-received');
|
|
11
|
+
const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
|
|
12
12
|
const libmime = require('libmime');
|
|
13
13
|
const os = require('node:os');
|
|
14
14
|
const { isIP } = require('net');
|
|
@@ -24,6 +24,7 @@ const { isIP } = require('net');
|
|
|
24
24
|
* @param {String} [opts.helo] Hostname from EHLO/HELO
|
|
25
25
|
* @param {String} [opts.mta] MTA/MX hostname (defaults to os.hostname)
|
|
26
26
|
* @param {Number} [opts.minBitLength=1024] Minimal allowed length of public keys. If DKIM/ARC key is smaller, then verification fails
|
|
27
|
+
* @param {Boolean} [opts.rejectRsaSha1=false] If true then rsa-sha1 DKIM signatures get dkim=policy (weak-algorithm) as in strict mode (RFC 8301), the other checks stay lenient
|
|
27
28
|
* @param {Object} [opts.seal] ARC sealing options
|
|
28
29
|
* @param {String} [opts.seal.signingDomain] ARC key domain name
|
|
29
30
|
* @param {String} [opts.seal.selector] ARC key selector
|
|
@@ -31,18 +32,26 @@ const { isIP } = require('net');
|
|
|
31
32
|
* @param {Boolean} [opts.disableArc=false] If true then do not perform ARC validation and sealing
|
|
32
33
|
* @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
|
|
33
34
|
* @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
|
|
35
|
+
* @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
|
|
34
36
|
* @returns Authentication result
|
|
35
37
|
*/
|
|
36
38
|
const authenticate = async (input, opts) => {
|
|
37
39
|
opts = Object.assign({}, opts); // copy keys
|
|
38
40
|
|
|
39
41
|
opts.mta = opts.mta || os.hostname();
|
|
42
|
+
opts.strict = !!opts.strict;
|
|
43
|
+
|
|
44
|
+
// RFC 8601 section 2.5: the authserv-id, usually a host name. A value that is not a token is
|
|
45
|
+
// quoted, or throws under strict, before any work is done
|
|
46
|
+
const authservId = formatAuthservId(opts.mta, opts.strict);
|
|
40
47
|
|
|
41
48
|
const dkimResult = await dkimVerify(input, {
|
|
42
49
|
resolver: opts.resolver,
|
|
43
50
|
sender: opts.sender, // defaults to Return-Path header
|
|
44
51
|
seal: opts.seal,
|
|
45
|
-
minBitLength: opts.minBitLength
|
|
52
|
+
minBitLength: opts.minBitLength,
|
|
53
|
+
rejectRsaSha1: opts.rejectRsaSha1,
|
|
54
|
+
strict: opts.strict
|
|
46
55
|
});
|
|
47
56
|
|
|
48
57
|
const receivedChain = dkimResult.headers?.parsed.filter(r => r.key === 'received').map(row => parseReceived(row.line));
|
|
@@ -52,13 +61,9 @@ const authenticate = async (input, opts) => {
|
|
|
52
61
|
let rcvd = receivedChain?.find(row => row.from?.value);
|
|
53
62
|
if (rcvd) {
|
|
54
63
|
let helo = rcvd.from.value;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (ipMatch) {
|
|
59
|
-
ip = ipMatch[1].replace(/^IPv6:/i, '');
|
|
60
|
-
}
|
|
61
|
-
}
|
|
64
|
+
// the address of the connection from the TCP-info part of the from clause
|
|
65
|
+
// (RFC 5321 section 4.4), never an address the client claimed in its HELO
|
|
66
|
+
let ip = getClientAddress(rcvd.from);
|
|
62
67
|
|
|
63
68
|
if (ip && !opts.ip) {
|
|
64
69
|
opts.ip = ip;
|
|
@@ -95,12 +100,16 @@ const authenticate = async (input, opts) => {
|
|
|
95
100
|
if (!opts.disableArc) {
|
|
96
101
|
arcResult = await arc(dkimResult.arc, {
|
|
97
102
|
resolver: opts.resolver,
|
|
98
|
-
minBitLength: opts.minBitLength
|
|
103
|
+
minBitLength: opts.minBitLength,
|
|
104
|
+
strict: opts.strict,
|
|
105
|
+
// reported as smtp.remote-ip in strict mode (RFC 8617 section 6)
|
|
106
|
+
ip: opts.ip
|
|
99
107
|
});
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
let headers = [];
|
|
103
111
|
let arHeader = [];
|
|
112
|
+
let receivedSpfHeader = false;
|
|
104
113
|
|
|
105
114
|
dkimResult?.results?.forEach(row => {
|
|
106
115
|
arHeader.push(`${libmime.foldLines(row.info, 160)}`);
|
|
@@ -108,7 +117,7 @@ const authenticate = async (input, opts) => {
|
|
|
108
117
|
|
|
109
118
|
if (spfResult) {
|
|
110
119
|
arHeader.push(libmime.foldLines(spfResult.info, 160));
|
|
111
|
-
|
|
120
|
+
receivedSpfHeader = spfResult.header;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
if (arcResult?.info) {
|
|
@@ -116,10 +125,16 @@ const authenticate = async (input, opts) => {
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
let dmarcResult;
|
|
128
|
+
let dmarcSkipReason;
|
|
119
129
|
if (!opts.disableDmarc && dkimResult?.headerFrom) {
|
|
120
130
|
let dkimAligned;
|
|
121
|
-
({
|
|
131
|
+
({
|
|
132
|
+
response: dmarcResult,
|
|
133
|
+
dkimAligned,
|
|
134
|
+
reason: dmarcSkipReason
|
|
135
|
+
} = await evaluateDmarc({
|
|
122
136
|
headerFrom: dkimResult.headerFrom,
|
|
137
|
+
fromFields: dkimResult.fromFields,
|
|
123
138
|
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
|
|
124
139
|
dkimDomains: (dkimResult.results || [])
|
|
125
140
|
.filter(r => r.status.result === 'pass')
|
|
@@ -130,13 +145,14 @@ const authenticate = async (input, opts) => {
|
|
|
130
145
|
underSized: r.status.underSized
|
|
131
146
|
})),
|
|
132
147
|
arcResult,
|
|
133
|
-
resolver: opts.resolver
|
|
148
|
+
resolver: opts.resolver,
|
|
149
|
+
strict: opts.strict
|
|
134
150
|
}));
|
|
135
|
-
if (dmarcResult
|
|
151
|
+
if (dmarcResult?.info) {
|
|
136
152
|
arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
if (dkimAligned) {
|
|
155
|
+
if (dkimAligned && dmarcResult) {
|
|
140
156
|
// The DKIM verifier runs before the DMARC record and the Tree Walks are known, so its
|
|
141
157
|
// per signature flag only guesses relaxed alignment from the Public Suffix List.
|
|
142
158
|
// Passing signatures take the DMARC verdict, failing ones the record's alignment mode.
|
|
@@ -157,7 +173,8 @@ const authenticate = async (input, opts) => {
|
|
|
157
173
|
dmarc: dmarcResult,
|
|
158
174
|
headers: dkimResult.headers,
|
|
159
175
|
bimiWithAlignedDkim: opts.bimiWithAlignedDkim,
|
|
160
|
-
resolver: opts.resolver
|
|
176
|
+
resolver: opts.resolver,
|
|
177
|
+
strict: opts.strict
|
|
161
178
|
});
|
|
162
179
|
}
|
|
163
180
|
|
|
@@ -165,42 +182,57 @@ const authenticate = async (input, opts) => {
|
|
|
165
182
|
arHeader.push(`${libmime.foldLines(bimiResult.info, 160)}`);
|
|
166
183
|
}
|
|
167
184
|
|
|
168
|
-
|
|
185
|
+
const arValue = `${authservId};\r\n ` + arHeader.join(';\r\n ');
|
|
186
|
+
const authResultsHeader = `Authentication-Results: ${arValue}`;
|
|
187
|
+
|
|
188
|
+
if (opts.strict) {
|
|
189
|
+
// RFC 8601 section 5: Authentication-Results "SHOULD be inserted above any other
|
|
190
|
+
// trace header fields", and Received-SPF is one (RFC 7208 section 9.1)
|
|
191
|
+
headers.push(authResultsHeader);
|
|
192
|
+
if (receivedSpfHeader) {
|
|
193
|
+
headers.push(receivedSpfHeader);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
if (receivedSpfHeader) {
|
|
197
|
+
headers.push(receivedSpfHeader);
|
|
198
|
+
}
|
|
199
|
+
headers.push(authResultsHeader);
|
|
200
|
+
}
|
|
169
201
|
|
|
170
202
|
if (arcResult) {
|
|
171
|
-
arcResult.authResults =
|
|
203
|
+
arcResult.authResults = arValue;
|
|
172
204
|
}
|
|
173
205
|
|
|
174
206
|
// seal only messages with a valid ARC chain
|
|
175
207
|
if (dkimResult?.seal && (['none', 'pass'].includes(arcResult?.status?.result) || arcResult?.status?.shouldSeal)) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
i,
|
|
180
|
-
cv: arcResult.status.result,
|
|
181
|
-
authResults: arcResult.authResults,
|
|
182
|
-
signTime: new Date()
|
|
183
|
-
},
|
|
184
|
-
dkimResult.seal
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
// get ARC sealing headers to prepend to the message
|
|
208
|
+
// createSeal picks the instance: the next one after the highest on the message, even if
|
|
209
|
+
// some of the ARC headers were not part of a valid chain, and never a 51st set
|
|
188
210
|
let sealResult = await createSeal(false, {
|
|
189
211
|
headers: dkimResult.headers,
|
|
190
212
|
arc: dkimResult.arc,
|
|
191
|
-
seal
|
|
213
|
+
seal: Object.assign(
|
|
214
|
+
{
|
|
215
|
+
cv: arcResult.status.result,
|
|
216
|
+
authResults: arcResult.authResults,
|
|
217
|
+
signTime: new Date()
|
|
218
|
+
},
|
|
219
|
+
dkimResult.seal
|
|
220
|
+
),
|
|
221
|
+
strict: opts.strict
|
|
192
222
|
});
|
|
193
223
|
|
|
194
224
|
if (sealResult?.errors?.length) {
|
|
195
|
-
// sealing failed, usually an unusable key
|
|
196
|
-
// say why somewhere the caller can find it rather
|
|
225
|
+
// sealing failed, usually an unusable key or a chain that is already full. The
|
|
226
|
+
// message goes out unsealed, so say why somewhere the caller can find it rather
|
|
227
|
+
// than dropping it silently
|
|
197
228
|
arcResult.sealErrors = sealResult.errors;
|
|
198
229
|
}
|
|
199
230
|
|
|
200
|
-
|
|
231
|
+
// the ARC headers go on top of the message
|
|
232
|
+
headers.unshift(...(sealResult?.headers || []));
|
|
201
233
|
}
|
|
202
234
|
|
|
203
|
-
|
|
235
|
+
const result = {
|
|
204
236
|
dkim: dkimResult,
|
|
205
237
|
spf: spfResult,
|
|
206
238
|
dmarc: dmarcResult || false,
|
|
@@ -209,6 +241,15 @@ const authenticate = async (input, opts) => {
|
|
|
209
241
|
receivedChain,
|
|
210
242
|
headers: headers.join('\r\n') + '\r\n'
|
|
211
243
|
};
|
|
244
|
+
|
|
245
|
+
if (!dmarcResult && dmarcSkipReason) {
|
|
246
|
+
// RFC 9989 section 5.3.1: DMARC can not be evaluated for a message with no Author
|
|
247
|
+
// Domain or with more than one, and section 11.5 suggests treating such a message as
|
|
248
|
+
// suspicious. `dmarc` stays false, this says why
|
|
249
|
+
result.dmarcSkipReason = dmarcSkipReason;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result;
|
|
212
253
|
};
|
|
213
254
|
|
|
214
255
|
module.exports = {
|
package/lib/mta-sts.d.ts
CHANGED
|
@@ -2,23 +2,86 @@
|
|
|
2
2
|
|
|
3
3
|
/// <reference types="node" />
|
|
4
4
|
|
|
5
|
-
import { MTASTSOptions, MTASTSPolicy, MTASTSPolicyResult, MTASTSValidationResult } from '../index';
|
|
5
|
+
import { MTASTSOptions as BaseMTASTSOptions, MTASTSPolicy, MTASTSPolicyResult as BaseMTASTSPolicyResult, MTASTSValidationResult } from '../index';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MTA-STS options
|
|
9
|
+
*/
|
|
10
|
+
export interface MTASTSOptions extends BaseMTASTSOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Apply the RFC 8461 rules exactly (default false).
|
|
13
|
+
*
|
|
14
|
+
* - The TXT record must match the RFC 8461 3.1 ABNF (case-sensitive `v=STSv1`, `id` of 1 to 32 letters and digits)
|
|
15
|
+
* - Only HTTP 200 is accepted and the Content-Type must be `text/plain`
|
|
16
|
+
* - Policy field names and the `mode` value are case-sensitive, `max_age` must be 1 to 10 digits and `mx` must be `["*."] Domain`
|
|
17
|
+
* - The Policy Host certificate must match through a DNS-ID subjectAltName, without CN fallback or partial-label wildcards
|
|
18
|
+
* - An expired cached policy is not applied when a fetch fails
|
|
19
|
+
*
|
|
20
|
+
* In the default lax mode these are accepted and reported in the `warnings` array of the `getPolicy` result.
|
|
21
|
+
*/
|
|
22
|
+
strict?: boolean;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Overall time limit for the HTTPS policy request in milliseconds (default 60000).
|
|
26
|
+
* The socket idle timeout is 15 seconds or this value, whichever is smaller.
|
|
27
|
+
*/
|
|
28
|
+
timeout?: number;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Maximum size of the policy file in bytes (default 65536). Larger responses fail with the `policy_too_large` error code.
|
|
32
|
+
*/
|
|
33
|
+
maxPolicySize?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Markers for input that the default lax mode accepted but `strict: true` would reject
|
|
38
|
+
*
|
|
39
|
+
* - `txt-syntax`: the TXT record does not match the RFC 8461 3.1 syntax
|
|
40
|
+
* - `http-status`: the policy was served with a 2xx status other than 200
|
|
41
|
+
* - `content-type`: the policy was not served as `text/plain`
|
|
42
|
+
* - `policy-syntax`: the policy file does not match the RFC 8461 3.2 syntax (field name or mode case, max_age format, mx pattern)
|
|
43
|
+
* - `cert-identity`: the Policy Host certificate matched only through the CN or a partial-label wildcard
|
|
44
|
+
* - `expired-cache`: an expired cached policy was returned because the policy could not be fetched
|
|
45
|
+
*/
|
|
46
|
+
export type MTASTSWarning = 'txt-syntax' | 'http-status' | 'content-type' | 'policy-syntax' | 'cert-identity' | 'expired-cache';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* MTA-STS policy fetch result
|
|
50
|
+
*/
|
|
51
|
+
export interface MTASTSPolicyResult extends BaseMTASTSPolicyResult {
|
|
52
|
+
/**
|
|
53
|
+
* Lax acceptance markers, only present when the default lax mode accepted something strict mode would reject
|
|
54
|
+
*/
|
|
55
|
+
warnings?: MTASTSWarning[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* MTA-STS MX validation options
|
|
60
|
+
*/
|
|
61
|
+
export interface MTASTSValidateOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Accepted for API consistency. The matching rules are the same in both modes.
|
|
64
|
+
*/
|
|
65
|
+
strict?: boolean;
|
|
66
|
+
}
|
|
6
67
|
|
|
7
68
|
/**
|
|
8
69
|
* Resolves MTA-STS policy ID from DNS
|
|
9
70
|
*
|
|
10
71
|
* @param address - Email address or domain name
|
|
11
72
|
* @param opts - MTA-STS options
|
|
12
|
-
* @returns Policy ID or false if not found
|
|
73
|
+
* @returns Policy ID or false if not found or not usable
|
|
74
|
+
* @throws Error on DNS errors other than NXDOMAIN/NODATA, or with code `multi_sts_records` for multiple records
|
|
13
75
|
*/
|
|
14
76
|
export function resolvePolicy(address: string, opts?: MTASTSOptions): Promise<string | false>;
|
|
15
77
|
|
|
16
78
|
/**
|
|
17
79
|
* Fetches and parses MTA-STS policy file from HTTPS
|
|
18
80
|
*
|
|
19
|
-
* @param domain - Domain name
|
|
81
|
+
* @param domain - Domain name or email address
|
|
20
82
|
* @param opts - MTA-STS options
|
|
21
|
-
* @returns Parsed policy or false if
|
|
83
|
+
* @returns Parsed policy or false if the policy host has no address records
|
|
84
|
+
* @throws Error if the policy can not be fetched or is invalid
|
|
22
85
|
*/
|
|
23
86
|
export function fetchPolicy(domain: string, opts?: MTASTSOptions): Promise<MTASTSPolicy | false>;
|
|
24
87
|
|
|
@@ -26,27 +89,30 @@ export function fetchPolicy(domain: string, opts?: MTASTSOptions): Promise<MTAST
|
|
|
26
89
|
* Parses MTA-STS policy file content
|
|
27
90
|
*
|
|
28
91
|
* @param file - Policy file content
|
|
92
|
+
* @param opts - Only `strict` is used
|
|
29
93
|
* @returns Parsed policy
|
|
30
94
|
* @throws Error if policy is invalid
|
|
31
95
|
*/
|
|
32
|
-
export function parsePolicy(file: Buffer | string): MTASTSPolicy;
|
|
96
|
+
export function parsePolicy(file: Buffer | string, opts?: Pick<MTASTSOptions, 'strict'>): MTASTSPolicy;
|
|
33
97
|
|
|
34
98
|
/**
|
|
35
|
-
* Validates MX hostname against MTA-STS policy
|
|
99
|
+
* Validates MX hostname against MTA-STS policy.
|
|
100
|
+
* This does not look at `policy.expires`, use `getPolicy` to refresh the policy.
|
|
36
101
|
*
|
|
37
102
|
* @param mx - MX hostname to validate
|
|
38
103
|
* @param policy - MTA-STS policy
|
|
104
|
+
* @param opts - Validation options
|
|
39
105
|
* @returns Validation result
|
|
40
106
|
*/
|
|
41
|
-
export function validateMx(mx: string, policy: MTASTSPolicy): MTASTSValidationResult;
|
|
107
|
+
export function validateMx(mx: string, policy: MTASTSPolicy, opts?: MTASTSValidateOptions): MTASTSValidationResult;
|
|
42
108
|
|
|
43
109
|
/**
|
|
44
110
|
* Gets complete MTA-STS policy for a domain
|
|
45
111
|
* Resolves DNS, fetches policy file, and handles caching
|
|
46
112
|
*
|
|
47
|
-
* @param domain - Domain name
|
|
48
|
-
* @param knownPolicy - Currently cached policy
|
|
113
|
+
* @param domain - Domain name or email address
|
|
114
|
+
* @param knownPolicy - Currently cached policy, the `policy` value of an earlier result
|
|
49
115
|
* @param opts - MTA-STS options
|
|
50
|
-
* @returns Policy fetch result
|
|
116
|
+
* @returns Policy fetch result, store its `policy` value as the new cached policy
|
|
51
117
|
*/
|
|
52
|
-
export function getPolicy(domain: string, knownPolicy?: MTASTSPolicy, opts?: MTASTSOptions): Promise<MTASTSPolicyResult>;
|
|
118
|
+
export function getPolicy(domain: string, knownPolicy?: MTASTSPolicy | null, opts?: MTASTSOptions): Promise<MTASTSPolicyResult>;
|