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.
Files changed (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +113 -39
  4. package/bin/mailauth.js +15 -6
  5. package/cli.md +9 -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 +508 -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 +3 -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 +577 -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 +154 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +77 -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');
@@ -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
- 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
- }
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
- headers.push(spfResult.header);
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
- ({ response: dmarcResult, dkimAligned } = await evaluateDmarc({
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.info) {
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
- headers.push(`Authentication-Results: ${opts.mta};\r\n ` + arHeader.join(';\r\n '));
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 = `${opts.mta};\r\n ` + arHeader.join(';\r\n ');
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
- 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
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. The message goes out unsealed, so
196
- // say why somewhere the caller can find it rather than dropping it silently
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
- sealResult?.headers?.reverse().forEach(header => headers.unshift(header));
231
+ // the ARC headers go on top of the message
232
+ headers.unshift(...(sealResult?.headers || []));
201
233
  }
202
234
 
203
- return {
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 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>;