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/bimi/index.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const dns = require('node:dns');
|
|
6
|
-
const { formatAuthHeaderRow, parseDkimHeaders, formatDomain, getAlignment } = require('../tools');
|
|
6
|
+
const { formatAuthHeaderRow, parseDkimHeaders, parseTagList, formatDomain, getAlignment } = require('../tools');
|
|
7
7
|
const Joi = require('joi');
|
|
8
8
|
const libmime = require('libmime');
|
|
9
|
+
const addressparser = require('nodemailer/lib/addressparser');
|
|
10
|
+
const getDmarcRecord = require('../dmarc/get-dmarc-record');
|
|
11
|
+
const { parseDmarcRecord } = getDmarcRecord;
|
|
9
12
|
//const packageData = require('../../package.json');
|
|
10
13
|
const httpsSchema = Joi.string().uri({
|
|
11
14
|
scheme: ['https']
|
|
@@ -21,8 +24,61 @@ const fetchAgent = new Agent({
|
|
|
21
24
|
const { vmc } = require('@postalsys/vmc');
|
|
22
25
|
const { validateSvg } = require('./validate-svg');
|
|
23
26
|
|
|
27
|
+
// Records that do not start with a "v=" tag for the current version are discarded (7.2 steps 4 and 7)
|
|
28
|
+
const BIMI_VERSION_RE = /^v[ \t]*=[ \t]*BIMI1[ \t]*(?:;|$)/i;
|
|
29
|
+
|
|
30
|
+
const POLICY_VALUES = new Set(['none', 'quarantine', 'reject']);
|
|
31
|
+
|
|
32
|
+
// Returns the reason a DMARC record does not meet the policy requirements of
|
|
33
|
+
// draft-brand-indicators-for-message-identification 7.1 steps 7 to 9, or false if it does
|
|
34
|
+
const checkDmarcRecord = record => {
|
|
35
|
+
// a record without a valid "p" is applied as p=none, or not at all (RFC 9989 4.10.1)
|
|
36
|
+
if (!POLICY_VALUES.has(record.p) || record.p === 'none') {
|
|
37
|
+
return 'too lax DMARC policy';
|
|
38
|
+
}
|
|
39
|
+
if (record.sp === 'none') {
|
|
40
|
+
return 'too lax DMARC subdomain policy';
|
|
41
|
+
}
|
|
42
|
+
if (record.p === 'quarantine' && record.pct !== undefined && record.pct !== 100) {
|
|
43
|
+
return 'DMARC policy applied to a percentage of messages';
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Parses a BIMI Assertion Record, which uses the DKIM tag-list syntax (4.3, RFC 6376 3.2). The
|
|
49
|
+
// values are the strings of the tag list: the parsed map of parseTagList would turn an empty "l="
|
|
50
|
+
// into the number 0. Tag names are case-folded and the last duplicate wins, as before.
|
|
51
|
+
const parseAssertionRecord = record => {
|
|
52
|
+
let tags = {};
|
|
53
|
+
for (let tag of parseTagList(record).tags) {
|
|
54
|
+
let name = tag.name.trim().toLowerCase();
|
|
55
|
+
if (name) {
|
|
56
|
+
tags[name] = tag.value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return tags;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Counts the addresses in the From header fields, including members of a group
|
|
63
|
+
const countFromAddresses = headerRows => {
|
|
64
|
+
let fromRows = headerRows.filter(row => row.key === 'from');
|
|
65
|
+
if (fromRows.length > 1) {
|
|
66
|
+
return fromRows.length;
|
|
67
|
+
}
|
|
68
|
+
let count = 0;
|
|
69
|
+
for (let row of fromRows) {
|
|
70
|
+
let value = (row.line || '').toString();
|
|
71
|
+
let splitterPos = value.indexOf(':');
|
|
72
|
+
let list = addressparser((splitterPos >= 0 ? value.substr(splitterPos + 1) : value).trim());
|
|
73
|
+
for (let entry of list) {
|
|
74
|
+
count += Array.isArray(entry.group) ? entry.group.length : 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return count;
|
|
78
|
+
};
|
|
79
|
+
|
|
24
80
|
const lookup = async data => {
|
|
25
|
-
let { dmarc, headers, resolver, bimiWithAlignedDkim } = data;
|
|
81
|
+
let { dmarc, headers, resolver, bimiWithAlignedDkim, strict } = data;
|
|
26
82
|
let headerRows = (headers && headers.parsed) || [];
|
|
27
83
|
|
|
28
84
|
resolver = resolver || dns.promises.resolve;
|
|
@@ -34,32 +90,36 @@ const lookup = async data => {
|
|
|
34
90
|
|
|
35
91
|
let response = { status: { header: {} } };
|
|
36
92
|
|
|
37
|
-
|
|
38
|
-
response.status.result =
|
|
39
|
-
|
|
40
|
-
|
|
93
|
+
const finish = (result, comment) => {
|
|
94
|
+
response.status.result = result;
|
|
95
|
+
if (comment) {
|
|
96
|
+
response.status.comment = comment;
|
|
97
|
+
}
|
|
98
|
+
response.info = formatAuthHeaderRow('bimi', response.status, { strict });
|
|
41
99
|
return response;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// 7.1 step 1: one From header field with one address. DMARC evaluates several addresses of the same domain.
|
|
103
|
+
let mailboxes = typeof dmarc.authorMailboxes === 'number' ? dmarc.authorMailboxes : 0;
|
|
104
|
+
if (mailboxes > 1 || countFromAddresses(headerRows) > 1) {
|
|
105
|
+
return finish('skipped', 'multiple From addresses');
|
|
42
106
|
}
|
|
43
107
|
|
|
44
|
-
if (dmarc.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
108
|
+
if (dmarc.status?.result !== 'pass') {
|
|
109
|
+
return finish('skipped', dmarc.status?.result === 'none' ? 'DMARC not enabled' : 'message failed DMARC');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// a domain testing its policy (t=y) is not enforcing it yet
|
|
113
|
+
if (dmarc.policy === 'none' || dmarc.testMode) {
|
|
114
|
+
return finish('skipped', 'too lax DMARC policy');
|
|
49
115
|
}
|
|
50
116
|
|
|
51
117
|
if (!dmarc.alignment?.dkim?.result && bimiWithAlignedDkim) {
|
|
52
|
-
|
|
53
|
-
response.status.comment = 'Aligned DKIM signature required';
|
|
54
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
55
|
-
return response;
|
|
118
|
+
return finish('skipped', 'Aligned DKIM signature required');
|
|
56
119
|
}
|
|
57
120
|
|
|
58
121
|
if (dmarc.alignment?.dkim?.underSized) {
|
|
59
|
-
|
|
60
|
-
response.status.comment = 'undersized DKIM signature';
|
|
61
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
62
|
-
return response;
|
|
122
|
+
return finish('skipped', 'undersized DKIM signature');
|
|
63
123
|
}
|
|
64
124
|
|
|
65
125
|
const authorDomain = dmarc.status?.header?.from;
|
|
@@ -67,10 +127,42 @@ const lookup = async data => {
|
|
|
67
127
|
|
|
68
128
|
if (!authorDomain || !orgDomain) {
|
|
69
129
|
// should this even happen?
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
130
|
+
return finish('skipped', 'could not determine domain');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 7.1 steps 7 to 9: neither the record for the Author Domain nor the one for the Author
|
|
134
|
+
// Organizational Domain may be p=none or sp=none, or apply quarantine to a percentage only.
|
|
135
|
+
// dmarc() provides both records, for a result built elsewhere they are looked up here.
|
|
136
|
+
let dmarcRecords = [];
|
|
137
|
+
let appliedRecord = dmarc.record || (dmarc.rr ? parseDmarcRecord(dmarc.rr) : null);
|
|
138
|
+
if (appliedRecord) {
|
|
139
|
+
dmarcRecords.push(appliedRecord);
|
|
140
|
+
}
|
|
141
|
+
let orgRecord = dmarc.orgRecord;
|
|
142
|
+
if (orgRecord === undefined) {
|
|
143
|
+
let appliedDomain = dmarc.policyDomain || dmarc.status?.header?.d || formatDomain(authorDomain);
|
|
144
|
+
if (formatDomain(orgDomain) !== formatDomain(appliedDomain) || !appliedRecord) {
|
|
145
|
+
try {
|
|
146
|
+
orgRecord = await getDmarcRecord(orgDomain, resolver);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
orgRecord = null;
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
orgRecord = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (orgRecord === null) {
|
|
155
|
+
// the Tree Walk that finds the Organizational Domain failed
|
|
156
|
+
return finish('temperror', 'failed to resolve the DMARC policy of the organizational domain');
|
|
157
|
+
}
|
|
158
|
+
if (orgRecord) {
|
|
159
|
+
dmarcRecords.push(orgRecord);
|
|
160
|
+
}
|
|
161
|
+
for (let record of dmarcRecords) {
|
|
162
|
+
let reason = checkDmarcRecord(record);
|
|
163
|
+
if (reason) {
|
|
164
|
+
return finish('skipped', reason);
|
|
165
|
+
}
|
|
74
166
|
}
|
|
75
167
|
|
|
76
168
|
let selector;
|
|
@@ -80,18 +172,12 @@ const lookup = async data => {
|
|
|
80
172
|
if (['bimi-selector'].includes(row.key)) {
|
|
81
173
|
if (bimiSelectorHeader) {
|
|
82
174
|
// already found one
|
|
83
|
-
|
|
84
|
-
response.status.comment = 'multiple BIMI-Selector headers';
|
|
85
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
86
|
-
return response;
|
|
175
|
+
return finish('fail', 'multiple BIMI-Selector headers');
|
|
87
176
|
}
|
|
88
177
|
|
|
89
178
|
bimiSelectorHeader = parseDkimHeaders(row.line);
|
|
90
179
|
if (bimiSelectorHeader?.parsed?.v?.value?.toLowerCase() !== 'bimi1') {
|
|
91
|
-
|
|
92
|
-
response.status.comment = 'missing bimi version in selector header';
|
|
93
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
94
|
-
return response;
|
|
180
|
+
return finish('fail', 'missing bimi version in selector header');
|
|
95
181
|
}
|
|
96
182
|
|
|
97
183
|
selector = bimiSelectorHeader?.parsed?.s?.value;
|
|
@@ -100,9 +186,11 @@ const lookup = async data => {
|
|
|
100
186
|
|
|
101
187
|
selector = selector?.trim() || 'default';
|
|
102
188
|
|
|
189
|
+
// 7.2 steps 3 and 6: the selector at the Author Domain, then the same selector at the
|
|
190
|
+
// Organizational Domain. A custom selector does not fall back to "default".
|
|
103
191
|
let bimiTags = [`${selector}._bimi.${authorDomain}`];
|
|
104
|
-
if (
|
|
105
|
-
bimiTags.push(
|
|
192
|
+
if (formatDomain(authorDomain) !== formatDomain(orgDomain)) {
|
|
193
|
+
bimiTags.push(`${selector}._bimi.${orgDomain}`);
|
|
106
194
|
}
|
|
107
195
|
|
|
108
196
|
let record;
|
|
@@ -114,85 +202,78 @@ const lookup = async data => {
|
|
|
114
202
|
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
|
115
203
|
continue;
|
|
116
204
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
120
|
-
return response;
|
|
205
|
+
// "temperror" is the result keyword of the draft (7.7), older versions used "temperr"
|
|
206
|
+
return finish('temperror', `failed to resolve ${d}`);
|
|
121
207
|
}
|
|
122
208
|
|
|
123
|
-
|
|
124
|
-
|
|
209
|
+
// 7.2 steps 4 and 7: other TXT records, such as site verification tokens, are discarded
|
|
210
|
+
let records = []
|
|
211
|
+
.concat(txt || [])
|
|
212
|
+
.map(row => (Array.isArray(row) ? row.join('') : String(row)).trim())
|
|
213
|
+
.filter(row => BIMI_VERSION_RE.test(row));
|
|
214
|
+
|
|
215
|
+
if (records.length === 1) {
|
|
216
|
+
record = records[0];
|
|
125
217
|
response.status.header.selector = d.split('._bimi.').shift();
|
|
126
218
|
response.status.header.d = d.split('._bimi.').pop();
|
|
127
219
|
response.rr = record;
|
|
128
220
|
break;
|
|
129
|
-
} else if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
133
|
-
return response;
|
|
221
|
+
} else if (records.length > 1) {
|
|
222
|
+
// 7.2 step 9: several records end the discovery
|
|
223
|
+
return finish('fail', `multiple BIMI records for ${d}`);
|
|
134
224
|
}
|
|
135
225
|
}
|
|
136
226
|
|
|
137
227
|
if (!record) {
|
|
138
|
-
|
|
139
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
140
|
-
return response;
|
|
228
|
+
return finish('none');
|
|
141
229
|
}
|
|
142
230
|
|
|
143
|
-
let recordData =
|
|
144
|
-
if (recordData
|
|
145
|
-
|
|
146
|
-
response.status.comment = 'missing bimi version in dns record';
|
|
147
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
148
|
-
return response;
|
|
231
|
+
let recordData = parseAssertionRecord(record);
|
|
232
|
+
if (recordData.v?.toLowerCase() !== 'bimi1') {
|
|
233
|
+
return finish('fail', 'missing bimi version in dns record');
|
|
149
234
|
}
|
|
150
235
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
236
|
+
let location = recordData.l;
|
|
237
|
+
let authority = recordData.a;
|
|
238
|
+
|
|
239
|
+
// 4.3.1 and 7.5: an empty l= with an empty or missing a= is a Declination to Publish
|
|
240
|
+
if (location === '' && !authority) {
|
|
241
|
+
return finish('declined');
|
|
156
242
|
}
|
|
157
243
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
response.status.result = 'fail';
|
|
162
|
-
response.status.comment = 'invalid location value in dns record';
|
|
163
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
164
|
-
return response;
|
|
165
|
-
}
|
|
244
|
+
// l= is required (4.3), and 7.3 step 1 needs a valid one even when a= is set
|
|
245
|
+
if (!location) {
|
|
246
|
+
return finish('fail', 'missing location value in dns record');
|
|
166
247
|
}
|
|
167
248
|
|
|
168
|
-
|
|
169
|
-
|
|
249
|
+
let locationValidation = httpsSchema.validate(location);
|
|
250
|
+
if (locationValidation.error) {
|
|
251
|
+
return finish('fail', 'invalid location value in dns record');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (authority) {
|
|
255
|
+
let authorityValidation = httpsSchema.validate(authority);
|
|
170
256
|
if (authorityValidation.error) {
|
|
171
|
-
|
|
172
|
-
response.status.comment = 'invalid authority value in dns record';
|
|
173
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
174
|
-
return response;
|
|
257
|
+
return finish('fail', 'invalid authority value in dns record');
|
|
175
258
|
}
|
|
176
259
|
}
|
|
177
260
|
|
|
178
261
|
response.status.result = 'pass';
|
|
179
262
|
|
|
180
|
-
|
|
181
|
-
response.location = recordData.parsed.l.value;
|
|
182
|
-
}
|
|
263
|
+
response.location = location;
|
|
183
264
|
|
|
184
|
-
if (
|
|
185
|
-
response.authority =
|
|
265
|
+
if (authority) {
|
|
266
|
+
response.authority = authority;
|
|
186
267
|
|
|
187
268
|
// Apple Mail requires additional policy header values in Authentication-Results header
|
|
188
|
-
response.status.policy = { authority: 'none', 'authority-uri':
|
|
269
|
+
response.status.policy = { authority: 'none', 'authority-uri': authority }; // VMC has not been actually checked here yet, so authority is none
|
|
189
270
|
}
|
|
190
271
|
|
|
191
|
-
if (recordData
|
|
192
|
-
response.preference = recordData.
|
|
272
|
+
if (recordData.p) {
|
|
273
|
+
response.preference = recordData.p;
|
|
193
274
|
}
|
|
194
275
|
|
|
195
|
-
response.info = formatAuthHeaderRow('bimi', response.status);
|
|
276
|
+
response.info = formatAuthHeaderRow('bimi', response.status, { strict });
|
|
196
277
|
return response;
|
|
197
278
|
};
|
|
198
279
|
|
package/lib/commands/report.js
CHANGED
package/lib/commands/seal.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { Buffer } = require('node:buffer');
|
|
3
4
|
const { authenticate } = require('../mailauth');
|
|
4
|
-
const {
|
|
5
|
+
const { createSeal } = require('../arc');
|
|
5
6
|
const fs = require('node:fs');
|
|
6
7
|
const { GathererStream } = require('../gatherer-stream');
|
|
7
8
|
const { resolve } = require('node:dns').promises;
|
|
@@ -67,7 +68,8 @@ const cmd = async argv => {
|
|
|
67
68
|
privateKey,
|
|
68
69
|
algorithm: argv.algo,
|
|
69
70
|
headerList: argv.headerFields,
|
|
70
|
-
signTime: argv.time ? new Date(argv.time * 1000) : new Date()
|
|
71
|
+
signTime: argv.time ? new Date(argv.time * 1000) : new Date(),
|
|
72
|
+
strict: !!argv.strict
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
if (argv.authResults || argv.authResultsFile) {
|
|
@@ -93,14 +95,36 @@ const cmd = async argv => {
|
|
|
93
95
|
console.error('--------');
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
let
|
|
98
|
+
let { headers, errors, warnings } = await createSeal(gatherer, { seal: sealOpts });
|
|
99
|
+
|
|
100
|
+
if (!headers.length) {
|
|
101
|
+
// nothing was sealed, say why instead of printing the message as if it had been
|
|
102
|
+
let err = errors?.[0]?.err || new Error('The message could not be sealed');
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
97
105
|
|
|
98
106
|
// createSeal writes the instance it actually used back into the seal options
|
|
99
107
|
if (sealOpts.i > 1 && !argv.cv) {
|
|
100
108
|
console.error(`warning: sealed with i=${sealOpts.i} and cv=none; a chain with i>1 only validates when cv=pass (use --cv pass)`);
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
|
|
111
|
+
for (let warning of warnings || []) {
|
|
112
|
+
switch (warning) {
|
|
113
|
+
case 'arc-instance-gap':
|
|
114
|
+
console.error(`warning: sealed with i=${sealOpts.i}, which does not follow the existing ARC chain`);
|
|
115
|
+
break;
|
|
116
|
+
case 'arc-cv-instance':
|
|
117
|
+
// covered by the message above for the default cv
|
|
118
|
+
if (argv.cv) {
|
|
119
|
+
console.error(`warning: cv=${argv.cv} is not valid for i=${sealOpts.i} (i=1 takes cv=none, later sets take cv=pass or cv=fail)`);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
console.error(`warning: ${warning}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
process.stdout.write(Buffer.from(headers.join('\r\n') + '\r\n'));
|
|
104
128
|
if (!argv.headersOnly) {
|
|
105
129
|
// print full message as well
|
|
106
130
|
await writeMessageBody(gatherer);
|
|
@@ -121,7 +145,8 @@ const cmd = async argv => {
|
|
|
121
145
|
|
|
122
146
|
const opts = {
|
|
123
147
|
trustReceived: true,
|
|
124
|
-
seal: signatureOpts
|
|
148
|
+
seal: signatureOpts,
|
|
149
|
+
strict: !!argv.strict
|
|
125
150
|
};
|
|
126
151
|
|
|
127
152
|
if (argv.clientIp) {
|
|
@@ -168,6 +193,11 @@ const cmd = async argv => {
|
|
|
168
193
|
|
|
169
194
|
let result = await authenticate(gatherer, opts);
|
|
170
195
|
|
|
196
|
+
for (let sealError of result.arc?.sealErrors || []) {
|
|
197
|
+
// the message is printed without a seal
|
|
198
|
+
console.error(`warning: the message was not sealed: ${sealError?.err?.message || sealError?.err}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
171
201
|
process.stdout.write(result.headers);
|
|
172
202
|
if (!argv.headersOnly) {
|
|
173
203
|
// print full message as well
|
package/lib/commands/sign.js
CHANGED
|
@@ -68,9 +68,16 @@ const cmd = async argv => {
|
|
|
68
68
|
|
|
69
69
|
let signResult = await dkimSign(gatherer, {
|
|
70
70
|
signTime,
|
|
71
|
-
signatureData: [signatureOpts]
|
|
71
|
+
signatureData: [signatureOpts],
|
|
72
|
+
strict: !!argv.strict
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
if (argv.verbose) {
|
|
76
|
+
for (let warning of signResult.warnings || []) {
|
|
77
|
+
console.error(`Signing warning: ${warning}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
if (signResult.errors?.length) {
|
|
75
82
|
if (argv.verbose) {
|
|
76
83
|
for (let error of signResult.errors) {
|
package/lib/commands/spf.js
CHANGED
|
@@ -32,6 +32,14 @@ const cmd = async argv => {
|
|
|
32
32
|
opts.maxVoidCount = argv.maxVoidLookups;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
if (argv.strict) {
|
|
36
|
+
opts.strict = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (argv.maxElapsedTime) {
|
|
40
|
+
opts.maxElapsedTime = Number(argv.maxElapsedTime);
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
for (let key of ['sender', 'helo', 'mta']) {
|
|
36
44
|
if (argv[key]) {
|
|
37
45
|
opts[key] = argv[key];
|
package/lib/dkim/body/relaxed.js
CHANGED
|
@@ -174,9 +174,9 @@ class RelaxedHash {
|
|
|
174
174
|
|
|
175
175
|
update(chunk, final) {
|
|
176
176
|
this.byteLength += (chunk && chunk.length) || 0;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
// The body is canonicalized to the end even once the l= limit has been reached.
|
|
178
|
+
// Nothing past the limit is hashed (_updateBodyHash stops that), but it is counted in
|
|
179
|
+
// canonicalizedLength, which is how the unsigned content after l= is reported
|
|
180
180
|
|
|
181
181
|
// Canonicalize content by applying a and b in order:
|
|
182
182
|
// a.1. Ignore all whitespace at the end of lines.
|
|
@@ -279,8 +279,9 @@ class RelaxedHash {
|
|
|
279
279
|
this._pushBodyHash(lineNeedsFixing || chunkPart[chunkPart.length - 1] === CHAR_SPACE ? this.fixLineBuffer(chunkPart) : chunkPart);
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
if (this.
|
|
283
|
-
// terminating line break for non-empty messages
|
|
282
|
+
if (this.canonicalizedLength) {
|
|
283
|
+
// terminating line break for non-empty messages. A body with content is non-empty
|
|
284
|
+
// even when l= keeps all of it out of the hash
|
|
284
285
|
this._updateBodyHash(CRLF);
|
|
285
286
|
}
|
|
286
287
|
}
|
package/lib/dkim/body/simple.js
CHANGED
|
@@ -84,9 +84,7 @@ class SimpleHash {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
this.byteLength += chunk.length;
|
|
87
|
-
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
87
|
+
// keeps reading past the l= limit, see RelaxedHash.update() for why
|
|
90
88
|
|
|
91
89
|
// Find the line endings at the end of the chunk. They may be the *CRLF that ends
|
|
92
90
|
// the body, which digest() collapses, and a trailing <CR> may still pair up with
|