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.
Files changed (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +111 -39
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +80 -33
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +495 -43
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +140 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +75 -36
  37. package/lib/mta-sts.d.ts +77 -11
  38. package/lib/mta-sts.js +497 -182
  39. package/lib/parse-dkim-headers.js +376 -191
  40. package/lib/parse-received.js +104 -32
  41. package/lib/spf/index.d.ts +37 -1
  42. package/lib/spf/index.js +260 -28
  43. package/lib/spf/macro.js +50 -13
  44. package/lib/spf/spf-verify.js +440 -204
  45. package/lib/spf/syntax.js +366 -0
  46. package/lib/tools.js +522 -116
  47. 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
- let ip;
56
- if (rcvd.from.comment) {
57
- let ipMatch = rcvd.from.comment.match(/\[([^\]]+)\]/);
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
- headers.push(spfResult.header);
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
- ({ response: dmarcResult, dkimAligned } = await evaluateDmarc({
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.info) {
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
- headers.push(`Authentication-Results: ${opts.mta};\r\n ` + arHeader.join(';\r\n '));
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 = `${opts.mta};\r\n ` + arHeader.join(';\r\n ');
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
- let i = arcResult.i + 1;
177
- let seal = Object.assign(
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. The message goes out unsealed, so
196
- // say why somewhere the caller can find it rather than dropping it silently
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
- sealResult?.headers?.reverse().forEach(header => headers.unshift(header));
229
+ // the ARC headers go on top of the message
230
+ headers.unshift(...(sealResult?.headers || []));
201
231
  }
202
232
 
203
- return {
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 not found
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>;