mailauth 2.3.3 → 3.0.1

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/README.md CHANGED
@@ -327,15 +327,6 @@ Some example authority evidence documents:
327
327
  - [from default.\_bimi.cnn.com](https://amplify.valimail.com/bimi/time-warner/LysAFUdG-Hw-cnn_vmc.pem)
328
328
  - [from default.\_bimi.entrustdatacard.com](https://www.entrustdatacard.com/-/media/certificate/Entrust%20VMC%20July%2014%202020.pem)
329
329
 
330
- You can parse logos from these certificate files using the `parseLogoFromX509` function.
331
-
332
- ```js
333
- const { parseLogoFromX509 } = require('mailauth/lib/tools');
334
- let { altnNames, svg } = await parseLogoFromX509(fs.readFileSync('vmc.pem'));
335
- ```
336
-
337
- > **NB!** `parseLogoFromX509` does not verify the validity of the VMC certificate. It could be self-signed or expired and still be processed.
338
-
339
330
  ## MTA-STS
340
331
 
341
332
  `mailauth` allows you to fetch MTA-STS information for a domain name.
package/bin/mailauth.js CHANGED
@@ -6,10 +6,13 @@ const yargs = require('yargs/yargs');
6
6
  const { hideBin } = require('yargs/helpers');
7
7
  const os = require('os');
8
8
  const assert = require('assert');
9
+
9
10
  const commandReport = require('../lib/commands/report');
10
11
  const commandSign = require('../lib/commands/sign');
11
12
  const commandSeal = require('../lib/commands/seal');
12
13
  const commandSpf = require('../lib/commands/spf');
14
+ const commandVmc = require('../lib/commands/vmc');
15
+
13
16
  const fs = require('fs');
14
17
  const pathlib = require('path');
15
18
 
@@ -287,6 +290,42 @@ const argv = yargs(hideBin(process.argv))
287
290
  });
288
291
  }
289
292
  )
293
+ .command(
294
+ ['vmc'],
295
+ 'Validate VMC logo',
296
+ yargs => {
297
+ yargs
298
+ .option('authorityPath', {
299
+ alias: 'p',
300
+ type: 'string',
301
+ description: 'Path to a VMC file',
302
+ demandOption: false
303
+ })
304
+ .option('authority', {
305
+ alias: 'a',
306
+ type: 'string',
307
+ description: 'URL to a VMC file',
308
+ demandOption: false
309
+ })
310
+ .option('domain', {
311
+ alias: 'd',
312
+ type: 'string',
313
+ description: 'Sending domain to validate',
314
+ demandOption: false
315
+ });
316
+ },
317
+ argv => {
318
+ commandVmc(argv)
319
+ .then(() => {
320
+ process.exit();
321
+ })
322
+ .catch(err => {
323
+ console.error('Failed to verify VMC file');
324
+ console.error(err);
325
+ process.exit(1);
326
+ });
327
+ }
328
+ )
290
329
  .command(
291
330
  ['license'],
292
331
  'Show license information',
package/cli.md CHANGED
@@ -1,3 +1,7 @@
1
+ ![](https://github.com/postalsys/mailauth/raw/master/assets/mailauth.png)
2
+
3
+ Command line utility and a [Node.js library](README.md) for email authentication.
4
+
1
5
  # CLI USAGE
2
6
 
3
7
  ## TOC
@@ -9,6 +13,7 @@
9
13
  - [sign](#sign) - to sign an email with DKIM
10
14
  - [seal](#seal) - to seal an email with ARC
11
15
  - [spf](#spf) - to validate SPF for an IP address and an email address
16
+ - [vmc](#vmc) - to validate BIMI VMC logo files
12
17
  - [license](#license) - display licenses for `mailauth` and included modules
13
18
  - [DNS cache file](#dns-cache-file)
14
19
 
@@ -208,6 +213,88 @@ DNS query for A mail.wildduck.email: ["217.146.76.20"]
208
213
  ...
209
214
  ```
210
215
 
216
+ ### vmc
217
+
218
+ `vmc` command takes either the URL for a VMC file or a file path or both. It then verifies if the VMC resource is a valid file or not and exposes its contents.
219
+
220
+ ```
221
+ $ mailauth vmc [options]
222
+ ```
223
+
224
+ Where
225
+
226
+ - **options** are option flags and arguments
227
+
228
+ **Options**
229
+
230
+ - `--authority <url>` or `-a <url>` is the URL for the VMC resource
231
+ - `--authorityPath <path>` or `-p <path>` is the cached file for the authority URL to avoid network requests
232
+ - `--domain <domain>` or `-d <domain>` is the sender domain to compare the certificate against
233
+
234
+ **Example**
235
+
236
+ ```
237
+ $ mailauth vmc -a https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.pem -d cnn.com
238
+ {
239
+ "url": "https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.pem",
240
+ "success": true,
241
+ "domainVerified": true,
242
+ "vmc": {
243
+ "mediaType": "image/svg+xml",
244
+ "hashAlgo": "sha1",
245
+ "hashValue": "ea8c81da633c66a16262134a78576cdf067638e9",
246
+ "logoFile": "<2300B base64 encoded file>",
247
+ "validHash": true,
248
+ "certificate": {
249
+ "subjectAltName": [
250
+ "cnn.com"
251
+ ],
252
+ "subject": {
253
+ "businessCategory": "Private Organization",
254
+ "jurisdictionCountryName": "US",
255
+ "jurisdictionStateOrProvinceName": "Delaware",
256
+ "serialNumber": "2976730",
257
+ "countryName": "US",
258
+ "stateOrProvinceName": "Georgia",
259
+ "localityName": "Atlanta",
260
+ "street": "190 Marietta St NW",
261
+ "organizationName": "Cable News Network, Inc.",
262
+ "commonName": "Cable News Network, Inc.",
263
+ "trademarkCountryOrRegionName": "US",
264
+ "trademarkRegistration": "5817930"
265
+ },
266
+ "fingerprint": "17:B3:94:97:E6:6B:C8:6B:33:B8:0A:D2:F0:79:6B:08:A2:A6:84:BD",
267
+ "serialNumber": "0821B8FE0A9CBC3BAC10DA08C088EEF4",
268
+ "issuer": {
269
+ "countryName": "US",
270
+ "organizationName": "DigiCert, Inc.",
271
+ "commonName": "DigiCert Verified Mark RSA4096 SHA256 2021 CA1"
272
+ }
273
+ }
274
+ }
275
+ }
276
+ ```
277
+
278
+ If the certificate verification fails, then the contents are not returned.
279
+
280
+ ```
281
+ $ mailauth vmc -p /path/to/random/cert-bundle.pem
282
+ {
283
+ "success": false,
284
+ "error": {
285
+ "message": "Self signed certificate in certificate chain",
286
+ "details": {
287
+ "subject": "CN=postal.vmc.local\nO=Postal Systems OU.\nC=EE",
288
+ "fingerprint": "CC:49:83:ED:3F:6B:77:45:5B:A5:3B:9E:EC:99:0E:A1:EF:D7:FF:97",
289
+ "fingerprint235": "D4:36:6F:B4:EF:2B:4F:9E:84:23:3D:F2:3A:F7:13:21:C6:C3:CF:CB:03:5F:BB:54:5B:69:A4:AC:6A:43:61:7D",
290
+ "validFrom": "Jul 9 06:13:33 2022 GMT",
291
+ "validTo": "Jul 9 06:13:33 2023 GMT"
292
+ },
293
+ "code": "SELF_SIGNED_CERT_IN_CHAIN"
294
+ }
295
+ }
296
+ ```
297
+
211
298
  ### license
212
299
 
213
300
  Display licenses for `mailauth` and included modules.
package/lib/bimi/index.js CHANGED
@@ -1,12 +1,18 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const dns = require('dns');
4
- const { formatAuthHeaderRow, parseDkimHeaders } = require('../tools');
5
+ const { formatAuthHeaderRow, parseDkimHeaders, formatDomain, getAlignment } = require('../tools');
5
6
  const Joi = require('joi');
7
+ const packageData = require('../../package.json');
6
8
  const httpsSchema = Joi.string().uri({
7
9
  scheme: ['https']
8
10
  });
9
11
 
12
+ const https = require('https');
13
+ const http = require('http');
14
+ const { vmc } = require('@postalsys/vmc');
15
+
10
16
  const lookup = async data => {
11
17
  let { dmarc, headers, resolver } = data;
12
18
  let headerRows = (headers && headers.parsed) || [];
@@ -161,4 +167,223 @@ const lookup = async data => {
161
167
  return response;
162
168
  };
163
169
 
164
- module.exports = { bimi: lookup };
170
+ const downloadPromise = (url, cachedFile) => {
171
+ if (cachedFile) {
172
+ return cachedFile;
173
+ }
174
+
175
+ if (!url) {
176
+ return false;
177
+ }
178
+
179
+ const parsedUrl = new URL(url);
180
+
181
+ const options = {
182
+ protocol: parsedUrl.protocol,
183
+ host: parsedUrl.host,
184
+ headers: {
185
+ host: parsedUrl.host,
186
+ 'User-Agent': `mailauth/${packageData.version} (+${packageData.homepage}`
187
+ },
188
+ servername: parsedUrl.hostname,
189
+ port: 443,
190
+ path: parsedUrl.pathname,
191
+ method: 'GET',
192
+ rejectUnauthorized: true
193
+ };
194
+
195
+ return new Promise((resolve, reject) => {
196
+ let protoHandler;
197
+ switch (parsedUrl.protocol) {
198
+ case 'https:':
199
+ protoHandler = https;
200
+ break;
201
+ case 'http:':
202
+ protoHandler = http;
203
+ break;
204
+ default:
205
+ reject(new Error(`Unknown protocol ${parsedUrl.protocol}`));
206
+ }
207
+ const req = protoHandler.request(options, res => {
208
+ let chunks = [],
209
+ chunklen = 0;
210
+ res.on('readable', () => {
211
+ let chunk;
212
+ while ((chunk = res.read()) !== null) {
213
+ chunks.push(chunk);
214
+ chunklen += chunk.length;
215
+ }
216
+ });
217
+ res.on('end', () => {
218
+ let data = Buffer.concat(chunks, chunklen);
219
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
220
+ let err = new Error(`Invalid response code ${res.statusCode || '-'}`);
221
+ err.code = 'http_status_' + (res.statusCode || 'na');
222
+ if (res.headers.location && res.statusCode >= 300 && res.statusCode < 400) {
223
+ err.redirect = {
224
+ code: res.statusCode,
225
+ location: res.headers.location
226
+ };
227
+ }
228
+ return reject(err);
229
+ }
230
+ resolve(data);
231
+ });
232
+ res.on('error', err => reject(err));
233
+ });
234
+
235
+ req.on('error', err => {
236
+ reject(err);
237
+ });
238
+ req.end();
239
+ });
240
+ };
241
+
242
+ const validateVMC = async bimiData => {
243
+ if (!bimiData) {
244
+ return false;
245
+ }
246
+
247
+ let selector = bimiData?.status?.header?.selector;
248
+ let d = bimiData?.status?.header?.d;
249
+
250
+ let promises = [];
251
+
252
+ promises.push(downloadPromise(bimiData.location, bimiData.locationPath));
253
+ promises.push(downloadPromise(bimiData.authority, bimiData.authorityPath));
254
+
255
+ if (!promises.length) {
256
+ return false;
257
+ }
258
+
259
+ let [{ reason: locationError, value: locationValue, status: locationStatus }, { reason: authorityError, value: authorityValue, status: authorityStatus }] =
260
+ await Promise.allSettled(promises);
261
+
262
+ let result = {};
263
+ if (locationValue || locationError) {
264
+ result.location = {
265
+ url: bimiData.location,
266
+ success: locationStatus === 'fulfilled'
267
+ };
268
+
269
+ if (locationError) {
270
+ let err = locationError;
271
+ result.location.error = { message: err.message };
272
+ if (err.redirect) {
273
+ result.location.error.redirect = err.redirect;
274
+ }
275
+ if (err.code) {
276
+ result.location.error.code = err.code;
277
+ }
278
+ }
279
+
280
+ if (result.location.success) {
281
+ result.location.logoFile = locationValue.toString('base64');
282
+ }
283
+ }
284
+
285
+ if (authorityValue || authorityError) {
286
+ result.authority = {
287
+ url: bimiData.authority,
288
+ success: authorityStatus === 'fulfilled'
289
+ };
290
+
291
+ if (authorityError) {
292
+ let err = authorityError;
293
+ result.authority.error = { message: err.message };
294
+ if (err.redirect) {
295
+ result.authority.error.redirect = err.redirect;
296
+ }
297
+ if (err.code) {
298
+ result.authority.error.code = err.code;
299
+ }
300
+ }
301
+
302
+ if (authorityValue) {
303
+ try {
304
+ let vmcData = await vmc(authorityValue);
305
+
306
+ if (vmcData?.mediaType?.toLowerCase() !== 'image/svg+xml') {
307
+ let error = new Error('Invalid media type for the logo file');
308
+ error.details = {
309
+ mediaType: vmcData.mediaType
310
+ };
311
+ error.code = 'INVALID_MEDIATYPE';
312
+ throw error;
313
+ }
314
+
315
+ if (d) {
316
+ // validate domain
317
+ let selectorSet = [];
318
+ let domainSet = [];
319
+ vmcData?.certificate?.subjectAltName?.map(formatDomain)?.forEach(domain => {
320
+ if (/\b_bimi\./.test(domain)) {
321
+ selectorSet.push(domain);
322
+ } else {
323
+ domainSet.push(domain);
324
+ }
325
+ });
326
+
327
+ let domainVerified = false;
328
+
329
+ if (selector && selectorSet.includes(formatDomain(`${selector}._bimi.${d}`))) {
330
+ domainVerified = true;
331
+ } else {
332
+ let alignedDomain = getAlignment(d, domainSet, false);
333
+ if (alignedDomain) {
334
+ domainVerified = true;
335
+ }
336
+ }
337
+
338
+ if (!domainVerified) {
339
+ let error = new Error('Domain can not be verified');
340
+ error.details = {
341
+ subjectAltName: vmcData?.certificate?.subjectAltName,
342
+ selector,
343
+ d
344
+ };
345
+ error.code = 'VMC_DOMAIN_MISMATCH';
346
+ throw error;
347
+ } else {
348
+ result.authority.domainVerified = true;
349
+ }
350
+ }
351
+
352
+ result.authority.vmc = vmcData;
353
+ } catch (err) {
354
+ result.authority.success = false;
355
+ result.authority.error = { message: err.message };
356
+ if (err.details) {
357
+ result.authority.error.details = err.details;
358
+ }
359
+ if (err.code) {
360
+ result.authority.error.code = err.code;
361
+ }
362
+ }
363
+ }
364
+
365
+ if (result.location && result.location.success && result.authority.success) {
366
+ try {
367
+ if (result.location.success && result.authority.vmc.hashAlgo && result.authority.vmc.validHash) {
368
+ let hash = crypto.createHash(result.authority.vmc.hashAlgo).update(locationValue).digest('hex');
369
+ result.location.hashAlgo = result.authority.vmc.hashAlgo;
370
+ result.location.hashValue = hash;
371
+ result.authority.hashMatch = hash === result.authority.vmc.hashValue;
372
+ }
373
+ } catch (err) {
374
+ result.authority.success = false;
375
+ result.authority.error = { message: err.message };
376
+ if (err.details) {
377
+ result.authority.error.details = err.details;
378
+ }
379
+ if (err.code) {
380
+ result.authority.error.code = err.code;
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ return result;
387
+ };
388
+
389
+ module.exports = { bimi: lookup, validateVMC };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const { validateVMC } = require('../bimi');
4
+
5
+ const fs = require('fs').promises;
6
+
7
+ const cmd = async argv => {
8
+ let bimiData = {};
9
+ if (argv.authorityPath) {
10
+ bimiData.authorityPath = await fs.readFile(argv.authorityPath);
11
+ }
12
+
13
+ if (argv.authority) {
14
+ bimiData.authority = argv.authority;
15
+ }
16
+
17
+ if (argv.domain) {
18
+ bimiData.status = { header: { d: argv.domain } };
19
+ }
20
+
21
+ const result = await validateVMC(bimiData);
22
+ process.stdout.write(JSON.stringify(result.authority, false, 2) + '\n');
23
+ };
24
+
25
+ module.exports = cmd;
@@ -1,9 +1,14 @@
1
- 'use strict';
1
+ /* eslint no-control-regex: 0 */
2
2
 
3
- // Calculates relaxed body hash for a message body stream
3
+ 'use strict';
4
4
 
5
5
  const crypto = require('crypto');
6
6
 
7
+ const CHAR_CR = 0x0d;
8
+ const CHAR_LF = 0x0a;
9
+ const CHAR_SPACE = 0x20;
10
+ const CHAR_TAB = 0x09;
11
+
7
12
  /**
8
13
  * Class for calculating body hash of an email message body stream
9
14
  * using the "relaxed" canonicalization
@@ -16,139 +21,244 @@ class RelaxedHash {
16
21
  * @param {Number} [maxBodyLength] Allowed body length count, the value from the l= parameter
17
22
  */
18
23
  constructor(algorithm, maxBodyLength) {
19
- algorithm = (algorithm || 'sha256').split('-').pop();
24
+ algorithm = (algorithm || 'sha256').split('-').pop().toLowerCase();
25
+
20
26
  this.bodyHash = crypto.createHash(algorithm);
21
27
 
22
- this.remainder = '';
28
+ this.remainder = false;
23
29
  this.byteLength = 0;
24
30
 
25
31
  this.bodyHashedBytes = 0;
26
32
  this.maxBodyLength = maxBodyLength;
33
+
34
+ this.maxSizeReached = false;
35
+
36
+ this.emptyLinesQueue = [];
27
37
  }
28
38
 
29
39
  _updateBodyHash(chunk) {
30
- // the following is needed for l= option
40
+ if (this.maxSizeReached) {
41
+ return;
42
+ }
43
+
44
+ // the following is needed for the l= option
31
45
  if (
32
46
  typeof this.maxBodyLength === 'number' &&
33
47
  !isNaN(this.maxBodyLength) &&
34
48
  this.maxBodyLength >= 0 &&
35
49
  this.bodyHashedBytes + chunk.length > this.maxBodyLength
36
50
  ) {
51
+ this.maxSizeReached = true;
37
52
  if (this.bodyHashedBytes >= this.maxBodyLength) {
38
53
  // nothing to do here, skip entire chunk
39
54
  return;
40
55
  }
56
+
41
57
  // only use allowed size of bytes
42
- chunk = chunk.slice(0, this.maxBodyLength - this.bodyHashedBytes);
58
+ chunk = chunk.subarray(0, this.maxBodyLength - this.bodyHashedBytes);
43
59
  }
44
60
 
45
61
  this.bodyHashedBytes += chunk.length;
46
62
  this.bodyHash.update(chunk);
63
+
64
+ //process.stdout.write(chunk);
47
65
  }
48
66
 
49
- update(chunk) {
50
- this.byteLength += chunk.length;
67
+ _drainPendingEmptyLines() {
68
+ if (this.emptyLinesQueue.length) {
69
+ for (let emptyLine of this.emptyLinesQueue) {
70
+ this._updateBodyHash(emptyLine);
71
+ }
72
+ this.emptyLinesQueue = [];
73
+ }
74
+ }
51
75
 
52
- let bodyStr;
76
+ _pushBodyHash(chunk) {
77
+ if (!chunk || !chunk.length) {
78
+ return;
79
+ }
53
80
 
54
- // find next remainder
55
- let nextRemainder = '';
81
+ // remove line endings
82
+ let foundNonLn = false;
56
83
 
57
- // This crux finds and removes the spaces from the last line and the newline characters after the last non-empty line
58
- // If we get another chunk that does not match this description then we can restore the previously processed data
59
- let state = 'file';
84
+ // buffer line endings and empty lines
60
85
  for (let i = chunk.length - 1; i >= 0; i--) {
61
- let c = chunk[i];
62
-
63
- if (state === 'file' && (c === 0x0a || c === 0x0d)) {
64
- // do nothing, found \n or \r at the end of chunk, stil end of file
65
- } else if (state === 'file' && (c === 0x09 || c === 0x20)) {
66
- // switch to line ending mode, this is the last non-empty line
67
- state = 'line';
68
- } else if (state === 'line' && (c === 0x09 || c === 0x20)) {
69
- // do nothing, found ' ' or \t at the end of line, keep processing the last non-empty line
70
- } else if (state === 'file' || state === 'line') {
71
- // non line/file ending character found, switch to body mode
72
- state = 'body';
73
- if (i === chunk.length - 1) {
74
- // final char is not part of line end or file end, so do nothing
75
- break;
86
+ if (chunk[i] !== CHAR_LF && chunk[i] !== CHAR_CR) {
87
+ this._drainPendingEmptyLines();
88
+ if (i < chunk.length - 1) {
89
+ this.emptyLinesQueue.push(chunk.subarray(i + 1));
90
+ chunk = chunk.subarray(0, i + 1);
76
91
  }
92
+ foundNonLn = true;
93
+ break;
77
94
  }
95
+ }
96
+
97
+ if (!foundNonLn) {
98
+ this.emptyLinesQueue.push(chunk);
99
+ return;
100
+ }
78
101
 
79
- if (i === 0) {
80
- // reached to the beginning of the chunk, check if it is still about the ending
81
- // and if the remainder also matches
82
- if (
83
- (state === 'file' && (!this.remainder || /[\r\n]$/.test(this.remainder))) ||
84
- (state === 'line' && (!this.remainder || /[ \t]$/.test(this.remainder)))
85
- ) {
86
- // keep everything
87
- this.remainder += chunk.toString('binary');
88
- return;
89
- } else if (state === 'line' || state === 'file') {
90
- // process existing remainder as normal line but store the current chunk
91
- nextRemainder = chunk.toString('binary');
92
- chunk = false;
93
- break;
102
+ this._updateBodyHash(chunk);
103
+ }
104
+
105
+ fixLineBuffer(line) {
106
+ let resultLine = [];
107
+
108
+ let nonWspFound = false;
109
+ let prevWsp = false;
110
+
111
+ for (let i = line.length - 1; i >= 0; i--) {
112
+ if (line[i] === CHAR_LF) {
113
+ resultLine.unshift(line[i]);
114
+ if (i === 0 || line[i - 1] !== CHAR_CR) {
115
+ // add missing carriage return
116
+ resultLine.unshift(CHAR_CR);
94
117
  }
118
+ continue;
95
119
  }
96
120
 
97
- if (state !== 'body') {
121
+ if (line[i] === CHAR_CR) {
122
+ resultLine.unshift(line[i]);
123
+ continue;
124
+ }
125
+
126
+ if (line[i] === CHAR_SPACE || line[i] === CHAR_TAB) {
127
+ if (nonWspFound) {
128
+ prevWsp = true;
129
+ }
98
130
  continue;
99
131
  }
100
132
 
101
- // reached first non ending byte
102
- nextRemainder = chunk.slice(i + 1).toString('binary');
103
- chunk = chunk.slice(0, i + 1);
104
- break;
133
+ if (prevWsp) {
134
+ resultLine.unshift(CHAR_SPACE);
135
+ prevWsp = false;
136
+ }
137
+
138
+ nonWspFound = true;
139
+ resultLine.unshift(line[i]);
140
+ }
141
+
142
+ if (prevWsp && nonWspFound) {
143
+ resultLine.unshift(CHAR_SPACE);
144
+ }
145
+
146
+ return Buffer.from(resultLine);
147
+ }
148
+
149
+ update(chunk, final) {
150
+ this.byteLength += (chunk && chunk.length) || 0;
151
+ if (this.maxSizeReached) {
152
+ return;
105
153
  }
106
154
 
107
- let needsFixing = !!this.remainder;
108
- if (chunk && !needsFixing) {
109
- // check if we even need to change anything
110
- for (let i = 0, len = chunk.length; i < len; i++) {
111
- if (i && chunk[i] === 0x0a && chunk[i - 1] !== 0x0d) {
112
- // missing \r before \n
113
- needsFixing = true;
114
- break;
115
- } else if (i && chunk[i] === 0x0d && chunk[i - 1] === 0x20) {
116
- // trailing WSP found
117
- needsFixing = true;
118
- break;
119
- } else if (i && chunk[i] === 0x20 && chunk[i - 1] === 0x20) {
120
- // multiple spaces found, needs to be replaced with just one
121
- needsFixing = true;
122
- break;
123
- } else if (chunk[i] === 0x09) {
124
- // TAB found, needs to be replaced with a space
125
- needsFixing = true;
126
- break;
155
+ // Canonicalize content by applying a and b in order:
156
+ // a.1. Ignore all whitespace at the end of lines.
157
+ // a.2. Reduce all sequences of WSP within a line to a single SP character.
158
+
159
+ // b.1. Ignore all empty lines at the end of the message body.
160
+ // b.2. If the body is non-empty but does not end with a CRLF, a CRLF is added.
161
+
162
+ let lineEndPos = -1;
163
+ let lineNeedsFixing = false;
164
+ let cursorPos = 0;
165
+
166
+ if (this.remainder && this.remainder.length) {
167
+ if (chunk) {
168
+ // concatting chunks might be bad for performance :S
169
+ chunk = Buffer.concat([this.remainder, chunk]);
170
+ } else {
171
+ chunk = this.remainder;
172
+ }
173
+ this.remainder = false;
174
+ }
175
+
176
+ if (chunk && chunk.length) {
177
+ for (let pos = 0; pos < chunk.length; pos++) {
178
+ switch (chunk[pos]) {
179
+ case CHAR_LF:
180
+ if (
181
+ !lineNeedsFixing &&
182
+ // previous character is not <CR>
183
+ ((pos >= 1 && chunk[pos - 1] !== CHAR_CR) ||
184
+ // LF is the first byte on the line
185
+ pos === 0 ||
186
+ // there's a space before line break
187
+ (pos >= 2 && chunk[pos - 1] === CHAR_CR && chunk[pos - 2] === CHAR_SPACE))
188
+ ) {
189
+ lineNeedsFixing = true;
190
+ }
191
+
192
+ // line break
193
+ if (lineNeedsFixing) {
194
+ // emit pending bytes up to the last line break before current line
195
+ if (lineEndPos >= 0 && lineEndPos >= cursorPos) {
196
+ let chunkPart = chunk.subarray(cursorPos, lineEndPos + 1);
197
+ this._pushBodyHash(chunkPart);
198
+ }
199
+
200
+ let line = chunk.subarray(lineEndPos + 1, pos + 1);
201
+ this._pushBodyHash(this.fixLineBuffer(line));
202
+
203
+ lineNeedsFixing = false;
204
+
205
+ // move cursor to the start of next line
206
+ cursorPos = pos + 1;
207
+ }
208
+
209
+ lineEndPos = pos;
210
+
211
+ break;
212
+
213
+ case CHAR_SPACE:
214
+ if (!lineNeedsFixing && pos && chunk[pos - 1] === CHAR_SPACE) {
215
+ lineNeedsFixing = true;
216
+ }
217
+ break;
218
+
219
+ case CHAR_TAB:
220
+ // non-space WSP always needs replacing
221
+ lineNeedsFixing = true;
222
+ break;
223
+
224
+ default:
127
225
  }
128
226
  }
129
227
  }
130
228
 
131
- if (needsFixing) {
132
- bodyStr = this.remainder + (chunk ? chunk.toString('binary') : '');
133
- this.remainder = nextRemainder;
134
- bodyStr = bodyStr
135
- .replace(/\r?\n/g, '\n') // use js line endings
136
- .replace(/[ \t]*$/gm, '') // remove line endings, rtrim
137
- .replace(/[ \t]+/gm, ' ') // single spaces
138
- .replace(/\n/g, '\r\n'); // restore rfc822 line endings
139
- chunk = Buffer.from(bodyStr, 'binary');
140
- } else if (nextRemainder) {
141
- this.remainder = nextRemainder;
229
+ if (chunk && cursorPos < chunk.length && cursorPos !== lineEndPos) {
230
+ // emit data from chunk
231
+
232
+ let chunkPart = chunk.subarray(cursorPos, lineEndPos + 1);
233
+
234
+ if (chunkPart.length) {
235
+ this._pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart);
236
+ lineNeedsFixing = false;
237
+ }
238
+
239
+ cursorPos = lineEndPos + 1;
142
240
  }
143
241
 
144
- this._updateBodyHash(chunk);
242
+ if (chunk && !final && cursorPos < chunk.length) {
243
+ this.remainder = chunk.subarray(cursorPos);
244
+ }
245
+
246
+ if (final) {
247
+ let chunkPart = (cursorPos && chunk && chunk.subarray(cursorPos)) || chunk;
248
+ if (chunkPart && chunkPart.length) {
249
+ this._pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart);
250
+ lineNeedsFixing = false;
251
+ }
252
+
253
+ if (this.bodyHashedBytes) {
254
+ // terminating line break for non-empty messages
255
+ this._updateBodyHash(Buffer.from([CHAR_CR, CHAR_LF]));
256
+ }
257
+ }
145
258
  }
146
259
 
147
260
  digest(encoding) {
148
- if (/[\r\n]$/.test(this.remainder) && this.bodyHashedBytes > 0) {
149
- // add terminating line end
150
- this._updateBodyHash(Buffer.from('\r\n'));
151
- }
261
+ this.update(null, true);
152
262
 
153
263
  // finalize
154
264
  return this.bodyHash.digest(encoding);
@@ -156,3 +266,27 @@ class RelaxedHash {
156
266
  }
157
267
 
158
268
  module.exports = { RelaxedHash };
269
+
270
+ /*
271
+ let fs = require('fs');
272
+
273
+ const getBody = message => {
274
+ message = message.toString('binary');
275
+ let match = message.match(/\r?\n\r?\n/);
276
+ if (match) {
277
+ message = message.substr(match.index + match[0].length);
278
+ }
279
+ return Buffer.from(message, 'binary');
280
+ };
281
+
282
+ let s = fs.readFileSync(process.argv[2]);
283
+
284
+ let k = new RelaxedHash('rsa-sha256', -1);
285
+
286
+ for (let byte of getBody(s)) {
287
+ k.update(Buffer.from([byte]));
288
+ }
289
+
290
+ console.error(k.digest('base64'));
291
+ console.error(k.byteLength, k.bodyHashedBytes);
292
+ */
@@ -22,6 +22,8 @@ class SimpleHash {
22
22
 
23
23
  this.bodyHashedBytes = 0;
24
24
  this.maxBodyLength = maxBodyLength;
25
+
26
+ this.lastNewline = false;
25
27
  }
26
28
 
27
29
  _updateBodyHash(chunk) {
@@ -42,6 +44,8 @@ class SimpleHash {
42
44
 
43
45
  this.bodyHashedBytes += chunk.length;
44
46
  this.bodyHash.update(chunk);
47
+
48
+ //process.stdout.write(chunk);
45
49
  }
46
50
 
47
51
  update(chunk) {
@@ -81,10 +85,11 @@ class SimpleHash {
81
85
  }
82
86
 
83
87
  this._updateBodyHash(chunk);
88
+ this.lastNewline = chunk[chunk.length - 1] === 0x0a;
84
89
  }
85
90
 
86
91
  digest(encoding) {
87
- if (this.remainder.length || !this.bodyHashedBytes) {
92
+ if (!this.lastNewline || !this.bodyHashedBytes) {
88
93
  // emit empty line buffer to keep the stream flowing
89
94
  this._updateBodyHash(Buffer.from('\r\n'));
90
95
  }
package/lib/mailauth.js CHANGED
@@ -4,7 +4,7 @@ const { dkimVerify } = require('./dkim/verify');
4
4
  const { spf } = require('./spf');
5
5
  const { dmarc } = require('./dmarc');
6
6
  const { arc, createSeal } = require('./arc');
7
- const { bimi } = require('./bimi');
7
+ const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
8
8
  const { parseReceived } = require('./parse-received');
9
9
  const { sealMessage } = require('./arc');
10
10
  const libmime = require('libmime');
@@ -180,4 +180,4 @@ const authenticate = async (input, opts) => {
180
180
  };
181
181
  };
182
182
 
183
- module.exports = { authenticate, sealMessage };
183
+ module.exports = { authenticate, sealMessage, validateBimiVmc };
package/lib/tools.js CHANGED
@@ -10,10 +10,6 @@ const https = require('https');
10
10
  const packageData = require('../package');
11
11
  const parseDkimHeaders = require('./parse-dkim-headers');
12
12
  const psl = require('psl');
13
- const { Certificate } = require('@fidm/x509');
14
- const zlib = require('zlib');
15
- const util = require('util');
16
- const gunzip = util.promisify(zlib.gunzip);
17
13
  const pki = require('node-forge').pki;
18
14
  const Joi = require('joi');
19
15
  const base64Schema = Joi.string().base64({ paddingRequired: false });
@@ -474,44 +470,6 @@ const validateAlgorithm = (algorithm, strict) => {
474
470
  }
475
471
  };
476
472
 
477
- /**
478
- * Function takes Verified Mark Certificate file and parses domain names and SVG file
479
- * NB! Certificate is not verified in any way. If there are altNames and SVG content
480
- * available then these are returned even if the certificate is self signed or expired.
481
- * @param {Buffer} pem VMC file
482
- * @returns {Object|Boolean} Either an object with {altNames[], svg} or false if required data was missing from the certificate
483
- */
484
- const parseLogoFromX509 = async pem => {
485
- const cert = Certificate.fromPEM(pem);
486
-
487
- const altNames = cert.extensions
488
- .filter(e => e.oid === '2.5.29.17')
489
- .flatMap(d => d?.altNames?.map(an => an?.dnsName?.trim()))
490
- .filter(an => an);
491
- if (!altNames.length) {
492
- return false;
493
- }
494
-
495
- let logo = cert.extensions.find(e => e.oid === '1.3.6.1.5.5.7.1.12');
496
- if (!logo?.value?.length) {
497
- return false;
498
- }
499
-
500
- let str = logo.value.toString();
501
- // No idea what is that binary stuff before the data uri block
502
- let dataMatch = /\bdata:/.test(str) && str.match(/\bbase64,/);
503
- if (dataMatch) {
504
- let b64 = str.substr(dataMatch.index + dataMatch[0].length);
505
- let svg = await gunzip(Buffer.from(b64, 'base64'));
506
- return {
507
- pem,
508
- altNames,
509
- svg: svg.toString()
510
- };
511
- }
512
- return false;
513
- };
514
-
515
473
  module.exports = {
516
474
  writeToStream,
517
475
  parseHeaders,
@@ -533,6 +491,5 @@ module.exports = {
533
491
  getAlignment,
534
492
 
535
493
  formatRelaxedLine,
536
-
537
- parseLogoFromX509
494
+ formatDomain
538
495
  };
package/licenses.txt CHANGED
@@ -1,11 +1,12 @@
1
- name license type link installed version author
2
- ---- ------------ ---- ----------------- ------
3
- @fidm/x509 MIT git+ssh://git@github.com/fidm/x509.git 1.2.1
4
- ipaddr.js MIT git://github.com/whitequark/ipaddr.js.git 2.0.1 whitequark whitequark@whitequark.org
5
- joi BSD-3-Clause git://github.com/sideway/joi.git 17.6.0
6
- libmime MIT git://github.com/andris9/libmime.git 5.0.0 Andris Reinman andris@kreata.ee
7
- node-forge (BSD-3-Clause OR GPL-2.0) git+https://github.com/digitalbazaar/forge.git 1.3.1 Digital Bazaar, Inc. support@digitalbazaar.com http://digitalbazaar.com/
8
- nodemailer MIT git+https://github.com/nodemailer/nodemailer.git 6.7.3 Andris Reinman
9
- psl MIT git+ssh://git@github.com/lupomontero/psl.git 1.8.0 Lupo Montero lupomontero@gmail.com https://lupomontero.com/
10
- punycode MIT git+https://github.com/bestiejs/punycode.js.git 2.1.1 Mathias Bynens https://mathiasbynens.be/
11
- yargs MIT git+https://github.com/yargs/yargs.git 17.4.1
1
+ name license type link installed version author
2
+ ---- ------------ ---- ----------------- ------
3
+ @fidm/x509 MIT git+ssh://git@github.com/fidm/x509.git 1.2.1
4
+ @postalsys/vmc MIT https://registry.npmjs.org/@postalsys/vmc/-/vmc-1.0.1.tgz 1.0.1 Postal Systems OÜ
5
+ ipaddr.js MIT git://github.com/whitequark/ipaddr.js.git 2.0.1 whitequark whitequark@whitequark.org
6
+ joi BSD-3-Clause git://github.com/sideway/joi.git 17.6.0
7
+ libmime MIT git://github.com/andris9/libmime.git 5.1.0 Andris Reinman andris@kreata.ee
8
+ node-forge (BSD-3-Clause OR GPL-2.0) git+https://github.com/digitalbazaar/forge.git 1.3.1 Digital Bazaar, Inc. support@digitalbazaar.com http://digitalbazaar.com/
9
+ nodemailer MIT git+https://github.com/nodemailer/nodemailer.git 6.7.7 Andris Reinman
10
+ psl MIT git+ssh://git@github.com/lupomontero/psl.git 1.9.0 Lupo Montero lupomontero@gmail.com https://lupomontero.com/
11
+ punycode MIT git+https://github.com/bestiejs/punycode.js.git 2.1.1 Mathias Bynens https://mathiasbynens.be/
12
+ yargs MIT git+https://github.com/yargs/yargs.git 17.5.1
package/man/mailauth.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH "MAILAUTH" "1" "May 2022" "v2.3.2" "Mailauth Help"
1
+ .TH "MAILAUTH" "1" "July 2022" "v3.0.0" "Mailauth Help"
2
2
  .SH "NAME"
3
3
  \fBmailauth\fR
4
4
  .QP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "2.3.3",
3
+ "version": "3.0.1",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "scripts": {
@@ -33,7 +33,7 @@
33
33
  "homepage": "https://github.com/postalsys/mailauth",
34
34
  "devDependencies": {
35
35
  "chai": "4.3.6",
36
- "eslint": "8.15.0",
36
+ "eslint": "8.19.0",
37
37
  "eslint-config-nodemailer": "1.2.0",
38
38
  "eslint-config-prettier": "8.5.0",
39
39
  "js-yaml": "4.1.0",
@@ -42,21 +42,21 @@
42
42
  "marked-man": "0.7.0",
43
43
  "mbox-reader": "1.1.5",
44
44
  "mocha": "10.0.0",
45
- "pkg": "5.6.0"
45
+ "pkg": "5.7.0"
46
46
  },
47
47
  "dependencies": {
48
- "@fidm/x509": "1.2.1",
48
+ "@postalsys/vmc": "1.0.3",
49
49
  "ipaddr.js": "2.0.1",
50
50
  "joi": "17.6.0",
51
51
  "libmime": "5.1.0",
52
52
  "node-forge": "1.3.1",
53
- "nodemailer": "6.7.5",
54
- "psl": "1.8.0",
53
+ "nodemailer": "6.7.7",
54
+ "psl": "1.9.0",
55
55
  "punycode": "2.1.1",
56
- "yargs": "17.5.0"
56
+ "yargs": "17.5.1"
57
57
  },
58
58
  "engines": {
59
- "node": ">=14.0.0"
59
+ "node": ">=16.0.0"
60
60
  },
61
61
  "bin": {
62
62
  "mailauth": "bin/mailauth.js"