mailauth 6.0.0 → 7.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 +12 -0
- package/README.md +111 -39
- package/bin/mailauth.js +14 -6
- package/cli.md +8 -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 +495 -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 +2 -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 +574 -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 +140 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +75 -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');
|
|
@@ -31,18 +31,25 @@ const { isIP } = require('net');
|
|
|
31
31
|
* @param {Boolean} [opts.disableArc=false] If true then do not perform ARC validation and sealing
|
|
32
32
|
* @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
|
|
33
33
|
* @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
|
|
34
|
+
* @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
|
|
34
35
|
* @returns Authentication result
|
|
35
36
|
*/
|
|
36
37
|
const authenticate = async (input, opts) => {
|
|
37
38
|
opts = Object.assign({}, opts); // copy keys
|
|
38
39
|
|
|
39
40
|
opts.mta = opts.mta || os.hostname();
|
|
41
|
+
opts.strict = !!opts.strict;
|
|
42
|
+
|
|
43
|
+
// RFC 8601 section 2.5: the authserv-id, usually a host name. A value that is not a token is
|
|
44
|
+
// quoted, or throws under strict, before any work is done
|
|
45
|
+
const authservId = formatAuthservId(opts.mta, opts.strict);
|
|
40
46
|
|
|
41
47
|
const dkimResult = await dkimVerify(input, {
|
|
42
48
|
resolver: opts.resolver,
|
|
43
49
|
sender: opts.sender, // defaults to Return-Path header
|
|
44
50
|
seal: opts.seal,
|
|
45
|
-
minBitLength: opts.minBitLength
|
|
51
|
+
minBitLength: opts.minBitLength,
|
|
52
|
+
strict: opts.strict
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
const receivedChain = dkimResult.headers?.parsed.filter(r => r.key === 'received').map(row => parseReceived(row.line));
|
|
@@ -52,13 +59,9 @@ const authenticate = async (input, opts) => {
|
|
|
52
59
|
let rcvd = receivedChain?.find(row => row.from?.value);
|
|
53
60
|
if (rcvd) {
|
|
54
61
|
let helo = rcvd.from.value;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (ipMatch) {
|
|
59
|
-
ip = ipMatch[1].replace(/^IPv6:/i, '');
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
+
// the address of the connection from the TCP-info part of the from clause
|
|
63
|
+
// (RFC 5321 section 4.4), never an address the client claimed in its HELO
|
|
64
|
+
let ip = getClientAddress(rcvd.from);
|
|
62
65
|
|
|
63
66
|
if (ip && !opts.ip) {
|
|
64
67
|
opts.ip = ip;
|
|
@@ -95,12 +98,16 @@ const authenticate = async (input, opts) => {
|
|
|
95
98
|
if (!opts.disableArc) {
|
|
96
99
|
arcResult = await arc(dkimResult.arc, {
|
|
97
100
|
resolver: opts.resolver,
|
|
98
|
-
minBitLength: opts.minBitLength
|
|
101
|
+
minBitLength: opts.minBitLength,
|
|
102
|
+
strict: opts.strict,
|
|
103
|
+
// reported as smtp.remote-ip in strict mode (RFC 8617 section 6)
|
|
104
|
+
ip: opts.ip
|
|
99
105
|
});
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
let headers = [];
|
|
103
109
|
let arHeader = [];
|
|
110
|
+
let receivedSpfHeader = false;
|
|
104
111
|
|
|
105
112
|
dkimResult?.results?.forEach(row => {
|
|
106
113
|
arHeader.push(`${libmime.foldLines(row.info, 160)}`);
|
|
@@ -108,7 +115,7 @@ const authenticate = async (input, opts) => {
|
|
|
108
115
|
|
|
109
116
|
if (spfResult) {
|
|
110
117
|
arHeader.push(libmime.foldLines(spfResult.info, 160));
|
|
111
|
-
|
|
118
|
+
receivedSpfHeader = spfResult.header;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
if (arcResult?.info) {
|
|
@@ -116,10 +123,16 @@ const authenticate = async (input, opts) => {
|
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
let dmarcResult;
|
|
126
|
+
let dmarcSkipReason;
|
|
119
127
|
if (!opts.disableDmarc && dkimResult?.headerFrom) {
|
|
120
128
|
let dkimAligned;
|
|
121
|
-
({
|
|
129
|
+
({
|
|
130
|
+
response: dmarcResult,
|
|
131
|
+
dkimAligned,
|
|
132
|
+
reason: dmarcSkipReason
|
|
133
|
+
} = await evaluateDmarc({
|
|
122
134
|
headerFrom: dkimResult.headerFrom,
|
|
135
|
+
fromFields: dkimResult.fromFields,
|
|
123
136
|
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
|
|
124
137
|
dkimDomains: (dkimResult.results || [])
|
|
125
138
|
.filter(r => r.status.result === 'pass')
|
|
@@ -130,13 +143,14 @@ const authenticate = async (input, opts) => {
|
|
|
130
143
|
underSized: r.status.underSized
|
|
131
144
|
})),
|
|
132
145
|
arcResult,
|
|
133
|
-
resolver: opts.resolver
|
|
146
|
+
resolver: opts.resolver,
|
|
147
|
+
strict: opts.strict
|
|
134
148
|
}));
|
|
135
|
-
if (dmarcResult
|
|
149
|
+
if (dmarcResult?.info) {
|
|
136
150
|
arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
|
|
137
151
|
}
|
|
138
152
|
|
|
139
|
-
if (dkimAligned) {
|
|
153
|
+
if (dkimAligned && dmarcResult) {
|
|
140
154
|
// The DKIM verifier runs before the DMARC record and the Tree Walks are known, so its
|
|
141
155
|
// per signature flag only guesses relaxed alignment from the Public Suffix List.
|
|
142
156
|
// Passing signatures take the DMARC verdict, failing ones the record's alignment mode.
|
|
@@ -157,7 +171,8 @@ const authenticate = async (input, opts) => {
|
|
|
157
171
|
dmarc: dmarcResult,
|
|
158
172
|
headers: dkimResult.headers,
|
|
159
173
|
bimiWithAlignedDkim: opts.bimiWithAlignedDkim,
|
|
160
|
-
resolver: opts.resolver
|
|
174
|
+
resolver: opts.resolver,
|
|
175
|
+
strict: opts.strict
|
|
161
176
|
});
|
|
162
177
|
}
|
|
163
178
|
|
|
@@ -165,42 +180,57 @@ const authenticate = async (input, opts) => {
|
|
|
165
180
|
arHeader.push(`${libmime.foldLines(bimiResult.info, 160)}`);
|
|
166
181
|
}
|
|
167
182
|
|
|
168
|
-
|
|
183
|
+
const arValue = `${authservId};\r\n ` + arHeader.join(';\r\n ');
|
|
184
|
+
const authResultsHeader = `Authentication-Results: ${arValue}`;
|
|
185
|
+
|
|
186
|
+
if (opts.strict) {
|
|
187
|
+
// RFC 8601 section 5: Authentication-Results "SHOULD be inserted above any other
|
|
188
|
+
// trace header fields", and Received-SPF is one (RFC 7208 section 9.1)
|
|
189
|
+
headers.push(authResultsHeader);
|
|
190
|
+
if (receivedSpfHeader) {
|
|
191
|
+
headers.push(receivedSpfHeader);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
if (receivedSpfHeader) {
|
|
195
|
+
headers.push(receivedSpfHeader);
|
|
196
|
+
}
|
|
197
|
+
headers.push(authResultsHeader);
|
|
198
|
+
}
|
|
169
199
|
|
|
170
200
|
if (arcResult) {
|
|
171
|
-
arcResult.authResults =
|
|
201
|
+
arcResult.authResults = arValue;
|
|
172
202
|
}
|
|
173
203
|
|
|
174
204
|
// seal only messages with a valid ARC chain
|
|
175
205
|
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
|
|
206
|
+
// createSeal picks the instance: the next one after the highest on the message, even if
|
|
207
|
+
// some of the ARC headers were not part of a valid chain, and never a 51st set
|
|
188
208
|
let sealResult = await createSeal(false, {
|
|
189
209
|
headers: dkimResult.headers,
|
|
190
210
|
arc: dkimResult.arc,
|
|
191
|
-
seal
|
|
211
|
+
seal: Object.assign(
|
|
212
|
+
{
|
|
213
|
+
cv: arcResult.status.result,
|
|
214
|
+
authResults: arcResult.authResults,
|
|
215
|
+
signTime: new Date()
|
|
216
|
+
},
|
|
217
|
+
dkimResult.seal
|
|
218
|
+
),
|
|
219
|
+
strict: opts.strict
|
|
192
220
|
});
|
|
193
221
|
|
|
194
222
|
if (sealResult?.errors?.length) {
|
|
195
|
-
// sealing failed, usually an unusable key
|
|
196
|
-
// say why somewhere the caller can find it rather
|
|
223
|
+
// sealing failed, usually an unusable key or a chain that is already full. The
|
|
224
|
+
// message goes out unsealed, so say why somewhere the caller can find it rather
|
|
225
|
+
// than dropping it silently
|
|
197
226
|
arcResult.sealErrors = sealResult.errors;
|
|
198
227
|
}
|
|
199
228
|
|
|
200
|
-
|
|
229
|
+
// the ARC headers go on top of the message
|
|
230
|
+
headers.unshift(...(sealResult?.headers || []));
|
|
201
231
|
}
|
|
202
232
|
|
|
203
|
-
|
|
233
|
+
const result = {
|
|
204
234
|
dkim: dkimResult,
|
|
205
235
|
spf: spfResult,
|
|
206
236
|
dmarc: dmarcResult || false,
|
|
@@ -209,6 +239,15 @@ const authenticate = async (input, opts) => {
|
|
|
209
239
|
receivedChain,
|
|
210
240
|
headers: headers.join('\r\n') + '\r\n'
|
|
211
241
|
};
|
|
242
|
+
|
|
243
|
+
if (!dmarcResult && dmarcSkipReason) {
|
|
244
|
+
// RFC 9989 section 5.3.1: DMARC can not be evaluated for a message with no Author
|
|
245
|
+
// Domain or with more than one, and section 11.5 suggests treating such a message as
|
|
246
|
+
// suspicious. `dmarc` stays false, this says why
|
|
247
|
+
result.dmarcSkipReason = dmarcSkipReason;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return result;
|
|
212
251
|
};
|
|
213
252
|
|
|
214
253
|
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>;
|