Skip to content

Commit 25839ab

Browse files
authored
Merge pull request #18 from postalsys/v2.4.0
v2.4.0
2 parents ef3d009 + d00d292 commit 25839ab

File tree

10 files changed

+336
-64
lines changed

10 files changed

+336
-64
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
test:
99
strategy:
1010
matrix:
11-
node: [14.x, 16.x, 18.x]
11+
node: [16.x, 18.x]
1212
os: [ubuntu-latest, macos-latest, windows-latest]
1313
runs-on: ${{ matrix.os }}
1414
steps:

README.md

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -327,15 +327,6 @@ Some example authority evidence documents:
327327
- [from default.\_bimi.cnn.com](https://amplify.valimail.com/bimi/time-warner/LysAFUdG-Hw-cnn_vmc.pem)
328328
- [from default.\_bimi.entrustdatacard.com](https://www.entrustdatacard.com/-/media/certificate/Entrust%20VMC%20July%2014%202020.pem)
329329
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-
339330
## MTA-STS
340331
341332
`mailauth` allows you to fetch MTA-STS information for a domain name.

bin/mailauth.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ const yargs = require('yargs/yargs');
66
const { hideBin } = require('yargs/helpers');
77
const os = require('os');
88
const assert = require('assert');
9+
910
const commandReport = require('../lib/commands/report');
1011
const commandSign = require('../lib/commands/sign');
1112
const commandSeal = require('../lib/commands/seal');
1213
const commandSpf = require('../lib/commands/spf');
14+
const commandVmc = require('../lib/commands/vmc');
15+
1316
const fs = require('fs');
1417
const pathlib = require('path');
1518

@@ -287,6 +290,35 @@ const argv = yargs(hideBin(process.argv))
287290
});
288291
}
289292
)
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+
)
290322
.command(
291323
['license'],
292324
'Show license information',

cli.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- [sign](#sign) - to sign an email with DKIM
1010
- [seal](#seal) - to seal an email with ARC
1111
- [spf](#spf) - to validate SPF for an IP address and an email address
12+
- [vmc](#vmc) - to validate BIMI VMC logo files
1213
- [license](#license) - display licenses for `mailauth` and included modules
1314
- [DNS cache file](#dns-cache-file)
1415

@@ -208,6 +209,86 @@ DNS query for A mail.wildduck.email: ["217.146.76.20"]
208209
...
209210
```
210211

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+
211292
### license
212293

213294
Display licenses for `mailauth` and included modules.

lib/bimi/index.js

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
'use strict';
22

3+
const crypto = require('crypto');
34
const dns = require('dns');
45
const { formatAuthHeaderRow, parseDkimHeaders } = require('../tools');
56
const Joi = require('joi');
7+
const packageData = require('../../package.json');
68
const httpsSchema = Joi.string().uri({
79
scheme: ['https']
810
});
911

12+
const https = require('https');
13+
const http = require('http');
14+
const { vmc } = require('@postalsys/vmc');
15+
1016
const lookup = async data => {
1117
let { dmarc, headers, resolver } = data;
1218
let headerRows = (headers && headers.parsed) || [];
@@ -161,4 +167,171 @@ const lookup = async data => {
161167
return response;
162168
};
163169

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 };

lib/commands/vmc.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

lib/mailauth.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { dkimVerify } = require('./dkim/verify');
44
const { spf } = require('./spf');
55
const { dmarc } = require('./dmarc');
66
const { arc, createSeal } = require('./arc');
7-
const { bimi } = require('./bimi');
7+
const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
88
const { parseReceived } = require('./parse-received');
99
const { sealMessage } = require('./arc');
1010
const libmime = require('libmime');
@@ -180,4 +180,4 @@ const authenticate = async (input, opts) => {
180180
};
181181
};
182182

183-
module.exports = { authenticate, sealMessage };
183+
module.exports = { authenticate, sealMessage, validateBimiVmc };

0 commit comments

Comments
 (0)