mailauth 2.3.2 → 3.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/.gitattributes ADDED
@@ -0,0 +1 @@
1
+ *.js text eol=lf
package/README.md CHANGED
@@ -261,7 +261,7 @@ process.stdout.write(message);
261
261
  If you want to modify the message before sealing, you have to authenticate the message first and then use authentication results as input for the sealing step.
262
262
 
263
263
  ```js
264
- const { authenticate, sealMessage } = require('@postalsys/mailauth');
264
+ const { authenticate, sealMessage } = require('mailauth');
265
265
 
266
266
  // 1. authenticate the message
267
267
  const { arc, headers } = await authenticate(
@@ -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,35 @@ const argv = yargs(hideBin(process.argv))
287
290
  });
288
291
  }
289
292
  )
293
+ .command(
294
+ ['vmc'],
295
+ 'Validate VMC logo',
296
+ yargs => {
297
+ yargs.option('authorityFile', {
298
+ alias: 'f',
299
+ type: 'string',
300
+ description: 'Path to a VMC file',
301
+ demandOption: false
302
+ });
303
+ yargs.option('authority', {
304
+ alias: 'a',
305
+ type: 'string',
306
+ description: 'URL to a VMC file',
307
+ demandOption: false
308
+ });
309
+ },
310
+ argv => {
311
+ commandVmc(argv)
312
+ .then(() => {
313
+ process.exit();
314
+ })
315
+ .catch(err => {
316
+ console.error('Failed to verify VMC file');
317
+ console.error(err);
318
+ process.exit(1);
319
+ });
320
+ }
321
+ )
290
322
  .command(
291
323
  ['license'],
292
324
  'Show license information',
package/cli.md CHANGED
@@ -9,6 +9,7 @@
9
9
  - [sign](#sign) - to sign an email with DKIM
10
10
  - [seal](#seal) - to seal an email with ARC
11
11
  - [spf](#spf) - to validate SPF for an IP address and an email address
12
+ - [vmc](#vmc) - to validate BIMI VMC logo files
12
13
  - [license](#license) - display licenses for `mailauth` and included modules
13
14
  - [DNS cache file](#dns-cache-file)
14
15
 
@@ -208,6 +209,86 @@ DNS query for A mail.wildduck.email: ["217.146.76.20"]
208
209
  ...
209
210
  ```
210
211
 
212
+ ### vmc
213
+
214
+ `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.
215
+
216
+ ```
217
+ $ mailauth vmc [options]
218
+ ```
219
+
220
+ Where
221
+
222
+ - **options** are option flags and arguments
223
+
224
+ **Options**
225
+
226
+ - `--authority <url>` or `-a <url>` is the URL for the VMC resource
227
+ - `--authorityFile <path>` or `-f <path>` is the cached file for the authority URL to avoid network requests
228
+
229
+ **Example**
230
+
231
+ ```
232
+ $ mailauth vmc -a https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.pem
233
+ {
234
+ "url": "https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.pem",
235
+ "success": true,
236
+ "vmc": {
237
+ "mediaType": "image/svg+xml",
238
+ "hashAlgo": "sha1",
239
+ "hashValue": "ea8c81da633c66a16262134a78576cdf067638e9",
240
+ "logoFile": "PD94bWwgdmVyc...",
241
+ "validHash": true,
242
+ "certificate": {
243
+ "subjectAltName": [
244
+ "cnn.com"
245
+ ],
246
+ "subject": {
247
+ "businessCategory": "Private Organization",
248
+ "jurisdictionCountryName": "US",
249
+ "jurisdictionStateOrProvinceName": "Delaware",
250
+ "serialNumber": "2976730",
251
+ "countryName": "US",
252
+ "stateOrProvinceName": "Georgia",
253
+ "localityName": "Atlanta",
254
+ "street": "190 Marietta St NW",
255
+ "organizationName": "Cable News Network, Inc.",
256
+ "commonName": "Cable News Network, Inc.",
257
+ "trademarkCountryOrRegionName": "US",
258
+ "trademarkRegistration": "5817930"
259
+ },
260
+ "fingerprint": "17:B3:94:97:E6:6B:C8:6B:33:B8:0A:D2:F0:79:6B:08:A2:A6:84:BD",
261
+ "serialNumber": "0821B8FE0A9CBC3BAC10DA08C088EEF4",
262
+ "issuer": {
263
+ "countryName": "US",
264
+ "organizationName": "DigiCert, Inc.",
265
+ "commonName": "DigiCert Verified Mark RSA4096 SHA256 2021 CA1"
266
+ }
267
+ }
268
+ }
269
+ }
270
+ ```
271
+
272
+ If the certificate verification fails, then the contents are not returned.
273
+
274
+ ```
275
+ $ mailauth vmc -f /path/to/random/cert-bundle.pem
276
+ {
277
+ "success": false,
278
+ "error": {
279
+ "message": "Self signed certificate in certificate chain",
280
+ "details": {
281
+ "subject": "CN=catchall.delivery",
282
+ "fingerprint": "35:EF:C9:9A:52:D5:A9:94:00:68:C6:D4:17:F1:26:61:01:0F:70:6D",
283
+ "fingerprint235": "09:AB:0F:6B:F5:4F:16:58:F8:94:80:DE:E2:1A:D1:47:CC:64:F2:BF:63:E7:73:E4:02:F9:D3:C3:F6:9E:CC:86",
284
+ "validFrom": "Jul 6 23:10:49 2022 GMT",
285
+ "validTo": "Oct 4 23:10:48 2022 GMT"
286
+ },
287
+ "code": "SELF_SIGNED_CERT_IN_CHAIN"
288
+ }
289
+ }
290
+ ```
291
+
211
292
  ### license
212
293
 
213
294
  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
5
  const { formatAuthHeaderRow, parseDkimHeaders } = 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,171 @@ 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 promises = [];
248
+
249
+ promises.push(downloadPromise(bimiData.location, bimiData.locationFile));
250
+ promises.push(downloadPromise(bimiData.authority, bimiData.authorityFile));
251
+
252
+ if (!promises.length) {
253
+ return false;
254
+ }
255
+
256
+ let results = await Promise.allSettled(promises);
257
+
258
+ let result = {};
259
+ if (results[0].value || results[0].reason) {
260
+ result.location = {
261
+ url: bimiData.location,
262
+ success: results[0].status === 'fulfilled'
263
+ };
264
+
265
+ if (results[0].reason) {
266
+ let err = results[0].reason;
267
+ result.location.error = { message: err.message };
268
+ if (err.redirect) {
269
+ result.location.error.redirect = err.redirect;
270
+ }
271
+ if (err.code) {
272
+ result.location.error.code = err.code;
273
+ }
274
+ }
275
+
276
+ if (result.location.success) {
277
+ result.location.logoFile = results[0].value.toString('base64');
278
+ }
279
+ }
280
+
281
+ if (results[1].value || results[1].reason) {
282
+ result.authority = {
283
+ url: bimiData.authority,
284
+ success: results[1].status === 'fulfilled'
285
+ };
286
+
287
+ if (results[1].reason) {
288
+ let err = results[1].reason;
289
+ result.authority.error = { message: err.message };
290
+ if (err.redirect) {
291
+ result.authority.error.redirect = err.redirect;
292
+ }
293
+ if (err.code) {
294
+ result.authority.error.code = err.code;
295
+ }
296
+ }
297
+
298
+ if (results[1].value) {
299
+ try {
300
+ result.authority.vmc = await vmc(results[1].value);
301
+ } catch (err) {
302
+ result.authority.success = false;
303
+ result.authority.error = { message: err.message };
304
+ if (err.details) {
305
+ result.authority.error.details = err.details;
306
+ }
307
+ if (err.code) {
308
+ result.authority.error.code = err.code;
309
+ }
310
+ }
311
+ }
312
+
313
+ if (result.location && result.location.success && result.authority.success) {
314
+ try {
315
+ if (result.location.success && result.authority.vmc.hashAlgo && result.authority.vmc.validHash) {
316
+ let hash = crypto.createHash(result.authority.vmc.hashAlgo).update(results[0].value).digest('hex');
317
+ result.location.hashAlgo = result.authority.vmc.hashAlgo;
318
+ result.location.hashValue = hash;
319
+ result.authority.hashMatch = hash === result.authority.vmc.hashValue;
320
+ }
321
+ } catch (err) {
322
+ result.authority.success = false;
323
+ result.authority.error = { message: err.message };
324
+ if (err.details) {
325
+ result.authority.error.details = err.details;
326
+ }
327
+ if (err.code) {
328
+ result.authority.error.code = err.code;
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ return result;
335
+ };
336
+
337
+ module.exports = { bimi: lookup, validateVMC };
@@ -0,0 +1,20 @@
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.authorityFile) {
10
+ bimiData.authorityFile = await fs.readFile(argv.authorityFile);
11
+ }
12
+ if (argv.authority) {
13
+ bimiData.authority = argv.authority;
14
+ }
15
+
16
+ const result = await validateVMC(bimiData);
17
+ process.stdout.write(JSON.stringify(result.authority, false, 2) + '\n');
18
+ };
19
+
20
+ 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
  }
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { getSigningHeaderLines, getPublicKey, parseDkimHeaders, formatAuthHeaderRow, getAligment } = require('../../lib/tools');
3
+ const { getSigningHeaderLines, getPublicKey, parseDkimHeaders, formatAuthHeaderRow, getAlignment } = require('../../lib/tools');
4
4
  const { MessageParser } = require('./message-parser');
5
5
  const { dkimBody } = require('./body');
6
6
  const { generateCanonicalizedHeader } = require('./header');
@@ -195,7 +195,7 @@ class DkimVerifier extends MessageParser {
195
195
  };
196
196
 
197
197
  if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) {
198
- status.aligned = this.headerFrom?.length ? getAligment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain]) : false;
198
+ status.aligned = this.headerFrom?.length ? getAlignment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain]) : false;
199
199
  }
200
200
 
201
201
  let bodyHash = this.bodyHashes.get(signatureHeader.bodyHashKey)?.hash;
@@ -3,7 +3,7 @@
3
3
  const dns = require('dns').promises;
4
4
  const punycode = require('punycode/');
5
5
  const psl = require('psl');
6
- const { formatAuthHeaderRow, getAligment } = require('../tools');
6
+ const { formatAuthHeaderRow, getAlignment } = require('../tools');
7
7
 
8
8
  const resolveTxt = async (domain, resolver) => {
9
9
  try {
@@ -146,8 +146,8 @@ const verifyDmarc = async opts => {
146
146
  // use "sp" if this is a subdomain of an org domain and "sp" is set, otherwise use "p"
147
147
  const policy = dmarcRecord.isOrgRecord && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
148
148
 
149
- const dkimAlignment = getAligment(domain, dkimDomains, { strict: dmarcRecord.adkim === 's' });
150
- const spfAlignment = getAligment(domain, spfDomains, { strict: dmarcRecord.aspf === 's' });
149
+ const dkimAlignment = getAlignment(domain, dkimDomains, { strict: dmarcRecord.adkim === 's' });
150
+ const spfAlignment = getAlignment(domain, spfDomains, { strict: dmarcRecord.aspf === 's' });
151
151
 
152
152
  if (dkimAlignment || spfAlignment) {
153
153
  // pass
@@ -164,7 +164,12 @@ const verifyDmarc = async opts => {
164
164
  p: dmarcRecord.p,
165
165
  sp: dmarcRecord.sp || dmarcRecord.p,
166
166
  pct: dmarcRecord.pct,
167
- rr: dmarcRecord.rr
167
+ rr: dmarcRecord.rr,
168
+
169
+ alignment: {
170
+ spf: { result: spfAlignment, strict: dmarcRecord.aspf === 's' },
171
+ dkim: { result: dkimAlignment, strict: dmarcRecord.adkim === 's' }
172
+ }
168
173
  });
169
174
  };
170
175
 
package/lib/mailauth.js CHANGED
@@ -4,8 +4,9 @@ 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
+ const { sealMessage } = require('./arc');
9
10
  const libmime = require('libmime');
10
11
  const os = require('os');
11
12
  const { isIP } = require('net');
@@ -179,4 +180,4 @@ const authenticate = async (input, opts) => {
179
180
  };
180
181
  };
181
182
 
182
- module.exports = { authenticate };
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 });
@@ -429,7 +425,7 @@ const formatDomain = domain => {
429
425
  return domain;
430
426
  };
431
427
 
432
- const getAligment = (fromDomain, domainList, strict) => {
428
+ const getAlignment = (fromDomain, domainList, strict) => {
433
429
  domainList = [].concat(domainList || []);
434
430
  if (strict) {
435
431
  fromDomain = formatDomain(fromDomain);
@@ -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,
@@ -530,9 +488,7 @@ module.exports = {
530
488
 
531
489
  validateAlgorithm,
532
490
 
533
- getAligment,
534
-
535
- formatRelaxedLine,
491
+ getAlignment,
536
492
 
537
- parseLogoFromX509
493
+ formatRelaxedLine
538
494
  };
package/licenses.txt ADDED
@@ -0,0 +1,11 @@
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.1.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.5 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.5.1
package/man/mailauth.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH "MAILAUTH" "1" "April 2022" "v2.3.1" "Mailauth Help"
1
+ .TH "MAILAUTH" "1" "July 2022" "v2.3.4" "Mailauth Help"
2
2
  .SH "NAME"
3
3
  \fBmailauth\fR
4
4
  .QP
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "2.3.2",
3
+ "version": "3.0.0",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "scripts": {
7
7
  "test": "eslint \"lib/**/*.js\" \"test/**/*.js\" && mocha --recursive \"./test/**/*.js\" --reporter spec",
8
8
  "prepublish": "npm run man || true",
9
9
  "man": "cd man && marked-man --version `node -e \"console.log('v'+require('../package.json').version)\"` --manual 'Mailauth Help' --section 1 man.md > mailauth.1",
10
- "build-dist": "npm run man && npm run licenses && pkg --compress Brotli package.json",
10
+ "build-source": "rm -rf node_modules package-lock.json && npm install && npm run man && npm run licenses && rm -rf node_modules package-lock.json && npm install --production && rm -rf package-lock.json",
11
+ "build-dist": "npx pkg --compress Brotli package.json && rm -rf package-lock.json && npm install",
11
12
  "licenses": "license-report --only=prod --output=table --config license-report-config.json > licenses.txt"
12
13
  },
13
14
  "repository": {
@@ -32,7 +33,7 @@
32
33
  "homepage": "https://github.com/postalsys/mailauth",
33
34
  "devDependencies": {
34
35
  "chai": "4.3.6",
35
- "eslint": "8.14.0",
36
+ "eslint": "8.19.0",
36
37
  "eslint-config-nodemailer": "1.2.0",
37
38
  "eslint-config-prettier": "8.5.0",
38
39
  "js-yaml": "4.1.0",
@@ -40,22 +41,23 @@
40
41
  "marked": "0.7.0",
41
42
  "marked-man": "0.7.0",
42
43
  "mbox-reader": "1.1.5",
43
- "mocha": "9.2.2",
44
- "pkg": "5.6.0"
44
+ "mocha": "10.0.0",
45
+ "pkg": "5.7.0"
45
46
  },
46
47
  "dependencies": {
47
48
  "@fidm/x509": "1.2.1",
49
+ "@postalsys/vmc": "1.0.1",
48
50
  "ipaddr.js": "2.0.1",
49
51
  "joi": "17.6.0",
50
- "libmime": "5.0.0",
52
+ "libmime": "5.1.0",
51
53
  "node-forge": "1.3.1",
52
- "nodemailer": "6.7.3",
53
- "psl": "1.8.0",
54
+ "nodemailer": "6.7.7",
55
+ "psl": "1.9.0",
54
56
  "punycode": "2.1.1",
55
- "yargs": "17.4.1"
57
+ "yargs": "17.5.1"
56
58
  },
57
59
  "engines": {
58
- "node": ">=14.0.0"
60
+ "node": ">=16.0.0"
59
61
  },
60
62
  "bin": {
61
63
  "mailauth": "bin/mailauth.js"
@@ -64,16 +66,15 @@
64
66
  "man/mailauth.1"
65
67
  ],
66
68
  "pkg": {
67
- "scripts": [
68
- "workers/**/*.js"
69
- ],
70
69
  "assets": [
71
70
  "man/**/*",
72
71
  "licenses.txt",
73
72
  "LICENSE.txt"
74
73
  ],
75
- "_targets": [
76
- "node16-macos-x64"
74
+ "targets": [
75
+ "node16-linux-x64",
76
+ "node16-macos-x64",
77
+ "node16-win-x64"
77
78
  ],
78
79
  "outputPath": "ee-dist"
79
80
  }