'use strict'; const packageData = require('../package.json'); const https = require('https'); const { validateSvg } = require('mailauth/lib/bimi/validate-svg'); const { vmc } = require('@postalsys/vmc'); const { formatDomain, getAlignment } = require('mailauth/lib/tools'); const { bimi: bimiLookup } = require('mailauth/lib/bimi'); const crypto = require('crypto'); const log = require('npmlog'); class BimiHandler { static create(options = {}) { return new BimiHandler(options); } constructor(options) { this.options = options || {}; this.database = options.database; this.loggelf = options.loggelf || (() => false); } async download(url, bimiDocument, bimiType, bimiDomain) { if (!url) { return false; } bimiDocument = bimiDocument || {}; const parsedUrl = new URL(url); let protoHandler; switch (parsedUrl.protocol) { case 'https:': protoHandler = https; break; case 'http:': { let error = new Error(`Only HTTPS addresses are allowed`); error.code = 'PROTO_NOT_HTTPS'; error.source = 'pre-request'; throw error; } default: { let error = new Error(`Unknown protocol ${parsedUrl.protocol}`); error.code = 'UNKNOWN_PROTO'; error.source = 'pre-request'; throw error; } } const headers = { host: parsedUrl.host, 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage}` }; if (bimiDocument.etag) { headers['If-None-Match'] = bimiDocument.etag; } if (bimiDocument.lastModified) { headers['If-Modified-Since'] = bimiDocument.lastModified; } const options = { protocol: parsedUrl.protocol, host: parsedUrl.host, headers, servername: parsedUrl.hostname, port: 443, path: parsedUrl.pathname, method: 'GET', rejectUnauthorized: true }; return new Promise((resolve, reject) => { 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 content = Buffer.concat(chunks, chunklen); this.loggelf({ short_message: `[BIMI FETCH] ${url}`, _mail_action: 'bimi_fetch', _bimi_url: url, _bimi_type: bimiType, _bimi_domain: bimiDomain, _status_code: res?.statusCode, _req_etag: bimiDocument.etag, _req_last_modified: bimiDocument.lastModified, _res_etag: res?.headers?.etag, _res_last_modified: res?.headers['last-modified'] }); if (res?.statusCode === 304) { // no changes let err = new Error('No changes'); err.code = 'NO_CHANGES'; return reject(err); } if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { let err = new Error(`Invalid response code ${res.statusCode || '-'}`); if (res.headers.location && res.statusCode >= 300 && res.statusCode < 400) { err.code = 'REDIRECT_NOT_ALLOWED'; } else { err.code = 'HTTP_STATUS_' + (res.statusCode || 'NA'); } err.details = err.details || { code: res.statusCode, url, etag: bimiDocument.etag, lastModified: bimiDocument.lastModified, location: res.headers?.location }; return reject(err); } resolve({ content, etag: res.headers.etag, lastModified: res.headers['last-modified'] }); }); res.on('error', err => { this.loggelf({ short_message: `[BIMI FETCH] ${url}`, _mail_action: 'bimi_fetch', _bimi_url: url, _bimi_type: bimiType, _bimi_domain: bimiDomain, _req_etag: bimiDocument.etag, _req_last_modified: bimiDocument.lastModified, _failure: 'yes', _error: err.message, _err_code: err.code }); reject(err); }); }); req.on('error', err => { reject(err); }); req.end(); }); } async getBimiData(url, type, bimiDomain) { if (!url) { return false; } let bimiDocument = await this.database.collection('bimi').findOne({ url, type }); let bimiTtl = bimiDocument?.error ? 1 * 3600 * 1000 : 24 * 3600 * 1000; if (bimiDocument && bimiDocument?.updated > new Date(Date.now() - bimiTtl)) { if (bimiDocument.error) { let error = new Error(bimiDocument.error.message); if (bimiDocument.error.details) { error.details = bimiDocument.error.details; } if (bimiDocument.error.code) { error.code = bimiDocument.error.code; } error.source = 'db'; throw error; } if (bimiDocument?.content?.buffer) { bimiDocument.content = bimiDocument.content.buffer; } bimiDocument.source = 'db'; return bimiDocument; } let bimiDocumentUpdate = { updated: new Date() }; // Step 1. Download let file; try { let { content, etag, lastModified } = await this.download(url, bimiDocument, type, bimiDomain); bimiDocumentUpdate.etag = etag || null; bimiDocumentUpdate.lastModified = lastModified || null; file = content; } catch (err) { if (err.code === 'NO_CHANGES') { // existing document is good enough, proceed to checkout let r = await this.database.collection('bimi').findOneAndUpdate( { type, url }, { $set: bimiDocumentUpdate, $setOnInsert: { url, type, created: new Date() } }, { upsert: true, returnDocument: 'after' } ); let updatedBimiDocument = r?.value; if (updatedBimiDocument?.content?.buffer) { updatedBimiDocument.content = updatedBimiDocument.content.buffer; } if (bimiDocument.error) { let error = new Error(bimiDocument.error.message); if (bimiDocument.error.details) { error.details = bimiDocument.error.details; } if (bimiDocument.error.code) { error.code = bimiDocument.error.code; } error.source = 'cache-hit'; throw error; } updatedBimiDocument.source = 'cache-hit'; return updatedBimiDocument; } else { bimiDocumentUpdate.error = { message: err.message, details: err.details, code: err.code }; try { await this.database.collection('bimi').updateOne( { url, type }, { $set: bimiDocumentUpdate, $setOnInsert: { type, url, created: new Date() } }, { upsert: true } ); } catch (err) { // ignore } err.source = 'post-request'; throw err; } } // Step 2. Validate VMC if (type === 'authority') { try { let vmcData = await vmc(file); if (!vmcData.logoFile) { let error = new Error('VMC does not contain a logo file'); error.code = 'MISSING_VMC_LOGO'; error.source = 'post-request'; throw error; } if (vmcData?.mediaType?.toLowerCase() !== 'image/svg+xml') { let error = new Error('Invalid media type for the logo file'); error.details = { mediaType: vmcData.mediaType }; error.code = 'INVALID_MEDIATYPE'; error.source = 'post-request'; throw error; } if (!vmcData.validHash) { let error = new Error('VMC hash does not match logo file'); error.details = { hashAlgo: vmcData.hashAlgo, hashValue: vmcData.hashValue, logoFile: vmcData.logoFile }; error.code = 'INVALID_LOGO_HASH'; error.source = 'post-request'; throw error; } bimiDocumentUpdate.content = Buffer.from(vmcData.logoFile, 'base64'); bimiDocumentUpdate.vmc = vmcData; } catch (err) { bimiDocumentUpdate.error = { message: err.message, details: err.details, code: err.code }; try { await this.database.collection('bimi').updateOne( { type, url }, { $set: bimiDocumentUpdate, $setOnInsert: { type, url, created: new Date() } }, { upsert: true } ); } catch (err) { // ignore } err.source = err.source || 'post-request'; throw err; } } else { bimiDocumentUpdate.content = file; } // Step 3. Validate SVG try { validateSvg(bimiDocumentUpdate.content); } catch (err) { let error = new Error('VMC logo SVG validation failed'); error.details = Object.assign( { message: err.message }, error.details || {}, err.code ? { code: err.code } : {} ); error.code = 'SVG_VALIDATION_FAILED'; bimiDocumentUpdate.error = { message: error.message, details: error.details, code: error.code }; try { await this.database.collection('bimi').updateOne( { type, url }, { $set: bimiDocumentUpdate, $setOnInsert: { type, url, created: new Date() } }, { upsert: true } ); } catch (err) { // ignore } error.source = 'post-request'; throw error; } // clear pending errors bimiDocumentUpdate.error = null; let r = await this.database.collection('bimi').findOneAndUpdate( { type, url }, { $set: bimiDocumentUpdate, $setOnInsert: { type, url, created: new Date() } }, { upsert: true, returnDocument: 'after' } ); let updatedBimiDocument = r?.value; if (updatedBimiDocument?.content?.buffer) { updatedBimiDocument.content = updatedBimiDocument.content.buffer; } updatedBimiDocument.source = 'new'; return updatedBimiDocument; } async getInfo(bimiData) { let [ { reason: locationError, value: locationValue, status: locationStatus }, { reason: authorityError, value: authorityValue, status: authorityStatus } ] = await Promise.allSettled([ this.getBimiData(bimiData.location, 'location', bimiData.status?.header?.d), this.getBimiData(bimiData.authority, 'authority', bimiData.status?.header?.d) ]); if (authorityError) { throw authorityError; } if (authorityStatus === 'fulfilled' && authorityValue) { let selector = bimiData.status?.header?.selector; let d = bimiData.status?.header?.d; // validate domain let selectorSet = []; let domainSet = []; authorityValue.vmc?.certificate?.subjectAltName?.map(formatDomain)?.forEach(domain => { if (/\b_bimi\./.test(domain)) { selectorSet.push(domain); } else { domainSet.push(domain); } }); let domainVerified = false; if (selector && selectorSet.includes(formatDomain(`${selector}._bimi.${d}`))) { domainVerified = true; } else { let alignedDomain = getAlignment(d, domainSet, false); if (alignedDomain) { domainVerified = true; } } if (!domainVerified) { let error = new Error('Domain can not be verified'); error.details = { subjectAltName: authorityValue.vmc?.certificate?.subjectAltName, selector, d }; error.code = 'VMC_DOMAIN_MISMATCH'; throw error; } if (locationStatus === 'fulfilled' && locationValue?.content && authorityValue.vmc?.hashAlgo && authorityValue.vmc?.validHash) { let hash = crypto .createHash(authorityValue.vmc.hashAlgo) //sss .update(locationValue.content) .digest('hex'); if (hash === authorityValue.vmc.hashValue) { // logo files match, so location URL is safe to use authorityValue.locationUrl = bimiData.location; } else { log.info( 'BIMI', 'Logo files from l= and a= do not match lh=%s ah=%s algo=%s d=%s', hash, authorityValue.vmc.hashValue, authorityValue.vmc.hashAlgo, d ); authorityValue.locationUrl = bimiData.location; } } return authorityValue; } // If signed VMC was ok, then ignore any errors from regular SVG as this would not be used anyway if (locationError) { throw locationError; } return locationStatus === 'fulfilled' && locationValue; } /** * Helper method to fetch BIMI info for a domain name and selector * @param {String} domain * @param {String} [selector] * @returns {Object} BIMI record */ async fetchByDomain(domain, selector) { const bimiVerificationResults = await bimiLookup({ dmarc: { status: { result: 'pass', header: { from: domain } }, domain, policy: 'reject' }, headers: { parsed: selector && selector !== 'default' ? [ { key: 'bimi-selector', line: `v=BIMI1; s=${selector}` } ] : [] } }); return await this.getInfo(bimiVerificationResults); } } module.exports = BimiHandler; /* const db = require('./db'); db.connect(() => { let bimi = BimiHandler.create({ database: db.database }); bimi.getInfo({ status: { header: { selector: 'default', d: 'zone.ee' }, result: 'pass' }, rr: 'v=BIMI1; l=https://zone.ee/common/img/zone_profile_square_bimi.svg;a=https://zone.ee/.well-known/bimi.pem', location: 'https://zone.ee/common/img/zone_profile_square_bimi.svg', authority: 'https://zone.ee/.well-known/bimi.pem', info: 'bimi=pass header.selector=default header.d=zone.ee' }) .then(result => console.log(require('util').inspect(result, false, 22))) .catch(err => console.error(err)) .finally(() => process.exit()); }); */