Skip to content

Commit

Permalink
Merge pull request #18 from postalsys/v2.4.0
Browse files Browse the repository at this point in the history
v2.4.0
  • Loading branch information
andris9 authored Jul 9, 2022
2 parents ef3d009 + d00d292 commit 25839ab
Show file tree
Hide file tree
Showing 10 changed files with 336 additions and 64 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
test:
strategy:
matrix:
node: [14.x, 16.x, 18.x]
node: [16.x, 18.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
Expand Down
9 changes: 0 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,15 +327,6 @@ Some example authority evidence documents:
- [from default.\_bimi.cnn.com](https://amplify.valimail.com/bimi/time-warner/LysAFUdG-Hw-cnn_vmc.pem)
- [from default.\_bimi.entrustdatacard.com](https://www.entrustdatacard.com/-/media/certificate/Entrust%20VMC%20July%2014%202020.pem)
You can parse logos from these certificate files using the `parseLogoFromX509` function.
```js
const { parseLogoFromX509 } = require('mailauth/lib/tools');
let { altnNames, svg } = await parseLogoFromX509(fs.readFileSync('vmc.pem'));
```
> **NB!** `parseLogoFromX509` does not verify the validity of the VMC certificate. It could be self-signed or expired and still be processed.
## MTA-STS
`mailauth` allows you to fetch MTA-STS information for a domain name.
Expand Down
32 changes: 32 additions & 0 deletions bin/mailauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const os = require('os');
const assert = require('assert');

const commandReport = require('../lib/commands/report');
const commandSign = require('../lib/commands/sign');
const commandSeal = require('../lib/commands/seal');
const commandSpf = require('../lib/commands/spf');
const commandVmc = require('../lib/commands/vmc');

const fs = require('fs');
const pathlib = require('path');

Expand Down Expand Up @@ -287,6 +290,35 @@ const argv = yargs(hideBin(process.argv))
});
}
)
.command(
['vmc'],
'Validate VMC logo',
yargs => {
yargs.option('authorityFile', {
alias: 'f',
type: 'string',
description: 'Path to a VMC file',
demandOption: false
});
yargs.option('authority', {
alias: 'a',
type: 'string',
description: 'URL to a VMC file',
demandOption: false
});
},
argv => {
commandVmc(argv)
.then(() => {
process.exit();
})
.catch(err => {
console.error('Failed to verify VMC file');
console.error(err);
process.exit(1);
});
}
)
.command(
['license'],
'Show license information',
Expand Down
81 changes: 81 additions & 0 deletions cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [sign](#sign) - to sign an email with DKIM
- [seal](#seal) - to seal an email with ARC
- [spf](#spf) - to validate SPF for an IP address and an email address
- [vmc](#vmc) - to validate BIMI VMC logo files
- [license](#license) - display licenses for `mailauth` and included modules
- [DNS cache file](#dns-cache-file)

Expand Down Expand Up @@ -208,6 +209,86 @@ DNS query for A mail.wildduck.email: ["217.146.76.20"]
...
```

### vmc

`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.

```
$ mailauth vmc [options]
```

Where

- **options** are option flags and arguments

**Options**

- `--authority <url>` or `-a <url>` is the URL for the VMC resource
- `--authorityFile <path>` or `-f <path>` is the cached file for the authority URL to avoid network requests

**Example**

```
$ mailauth vmc -a https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.pem
{
"url": "https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.pem",
"success": true,
"vmc": {
"mediaType": "image/svg+xml",
"hashAlgo": "sha1",
"hashValue": "ea8c81da633c66a16262134a78576cdf067638e9",
"logoFile": "PD94bWwgdmVyc...",
"validHash": true,
"certificate": {
"subjectAltName": [
"cnn.com"
],
"subject": {
"businessCategory": "Private Organization",
"jurisdictionCountryName": "US",
"jurisdictionStateOrProvinceName": "Delaware",
"serialNumber": "2976730",
"countryName": "US",
"stateOrProvinceName": "Georgia",
"localityName": "Atlanta",
"street": "190 Marietta St NW",
"organizationName": "Cable News Network, Inc.",
"commonName": "Cable News Network, Inc.",
"trademarkCountryOrRegionName": "US",
"trademarkRegistration": "5817930"
},
"fingerprint": "17:B3:94:97:E6:6B:C8:6B:33:B8:0A:D2:F0:79:6B:08:A2:A6:84:BD",
"serialNumber": "0821B8FE0A9CBC3BAC10DA08C088EEF4",
"issuer": {
"countryName": "US",
"organizationName": "DigiCert, Inc.",
"commonName": "DigiCert Verified Mark RSA4096 SHA256 2021 CA1"
}
}
}
}
```

If the certificate verification fails, then the contents are not returned.

```
$ mailauth vmc -f /path/to/random/cert-bundle.pem
{
"success": false,
"error": {
"message": "Self signed certificate in certificate chain",
"details": {
"subject": "CN=catchall.delivery",
"fingerprint": "35:EF:C9:9A:52:D5:A9:94:00:68:C6:D4:17:F1:26:61:01:0F:70:6D",
"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",
"validFrom": "Jul 6 23:10:49 2022 GMT",
"validTo": "Oct 4 23:10:48 2022 GMT"
},
"code": "SELF_SIGNED_CERT_IN_CHAIN"
}
}
```

### license

Display licenses for `mailauth` and included modules.
Expand Down
175 changes: 174 additions & 1 deletion lib/bimi/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
'use strict';

const crypto = require('crypto');
const dns = require('dns');
const { formatAuthHeaderRow, parseDkimHeaders } = require('../tools');
const Joi = require('joi');
const packageData = require('../../package.json');
const httpsSchema = Joi.string().uri({
scheme: ['https']
});

const https = require('https');
const http = require('http');
const { vmc } = require('@postalsys/vmc');

const lookup = async data => {
let { dmarc, headers, resolver } = data;
let headerRows = (headers && headers.parsed) || [];
Expand Down Expand Up @@ -161,4 +167,171 @@ const lookup = async data => {
return response;
};

module.exports = { bimi: lookup };
const downloadPromise = (url, cachedFile) => {
if (cachedFile) {
return cachedFile;
}

if (!url) {
return false;
}

const parsedUrl = new URL(url);

const options = {
protocol: parsedUrl.protocol,
host: parsedUrl.host,
headers: {
host: parsedUrl.host,
'User-Agent': `mailauth/${packageData.version} (+${packageData.homepage}`
},
servername: parsedUrl.hostname,
port: 443,
path: parsedUrl.pathname,
method: 'GET',
rejectUnauthorized: true
};

return new Promise((resolve, reject) => {
let protoHandler;
switch (parsedUrl.protocol) {
case 'https:':
protoHandler = https;
break;
case 'http:':
protoHandler = http;
break;
default:
reject(new Error(`Unknown protocol ${parsedUrl.protocol}`));
}
const req = protoHandler.request(options, res => {
let chunks = [],
chunklen = 0;
res.on('readable', () => {
let chunk;
while ((chunk = res.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
res.on('end', () => {
let data = Buffer.concat(chunks, chunklen);
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
let err = new Error(`Invalid response code ${res.statusCode || '-'}`);
err.code = 'http_status_' + (res.statusCode || 'na');
if (res.headers.location && res.statusCode >= 300 && res.statusCode < 400) {
err.redirect = {
code: res.statusCode,
location: res.headers.location
};
}
return reject(err);
}
resolve(data);
});
res.on('error', err => reject(err));
});

req.on('error', err => {
reject(err);
});
req.end();
});
};

const validateVMC = async bimiData => {
if (!bimiData) {
return false;
}

let promises = [];

promises.push(downloadPromise(bimiData.location, bimiData.locationFile));
promises.push(downloadPromise(bimiData.authority, bimiData.authorityFile));

if (!promises.length) {
return false;
}

let results = await Promise.allSettled(promises);

let result = {};
if (results[0].value || results[0].reason) {
result.location = {
url: bimiData.location,
success: results[0].status === 'fulfilled'
};

if (results[0].reason) {
let err = results[0].reason;
result.location.error = { message: err.message };
if (err.redirect) {
result.location.error.redirect = err.redirect;
}
if (err.code) {
result.location.error.code = err.code;
}
}

if (result.location.success) {
result.location.logoFile = results[0].value.toString('base64');
}
}

if (results[1].value || results[1].reason) {
result.authority = {
url: bimiData.authority,
success: results[1].status === 'fulfilled'
};

if (results[1].reason) {
let err = results[1].reason;
result.authority.error = { message: err.message };
if (err.redirect) {
result.authority.error.redirect = err.redirect;
}
if (err.code) {
result.authority.error.code = err.code;
}
}

if (results[1].value) {
try {
result.authority.vmc = await vmc(results[1].value);
} catch (err) {
result.authority.success = false;
result.authority.error = { message: err.message };
if (err.details) {
result.authority.error.details = err.details;
}
if (err.code) {
result.authority.error.code = err.code;
}
}
}

if (result.location && result.location.success && result.authority.success) {
try {
if (result.location.success && result.authority.vmc.hashAlgo && result.authority.vmc.validHash) {
let hash = crypto.createHash(result.authority.vmc.hashAlgo).update(results[0].value).digest('hex');
result.location.hashAlgo = result.authority.vmc.hashAlgo;
result.location.hashValue = hash;
result.authority.hashMatch = hash === result.authority.vmc.hashValue;
}
} catch (err) {
result.authority.success = false;
result.authority.error = { message: err.message };
if (err.details) {
result.authority.error.details = err.details;
}
if (err.code) {
result.authority.error.code = err.code;
}
}
}
}

return result;
};

module.exports = { bimi: lookup, validateVMC };
20 changes: 20 additions & 0 deletions lib/commands/vmc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';

const { validateVMC } = require('../bimi');

const fs = require('fs').promises;

const cmd = async argv => {
let bimiData = {};
if (argv.authorityFile) {
bimiData.authorityFile = await fs.readFile(argv.authorityFile);
}
if (argv.authority) {
bimiData.authority = argv.authority;
}

const result = await validateVMC(bimiData);
process.stdout.write(JSON.stringify(result.authority, false, 2) + '\n');
};

module.exports = cmd;
4 changes: 2 additions & 2 deletions lib/mailauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { dkimVerify } = require('./dkim/verify');
const { spf } = require('./spf');
const { dmarc } = require('./dmarc');
const { arc, createSeal } = require('./arc');
const { bimi } = require('./bimi');
const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
const { parseReceived } = require('./parse-received');
const { sealMessage } = require('./arc');
const libmime = require('libmime');
Expand Down Expand Up @@ -180,4 +180,4 @@ const authenticate = async (input, opts) => {
};
};

module.exports = { authenticate, sealMessage };
module.exports = { authenticate, sealMessage, validateBimiVmc };
Loading

0 comments on commit 25839ab

Please sign in to comment.