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/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
- if (dmarc.status?.result !== 'pass') {
38
- response.status.result = 'skipped';
39
- response.status.comment = dmarc.status?.result === 'none' ? 'DMARC not enabled' : 'message failed DMARC';
40
- response.info = formatAuthHeaderRow('bimi', response.status);
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.policy === 'none' || (dmarc.policy === 'quarantine' && dmarc.pct && dmarc.pct < 100)) {
45
- response.status.result = 'skipped';
46
- response.status.comment = 'too lax DMARC policy';
47
- response.info = formatAuthHeaderRow('bimi', response.status);
48
- return response;
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
- response.status.result = 'skipped';
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
- response.status.result = 'skipped';
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
- response.status.result = 'skipped';
71
- response.status.comment = 'could not determine domain';
72
- response.info = formatAuthHeaderRow('bimi', response.status);
73
- return response;
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
- response.status.result = 'fail';
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
- response.status.result = 'fail';
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 (selector !== 'default' || authorDomain !== orgDomain) {
105
- bimiTags.push(`default._bimi.${orgDomain}`);
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
- response.status.result = 'temperr';
118
- response.status.comment = `failed to resolve ${d}`;
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
- if (txt?.length === 1 && Array.isArray(txt?.[0])) {
124
- record = txt[0]?.join('').trim();
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 (txt) {
130
- response.status.result = 'temperr';
131
- response.status.comment = `invalid BIMI response for ${d}`;
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
- response.status.result = 'none';
139
- response.info = formatAuthHeaderRow('bimi', response.status);
140
- return response;
228
+ return finish('none');
141
229
  }
142
230
 
143
- let recordData = parseDkimHeaders(`DNS: TXT;${record}`);
144
- if (recordData?.parsed?.v?.value?.toLowerCase() !== 'bimi1') {
145
- response.status.result = 'fail';
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
- if (!recordData?.parsed?.l?.value && !recordData?.parsed?.a?.value) {
152
- response.status.result = 'fail';
153
- response.status.comment = 'missing location value in dns record';
154
- response.info = formatAuthHeaderRow('bimi', response.status);
155
- return response;
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
- if (recordData?.parsed?.l?.value) {
159
- let locationValidation = httpsSchema.validate(recordData?.parsed?.l?.value);
160
- if (locationValidation.error) {
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
- if (recordData?.parsed?.a?.value) {
169
- let authorityValidation = httpsSchema.validate(recordData?.parsed?.a?.value);
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
- response.status.result = 'fail';
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
- if (recordData?.parsed?.l?.value) {
181
- response.location = recordData.parsed.l.value;
182
- }
263
+ response.location = location;
183
264
 
184
- if (recordData?.parsed?.a?.value) {
185
- response.authority = recordData.parsed.a.value;
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': recordData.parsed.a.value }; // VMC has not been actually checked here yet, so authority is none
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?.parsed?.p?.value) {
192
- response.preference = recordData.parsed.p.value;
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
 
@@ -25,7 +25,8 @@ const cmd = async argv => {
25
25
  }
26
26
 
27
27
  const opts = {
28
- trustReceived: true
28
+ trustReceived: true,
29
+ strict: !!argv.strict
29
30
  };
30
31
 
31
32
  if (argv.clientIp) {
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { Buffer } = require('node:buffer');
3
4
  const { authenticate } = require('../mailauth');
4
- const { sealMessage } = require('../arc');
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 sealHeaders = await sealMessage(gatherer, sealOpts);
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
- process.stdout.write(sealHeaders);
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
@@ -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) {
@@ -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];
@@ -174,9 +174,9 @@ class RelaxedHash {
174
174
 
175
175
  update(chunk, final) {
176
176
  this.byteLength += (chunk && chunk.length) || 0;
177
- if (this.maxSizeReached) {
178
- return;
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.bodyHashedBytes) {
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
  }
@@ -84,9 +84,7 @@ class SimpleHash {
84
84
  }
85
85
 
86
86
  this.byteLength += chunk.length;
87
- if (this.maxSizeReached) {
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