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.
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { formatSignatureHeaderLine, formatRelaxedLine, getCurTime } = require('../../../lib/tools');
4
+ const { formatSignatureHeaderLine, formatRelaxedLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
5
5
 
6
6
  // generate headers for signing
7
7
  const relaxedHeaders = (type, signingHeaderLines, options) => {
@@ -34,9 +34,11 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
34
34
  opts.i = instance;
35
35
  }
36
36
 
37
- if (signTime) {
38
- opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
39
- }
37
+ // The same opts object formats the line that gets signed here and, once the
38
+ // signature exists, the line that is emitted. Leaving t= out of it lets
39
+ // formatSignatureHeaderLine read the clock again for the second one, and a second
40
+ // boundary falling between the two reads signs one timestamp and publishes another
41
+ opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
40
42
 
41
43
  if (expires) {
42
44
  opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
@@ -55,15 +57,7 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
55
57
  );
56
58
  }
57
59
 
58
- chunks.push(
59
- Buffer.from(
60
- formatRelaxedLine(signatureHeaderLine)
61
- .toString('binary')
62
- // remove value from b= key
63
- .replace(/([;:\s]+b=)[^;]+/, '$1'),
64
- 'binary'
65
- )
66
- );
60
+ chunks.push(stripSignatureValue(formatRelaxedLine(signatureHeaderLine)));
67
61
 
68
62
  return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
69
63
  };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { formatSignatureHeaderLine, getCurTime } = require('../../../lib/tools');
4
+ const { formatSignatureHeaderLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
5
5
 
6
6
  const formatSimpleLine = (line, suffix) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary');
7
7
 
@@ -36,9 +36,11 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
36
36
  opts.i = instance;
37
37
  }
38
38
 
39
- if (signTime) {
40
- opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
41
- }
39
+ // The same opts object formats the line that gets signed here and, once the
40
+ // signature exists, the line that is emitted. Leaving t= out of it lets
41
+ // formatSignatureHeaderLine read the clock again for the second one, and a second
42
+ // boundary falling between the two reads signs one timestamp and publishes another
43
+ opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
42
44
 
43
45
  if (expires) {
44
46
  opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
@@ -57,15 +59,7 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
57
59
  );
58
60
  }
59
61
 
60
- chunks.push(
61
- Buffer.from(
62
- formatSimpleLine(signatureHeaderLine)
63
- .toString('binary')
64
- // remove value from b= key
65
- .replace(/([;:\s]+b=)[^;]+/, '$1'),
66
- 'binary'
67
- )
68
- );
62
+ chunks.push(stripSignatureValue(formatSimpleLine(signatureHeaderLine)));
69
63
 
70
64
  return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
71
65
  };
@@ -37,6 +37,17 @@ class MessageParser extends Writable {
37
37
  // Override in child class
38
38
  }
39
39
 
40
+ // Parses everything collected so far as the header block and hands it over. The body
41
+ // hash and the signatures are only ever built after this has run
42
+ async emitHeaders(chunk) {
43
+ this.state = 'body';
44
+ if (chunk) {
45
+ this.headerChunks.push(chunk);
46
+ }
47
+ this.headers = parseHeaders(Buffer.concat(this.headerChunks));
48
+ await this.messageHeaders(this.headers);
49
+ }
50
+
40
51
  async processChunk(chunk) {
41
52
  if (!chunk || !chunk.length) {
42
53
  return;
@@ -57,17 +68,12 @@ class MessageParser extends Writable {
57
68
 
58
69
  if (b0 === 0x0a && (b1 === 0x0a || (b1 === 0x0d && b2 === 0x0a))) {
59
70
  // found header ending
60
- this.state = 'body';
61
71
  if (i === chunk.length - 1) {
62
72
  //end of chunk
63
- this.headerChunks.push(chunk);
64
- this.headers = parseHeaders(Buffer.concat(this.headerChunks));
65
- await this.messageHeaders(this.headers);
73
+ await this.emitHeaders(chunk);
66
74
  return;
67
75
  }
68
- this.headerChunks.push(chunk.slice(0, i + 1));
69
- this.headers = parseHeaders(Buffer.concat(this.headerChunks));
70
- await this.messageHeaders(this.headers);
76
+ await this.emitHeaders(chunk.slice(0, i + 1));
71
77
  chunk = chunk.slice(i + 1);
72
78
  break;
73
79
  }
@@ -132,13 +138,16 @@ class MessageParser extends Writable {
132
138
  }
133
139
 
134
140
  async finish() {
141
+ if (!this.headers) {
142
+ // no body, so no empty line ended the header block. Hand the headers out
143
+ // before the final chunk, which is where signatures are checked or created.
144
+ // An input with no bytes at all goes through here too, so that the rest of
145
+ // the pipeline always has a header set to look at
146
+ await this.emitHeaders();
147
+ }
148
+
135
149
  // generate final hash and emit it
136
150
  await this.finalChunk();
137
-
138
- if (!this.headers && this.headerChunks.length) {
139
- this.headers = parseHeaders(Buffer.concat(this.headerChunks));
140
- await this.messageHeaders(this.headers);
141
- }
142
151
  }
143
152
 
144
153
  _final(callback) {
@@ -1,58 +1,38 @@
1
1
  'use strict';
2
2
 
3
3
  const dns = require('node:dns').promises;
4
- const tldts = require('tldts');
5
- const { TLDTS_OPTS } = require('../tools');
4
+ const net = require('node:net');
5
+ const { formatDomain } = require('../tools');
6
6
 
7
7
  // Tags whose values are ABNF literals in RFC 7489 6.4 / RFC 9989 4.7 and are therefore
8
8
  // case insensitive (RFC 5234 2.3). Values of other tags keep their case: "v" is defined
9
9
  // with explicit hex literals and is case sensitive, "rua"/"ruf" are URIs.
10
10
  const CASE_INSENSITIVE_TAGS = new Set(['p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rf', 't', 'psd']);
11
11
 
12
- // Returns every DMARC record published for a domain. The caller distinguishes an empty set,
13
- // which continues policy discovery at the organizational domain, from a multi record set,
14
- // which terminates it (RFC 7489 6.6.3 step 5).
15
12
  // The version tag must be the first tag and its value "DMARC1" must match precisely
16
13
  // (RFC 9989 4.7 "v"), while the ABNF allows *WSP around "=" and a case insensitive
17
14
  // tag name, hence [vV] instead of an /i flag that would also relax the value
18
15
  const DMARC_VERSION_RE = /^[vV][ \t]*=[ \t]*DMARC1[ \t]*(?:;|$)/;
19
16
 
20
- const resolveTxt = async (domain, resolver) => {
21
- try {
22
- let txt = await resolver(`_dmarc.${domain}`, 'TXT');
23
- return (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
24
- } catch (err) {
25
- if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
26
- return [];
27
- }
28
- throw err;
29
- }
30
- };
17
+ // Resolver errors that mean the name has no record, as opposed to a transient failure.
18
+ // EBADNAME is a name that can not exist in the DNS at all.
19
+ const NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'EBADNAME']);
31
20
 
32
- const getDmarcRecord = async (domain, resolver) => {
33
- resolver = resolver || dns.resolve;
21
+ // A Tree Walk shortens an author domain of eight or more labels straight to seven (RFC 9989 4.10 step 4)
22
+ const WALK_MAX_LABELS = 7;
34
23
 
35
- let records = await resolveTxt(domain, resolver);
36
- let isOrgRecord = false;
37
-
38
- if (!records.length) {
39
- let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
40
- // tldts returns null for IPs, single labels and other non-domains, and querying
41
- // "_dmarc.null" would coerce that into a hostname
42
- if (orgDomain && orgDomain !== domain) {
43
- // try org domain as well
44
- records = await resolveTxt(orgDomain, resolver);
45
- isOrgRecord = true;
46
- }
47
- }
48
-
49
- if (records.length !== 1) {
50
- // no record, or a multi record set that terminates policy discovery
51
- return false;
52
- }
24
+ // A domain literal or an IP address is not a DNS name and can not publish a DMARC record
25
+ const isAddress = domain => domain.startsWith('[') || net.isIP(domain) !== 0;
53
26
 
54
- let txt = records[0];
27
+ // Whether "_dmarc.<name>" can exist in the DNS: no empty labels, labels of at most 63 octets
28
+ // and a name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a
29
+ // resolver error that looks like a transient failure.
30
+ const isQueryable = name => {
31
+ let fqdn = `_dmarc.${name}`;
32
+ return fqdn.length <= 253 && fqdn.split('.').every(label => label.length && label.length <= 63);
33
+ };
55
34
 
35
+ const parseDmarcRecord = txt => {
56
36
  let parsed = Object.fromEntries(
57
37
  txt
58
38
  .split(';')
@@ -78,9 +58,150 @@ const getDmarcRecord = async (domain, resolver) => {
78
58
  );
79
59
 
80
60
  parsed.rr = txt;
81
- parsed.isOrgRecord = isOrgRecord;
82
61
 
83
62
  return parsed;
84
63
  };
85
64
 
65
+ // Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
66
+ // records counts as no record, it is discarded (RFC 9989 4.10 steps 2 and 6).
67
+ const fetchRecord = async (name, resolver) => {
68
+ if (!isQueryable(name)) {
69
+ return false;
70
+ }
71
+
72
+ let records;
73
+ try {
74
+ let txt = await resolver(`_dmarc.${name}`, 'TXT');
75
+ records = (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
76
+ } catch (err) {
77
+ if (NO_RECORD_CODES.has(err.code)) {
78
+ return false;
79
+ }
80
+ throw err;
81
+ }
82
+
83
+ return records.length === 1 ? parseDmarcRecord(records[0]) : false;
84
+ };
85
+
86
+ // Tree Walks for one message share most of their names, so each name is queried only once
87
+ const createRecordLookup = resolver => {
88
+ resolver = resolver || dns.resolve;
89
+ let cache = new Map();
90
+ return name => {
91
+ if (!cache.has(name)) {
92
+ cache.set(name, fetchRecord(name, resolver));
93
+ }
94
+ return cache.get(name);
95
+ };
96
+ };
97
+
98
+ // DNS Tree Walk (RFC 9989 4.10). Returns the records found, longest name first. The walk
99
+ // stops at a record with a psd=y or psd=n tag, or when no labels are left, so it makes
100
+ // at most eight queries. A DNS error ends the walk and is returned next to the records
101
+ // found before it, because a record found at the start domain is usable without the rest.
102
+ const treeWalk = async (domain, lookup) => {
103
+ let found = [];
104
+ let labels = domain.split('.');
105
+
106
+ while (labels.length) {
107
+ let name = labels.join('.');
108
+ let record;
109
+ try {
110
+ record = await lookup(name);
111
+ } catch (err) {
112
+ return { found, error: err };
113
+ }
114
+
115
+ if (record) {
116
+ found.push({ domain: name, record });
117
+ if (record.psd === 'y' || record.psd === 'n') {
118
+ break;
119
+ }
120
+ }
121
+
122
+ labels = labels.length > WALK_MAX_LABELS ? labels.slice(-WALK_MAX_LABELS) : labels.slice(1);
123
+ }
124
+
125
+ return { found };
126
+ };
127
+
128
+ // Organizational Domain selection for a completed Tree Walk (RFC 9989 4.10.2)
129
+ const selectOrgDomain = (domain, found) => {
130
+ for (let { domain: recordDomain, record } of found) {
131
+ if (record.psd === 'n') {
132
+ return recordDomain;
133
+ }
134
+ if (record.psd === 'y' && recordDomain !== domain) {
135
+ // the domain one label below the PSD
136
+ let labelCount = recordDomain.split('.').length + 1;
137
+ return domain.split('.').slice(-labelCount).join('.');
138
+ }
139
+ }
140
+
141
+ // the record with the fewest labels, or the start domain itself when nothing was found
142
+ return found.length ? found[found.length - 1].domain : domain;
143
+ };
144
+
145
+ // Returns the Organizational Domain of a normalized domain name. Throws on DNS errors.
146
+ const getOrgDomain = async (domain, lookup) => {
147
+ if (isAddress(domain)) {
148
+ return domain;
149
+ }
150
+ let { found, error } = await treeWalk(domain, lookup);
151
+ if (error) {
152
+ throw error;
153
+ }
154
+ return selectOrgDomain(domain, found);
155
+ };
156
+
157
+ // DMARC Policy Discovery (RFC 9989 4.10.1) for a normalized author domain. Returns
158
+ // { record, recordDomain, orgDomain, error }, where "record" is false when no policy applies.
159
+ // When the Tree Walk fails after a record was found at the author domain, "orgDomain" is null
160
+ // and "error" says why. Throws on DNS errors that leave the policy unknown.
161
+ const discoverPolicy = async (domain, lookup) => {
162
+ if (isAddress(domain)) {
163
+ return { record: false, recordDomain: null, orgDomain: domain };
164
+ }
165
+
166
+ let { found, error } = await treeWalk(domain, lookup);
167
+
168
+ if (found[0]?.domain === domain) {
169
+ // the author domain's own record applies regardless of what is above it
170
+ return { record: found[0].record, recordDomain: domain, orgDomain: error ? null : selectOrgDomain(domain, found), error };
171
+ }
172
+
173
+ if (error) {
174
+ throw error;
175
+ }
176
+
177
+ let orgDomain = selectOrgDomain(domain, found);
178
+
179
+ // otherwise the record of the Organizational Domain, and failing that the record of the PSD.
180
+ // A walk only stops at a psd tag, so a psd=y record can only be the last one found.
181
+ let last = found[found.length - 1];
182
+ let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
183
+
184
+ return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain };
185
+ };
186
+
187
+ // Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
188
+ const getDmarcRecord = async (domain, resolver) => {
189
+ domain = formatDomain(domain || '');
190
+
191
+ let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver));
192
+ if (!record) {
193
+ return false;
194
+ }
195
+
196
+ return Object.assign(record, {
197
+ // the record is inherited from the Organizational Domain or the PSD
198
+ isOrgRecord: recordDomain !== domain,
199
+ recordDomain,
200
+ orgDomain
201
+ });
202
+ };
203
+
86
204
  module.exports = getDmarcRecord;
205
+ module.exports.createRecordLookup = createRecordLookup;
206
+ module.exports.discoverPolicy = discoverPolicy;
207
+ module.exports.getOrgDomain = getOrgDomain;
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const verifyDmarc = require('./verify');
4
+ const { evaluateDmarc } = verifyDmarc;
4
5
 
5
6
  const dmarc = async opts => verifyDmarc(opts);
6
7
 
7
- module.exports = { dmarc };
8
+ module.exports = { dmarc, evaluateDmarc };
@@ -1,22 +1,22 @@
1
1
  'use strict';
2
2
 
3
- const dns = require('node:dns').promises;
4
3
  const punycode = require('punycode.js');
5
- const tldts = require('tldts');
6
- const { formatAuthHeaderRow, getAlignment, TLDTS_OPTS } = require('../tools');
7
- const getDmarcRecord = require('./get-dmarc-record');
4
+ const { formatAuthHeaderRow, formatDomain } = require('../tools');
5
+ const { createRecordLookup, discoverPolicy, getOrgDomain } = require('./get-dmarc-record');
8
6
 
9
- const verifyDmarc = async opts => {
10
- let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts;
7
+ // Every Tree Walk is capped at eight queries, but the number of walks is not (RFC 9989 4.10.2),
8
+ // so a message can only make this many identifiers walk for relaxed alignment. The rest do not align.
9
+ const MAX_ALIGNMENT_WALKS = 10;
11
10
 
12
- resolver = resolver || dns.resolve;
11
+ const evaluateDmarc = async opts => {
12
+ let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts;
13
13
 
14
14
  if (Array.isArray(headerFrom)) {
15
15
  if (headerFrom.length === 1) {
16
16
  headerFrom = headerFrom[0];
17
17
  } else {
18
18
  // invalid number of FROM addresses found
19
- return false;
19
+ return { response: false };
20
20
  }
21
21
  }
22
22
 
@@ -31,6 +31,9 @@ const verifyDmarc = async opts => {
31
31
  // ignore punycode conversion errors
32
32
  }
33
33
 
34
+ // the name to query and compare, "example.com." and "example.com" are the same domain
35
+ const authorDomain = formatDomain(domain);
36
+
34
37
  let formatResponse = response => {
35
38
  response.info = formatAuthHeaderRow('dmarc', response.status);
36
39
 
@@ -41,8 +44,6 @@ const verifyDmarc = async opts => {
41
44
  return response;
42
45
  };
43
46
 
44
- let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
45
-
46
47
  let status = {
47
48
  result: 'neutral',
48
49
  comment: false,
@@ -54,24 +55,28 @@ const verifyDmarc = async opts => {
54
55
  }
55
56
  };
56
57
 
57
- let dmarcRecord;
58
+ const lookup = createRecordLookup(resolver);
59
+
60
+ let policyDiscovery;
58
61
  try {
59
- dmarcRecord = await getDmarcRecord(domain, resolver);
62
+ policyDiscovery = await discoverPolicy(authorDomain, lookup);
60
63
  } catch (err) {
61
64
  // temperror?
62
65
  status.result = 'temperror';
63
- return formatResponse({ status, domain: orgDomain || domain, error: err.message });
66
+ return { response: formatResponse({ status, domain: authorDomain, error: err.message }) };
64
67
  }
65
68
 
69
+ let { record: dmarcRecord, recordDomain, orgDomain } = policyDiscovery;
70
+
66
71
  if (!dmarcRecord) {
67
72
  // nothing to do here
68
73
  // none
69
74
  status.result = 'none';
70
- return formatResponse({ status, domain: orgDomain || domain });
75
+ return { response: formatResponse({ status, domain: orgDomain }) };
71
76
  }
72
77
 
73
78
  // the domain the applied policy record was published at
74
- status.header.d = dmarcRecord.isOrgRecord ? orgDomain : domain;
79
+ status.header.d = recordDomain;
75
80
 
76
81
  status.comment = []
77
82
  .concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
@@ -79,31 +84,84 @@ const verifyDmarc = async opts => {
79
84
  .concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : [])
80
85
  .join(' ');
81
86
 
82
- // use "sp" if this is a subdomain of an org domain and "sp" is set, otherwise use "p"
83
- const policy = dmarcRecord.isOrgRecord && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
87
+ // use "sp" if the record was inherited by a subdomain and "sp" is set, otherwise use "p"
88
+ const policy = recordDomain !== authorDomain && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
84
89
 
85
90
  const adkimStrict = dmarcRecord.adkim === 's';
86
91
  const aspfStrict = dmarcRecord.aspf === 's';
87
92
 
88
- const dkimAlignment = getAlignment(domain, dkimDomains, adkimStrict);
89
- const spfAlignment = getAlignment(domain, spfDomains, aspfStrict);
93
+ // Relaxed alignment compares Organizational Domains found by Tree Walks (RFC 9989 4.10.2).
94
+ // An identifier's Organizational Domain is always the identifier itself or one of its
95
+ // parents, so only identifiers at or below the author's Organizational Domain need a walk.
96
+ let orgDomains = new Map();
97
+ // throws when a Tree Walk the answer depends on failed
98
+ const alignsRelaxed = async entryDomain => {
99
+ if (entryDomain === authorDomain) {
100
+ return true;
101
+ }
102
+ if (!orgDomain) {
103
+ // the author's own Tree Walk failed, so there is nothing to compare against
104
+ throw policyDiscovery.error;
105
+ }
106
+ if (entryDomain !== orgDomain && !entryDomain.endsWith(`.${orgDomain}`)) {
107
+ return false;
108
+ }
109
+ if (!orgDomains.has(entryDomain)) {
110
+ if (orgDomains.size >= MAX_ALIGNMENT_WALKS) {
111
+ return false;
112
+ }
113
+ orgDomains.set(entryDomain, getOrgDomain(entryDomain, lookup));
114
+ }
115
+ return (await orgDomains.get(entryDomain)) === orgDomain;
116
+ };
117
+
118
+ // Returns every entry that aligns, most preferred first, and the first walk error seen.
119
+ // Signatures with an l= tag come last, so that a signature covering the whole body is
120
+ // reported when several align.
121
+ const findAligned = async (entries, strict) => {
122
+ let list = []
123
+ .concat(entries || [])
124
+ .map(entry => (typeof entry === 'string' ? { domain: entry } : entry))
125
+ .filter(entry => entry && typeof entry.domain === 'string')
126
+ .sort((a, b) => (a.underSized || 0) - (b.underSized || 0));
127
+
128
+ let aligned = [];
129
+ let error = null;
130
+ for (let entry of list) {
131
+ let entryDomain = formatDomain(entry.domain);
132
+ if (!authorDomain || !entryDomain) {
133
+ // a bare root label normalizes to an empty string, which must not align with anything
134
+ continue;
135
+ }
136
+ try {
137
+ if (strict ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
138
+ aligned.push(entry);
139
+ }
140
+ } catch (err) {
141
+ error = error || err;
142
+ }
143
+ }
144
+
145
+ return { aligned, error };
146
+ };
147
+
148
+ // SPF first, so a flood of DKIM signatures can not use up the walks it needs
149
+ const spf = await findAligned(spfDomains, aspfStrict);
150
+ // every signature is checked, so that each one's own alignment can be reported
151
+ const dkim = await findAligned(dkimDomains, adkimStrict);
152
+ const spfAlignment = spf.aligned[0];
153
+ const dkimAlignment = dkim.aligned[0];
154
+ const walkError = spf.error || dkim.error;
90
155
 
91
156
  // "underSized" warns that a passing DKIM signature covers only part of the body (l= tag).
92
157
  // It describes the signature rather than the alignment mode, so it is still reported when
93
- // strict alignment rejected a signature that aligns at the org level.
94
- const underSized = (adkimStrict && !dkimAlignment ? getAlignment(domain, dkimDomains, false) : dkimAlignment)?.underSized;
95
-
96
- if (dkimAlignment || spfAlignment) {
97
- // pass
98
- status.result = 'pass';
99
- } else {
100
- // fail
101
- status.result = 'fail';
102
- }
158
+ // strict alignment rejected a signature that aligns at the org level. A walk that fails
159
+ // for this warning alone does not affect the result.
160
+ const underSized = adkimStrict && !dkimAlignment ? (await findAligned(dkimDomains, false)).aligned[0]?.underSized : dkimAlignment?.underSized;
103
161
 
104
- return formatResponse({
162
+ let response = {
105
163
  status,
106
- domain: orgDomain || domain,
164
+ domain: orgDomain || authorDomain,
107
165
  policy,
108
166
  p: dmarcRecord.p,
109
167
  sp: dmarcRecord.sp || dmarcRecord.p,
@@ -114,7 +172,25 @@ const verifyDmarc = async opts => {
114
172
  spf: { result: spfAlignment?.domain, strict: aspfStrict },
115
173
  dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
116
174
  }
117
- });
175
+ };
176
+
177
+ if (dkimAlignment || spfAlignment) {
178
+ // pass
179
+ status.result = 'pass';
180
+ } else if (walkError) {
181
+ // an identifier might have aligned if its Tree Walk had not failed
182
+ status.result = 'temperror';
183
+ response.error = walkError.message;
184
+ } else {
185
+ // fail
186
+ status.result = 'fail';
187
+ }
188
+
189
+ return { response: formatResponse(response), dkimAligned: new Set(dkim.aligned.map(entry => formatDomain(entry.domain))) };
118
190
  };
119
191
 
192
+ const verifyDmarc = async opts => (await evaluateDmarc(opts)).response;
193
+
120
194
  module.exports = verifyDmarc;
195
+ // also returns the signing domains that aligned, for the per signature flags of authenticate()
196
+ module.exports.evaluateDmarc = evaluateDmarc;
package/lib/mailauth.js CHANGED
@@ -3,12 +3,12 @@
3
3
  const { dkimSign, DkimSignStream } = require('./dkim/sign');
4
4
  const { dkimVerify } = require('./dkim/verify');
5
5
  const { spf } = require('./spf');
6
- const { dmarc } = require('./dmarc');
6
+ 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
10
  const { parseReceived } = require('./parse-received');
11
- const { getAlignment } = require('./tools');
11
+ const { formatDomain, getAlignment } = require('./tools');
12
12
  const libmime = require('libmime');
13
13
  const os = require('node:os');
14
14
  const { isIP } = require('net');
@@ -117,7 +117,8 @@ const authenticate = async (input, opts) => {
117
117
 
118
118
  let dmarcResult;
119
119
  if (!opts.disableDmarc && dkimResult?.headerFrom) {
120
- dmarcResult = await dmarc({
120
+ let dkimAligned;
121
+ ({ response: dmarcResult, dkimAligned } = await evaluateDmarc({
121
122
  headerFrom: dkimResult.headerFrom,
122
123
  spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
123
124
  dkimDomains: (dkimResult.results || [])
@@ -130,16 +131,22 @@ const authenticate = async (input, opts) => {
130
131
  })),
131
132
  arcResult,
132
133
  resolver: opts.resolver
133
- });
134
+ }));
134
135
  if (dmarcResult.info) {
135
136
  arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
136
137
  }
137
138
 
138
- if (dmarcResult.alignment?.dkim?.strict) {
139
- // the DKIM verifier only knows relaxed alignment, it runs before the DMARC record
139
+ if (dkimAligned) {
140
+ // The DKIM verifier runs before the DMARC record and the Tree Walks are known, so its
141
+ // per signature flag only guesses relaxed alignment from the Public Suffix List.
142
+ // Passing signatures take the DMARC verdict, failing ones the record's alignment mode.
140
143
  let fromDomain = dmarcResult.status.header.from;
141
144
  for (let row of dkimResult.results || []) {
142
- row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
145
+ if (row.status.result === 'pass') {
146
+ row.status.aligned = dkimAligned.has(formatDomain(row.signingDomain)) ? row.signingDomain : false;
147
+ } else if (dmarcResult.alignment.dkim.strict) {
148
+ row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
149
+ }
143
150
  }
144
151
  }
145
152
  }
@@ -184,6 +191,12 @@ const authenticate = async (input, opts) => {
184
191
  seal
185
192
  });
186
193
 
194
+ 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
197
+ arcResult.sealErrors = sealResult.errors;
198
+ }
199
+
187
200
  sealResult?.headers?.reverse().forEach(header => headers.unshift(header));
188
201
  }
189
202