UNPKG

mailauth

Version:

Email authentication library for Node.js

536 lines (443 loc) 18 kB
'use strict'; const { Buffer } = require('node:buffer'); const { parseDkimHeaders, formatRelaxedLine, getPublicKey, formatAuthHeaderRow, formatSignatureHeaderLine, writeToStream, validateAlgorithm } = require('../../lib/tools'); const crypto = require('node:crypto'); const { DkimSigner } = require('../dkim/dkim-signer'); const verifyAS = async (chain, opts) => { const { resolver } = opts || {}; let chunks = []; let signatureHeader; for (let i = 0; i < chain.length; i++) { let isLast = i === chain.length - 1; let link = chain[i]; chunks.push(formatRelaxedLine(link['arc-authentication-results'].original, '\r\n')); chunks.push(formatRelaxedLine(link['arc-message-signature'].original, '\r\n')); if (!isLast) { chunks.push(formatRelaxedLine(link['arc-seal'].original, '\r\n')); } else { signatureHeader = link['arc-seal']; if (!signatureHeader.parsed?.s?.value || !signatureHeader.parsed?.d?.value) { let err = new Error(`Invalid ARC-Seal header`); err.code = 'invalid_arc_seal'; throw err; } chunks.push( Buffer.from( formatRelaxedLine(link['arc-seal'].original) .toString('binary') // remove value from b= key .replace(/([;:\s]+b=)[^;]+/, '$1'), 'binary' ) ); } } let canonicalizedHeader = Buffer.concat(chunks); let publicKey; let queryDomain = `${signatureHeader.parsed?.s?.value}._domainkey.${signatureHeader.parsed?.d?.value}`; try { let res = await getPublicKey('AS', queryDomain, opts.minBitLength, resolver); publicKey = res?.publicKey; } catch (err) { err.queryDomain = queryDomain; } let pass = crypto.verify( signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null, canonicalizedHeader, publicKey, Buffer.from(signatureHeader.parsed?.b?.value, 'base64') ); if (!pass) { let err = new Error(`i=${chain.length} seal signature validation failed`); err.code = 'failing_arc_seal'; throw err; } return true; }; const signAS = async (chain, entry, signatureData) => { let { instance, algorithm, selector, signingDomain, bodyHash, cv, signTime, privateKey } = signatureData; instance = instance || 1; const signAlgo = algorithm?.split('-').shift(); signTime = signTime || new Date(); let chunks = []; if (signatureData.cv === 'pass') { // sign existing only chain for passing validation for (let i = 0; i < chain.length; i++) { let link = chain[i]; chunks.push(formatRelaxedLine(link['arc-authentication-results'].original, '\r\n')); chunks.push(formatRelaxedLine(link['arc-message-signature'].original, '\r\n')); chunks.push(formatRelaxedLine(link['arc-seal'].original, '\r\n')); } } chunks.push(formatRelaxedLine(entry['arc-authentication-results'], '\r\n')); chunks.push(formatRelaxedLine(entry['arc-message-signature'], '\r\n')); let headerOpts = { i: instance, a: algorithm, s: selector, d: signingDomain, cv, bh: bodyHash }; if (signTime) { if (typeof signTime === 'string' || typeof signTime === 'number') { signTime = new Date(signTime); } if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') { // we need a unix timestamp value signTime = Math.round(signTime.getTime() / 1000); headerOpts.t = signTime; } } let canonSignatureHeaderLine = formatSignatureHeaderLine( 'AS', Object.assign( { // make sure that b= always has a value, otherwise folding would be different b: 'a'.repeat(73) }, headerOpts ), true ); chunks.push( Buffer.from( formatRelaxedLine(canonSignatureHeaderLine) .toString('binary') // remove value from b= key .replace(/([;:\s]+b=)[^;]+/, '$1'), 'binary' ) ); let canonicalizedHeader = Buffer.concat(chunks); let signature = crypto .sign( // use `null` as algorithm to detect it from the key file signAlgo === 'rsa' ? algorithm : null, signAlgo === 'rsa' ? canonicalizedHeader : crypto.createHash('sha256').update(canonicalizedHeader).digest(), privateKey ) .toString('base64'); headerOpts.b = signature; return formatSignatureHeaderLine('AS', headerOpts, true); }; const verifyASChain = async (data, opts) => { if (!data?.chain?.length) { return false; } for (let i = data.chain.length - 1; i >= 0; i--) { if (!['none', 'pass'].includes(data.chain[i]?.['arc-seal']?.parsed?.cv?.value)) { // no need to look further // validate this header set only // TODO: what should we report as the result? await verifyAS([data.chain[i]], opts); break; } // throws if validation fails await verifyAS(data.chain.slice(0, i + 1), opts); } return true; }; const getARChain = headers => { let headerRows = (headers && headers.parsed) || []; let arcChain = new Map(); for (let row of headerRows) { if (['arc-seal', 'arc-message-signature', 'arc-authentication-results'].includes(row.key)) { let value = parseDkimHeaders(row.line); let instance = value?.parsed?.i?.value; if (instance) { if (!arcChain.has(instance)) { arcChain.set(instance, { i: instance }); } else if (arcChain.get(instance)[row.key]) { // value for this header is already set let err = new Error(`i=${instance} multiple ${row.key} values`); err.code = 'multiple_arc_keys'; throw err; } arcChain.get(instance)[row.key] = value; } } } arcChain = Array.from(arcChain.values()).sort((a, b) => a.i - b.i); if (!arcChain.length) { // empty chain return false; } if (arcChain.length > 50) { let err = new Error(`chain-length=${arcChain.length}`); err.code = 'invalid_arc_count'; throw err; } for (let i = 0; i < arcChain.length; i++) { const arcInstance = arcChain[i]; if (arcInstance.i !== i + 1) { // not a complete sequence let err = new Error(`i=${arcInstance.i} expected=${i + 1}`); err.code = 'invalid_arc_instance'; throw err; } for (let headerKey of ['arc-seal', 'arc-message-signature', 'arc-authentication-results']) { if (!arcInstance[headerKey]) { // missing required header let err = new Error(`i=${arcInstance.i} no ${headerKey} set`); err.code = 'missing_arc_header'; throw err; } } if (i === 0 && arcInstance['arc-seal']?.parsed?.cv?.value?.toLowerCase() !== 'none') { let err = new Error(`i=1 cv="${arcInstance['arc-seal']?.parsed?.cv?.value}`); err.code = 'invalid_cv_value'; throw err; } let asC = arcInstance['arc-seal']?.parsed?.c?.value?.toLowerCase().trim(); if (asC && asC !== 'relaxed/relaxed') { let err = new Error(`i=${arcInstance.i} invalid as c`); err.code = 'unexpected_as_c_value'; throw err; } // add missing c value if (arcInstance['arc-message-signature']?.parsed && !arcInstance['arc-message-signature']?.parsed?.c) { arcInstance['arc-message-signature'].parsed.c = { value: 'relaxed/relaxed' }; } if (arcInstance['arc-seal']?.parsed?.a && !arcInstance['arc-seal']?.parsed?.a?.value) { let err = new Error(`i=${arcInstance.i} empty a`); err.code = 'invalid_a_value'; throw err; } if (!arcInstance['arc-seal']?.parsed?.a?.value) { let err = new Error(`i=${arcInstance.i} missing a`); err.code = 'missing_a_value'; throw err; } // throws if using non-supported algorithm validateAlgorithm(arcInstance['arc-seal']?.parsed?.a?.value, true); validateAlgorithm(arcInstance['arc-message-signature']?.parsed?.a?.value, true); if (i > 0 && arcInstance['arc-seal']?.parsed?.cv?.value?.toLowerCase() !== 'pass') { let err = new Error(`i=${arcInstance.i} cv=${arcInstance['arc-seal']?.parsed?.cv?.value}`); err.code = 'invalid_cv_value'; throw err; } if (arcInstance['arc-seal']?.parsed?.h) { let err = new Error(`i=${arcInstance.i} unexpected as h`); err.code = 'unexpected_as_h_value'; throw err; } let amsH = arcInstance['arc-message-signature']?.parsed?.h?.value ?.trim() .toLowerCase() .split(':') .map(v => v.trim()) .filter(v => v); if (amsH?.some(v => v === 'arc-seal')) { let err = new Error(`i=${arcInstance.i} invalid ams h`); err.code = 'invalid_ams_h_value'; throw err; } } return arcChain; }; // {chain, last} const arc = async (data, opts) => { const status = { result: 'none' }; const result = { status }; Object.defineProperty(result, 'chain', { enumerable: false, configurable: false, writable: false, value: data.chain }); try { if (data.error) { // raise error from `getARChain` throw data.error; } let hasChain = await verifyASChain(data, opts); if (hasChain) { result.i = data?.lastEntry?.i || false; result.signature = data?.lastEntry?.messageSignature || false; if (result?.signature?.status?.result !== 'pass') { // no valid ARC-Message-Signature found let err = new Error(`i=${result.i} no valid signature`); err.code = 'missing_valid_ams'; throw err; } result.authenticationResults = data?.lastEntry?.['arc-authentication-results']?.parsed; if (result.authenticationResults) { delete result.authenticationResults.i; delete result.authenticationResults.header; if (result.authenticationResults.value) { let mta = result.authenticationResults.value; delete result.authenticationResults.value; result.authenticationResults = Object.assign({ mta }, result.authenticationResults); } ['arc', 'spf', 'dmarc'].forEach(key => { if (result.authenticationResults[key]) { let res = result.authenticationResults[key].value; delete result.authenticationResults[key].value; result.authenticationResults[key] = Object.assign({ result: res }, result.authenticationResults[key]); } }); if (result.authenticationResults.dkim && result.authenticationResults.dkim.length) { result.authenticationResults.dkim = result.authenticationResults.dkim.map(entry => { let result = entry.value; delete entry.value; return Object.assign({ result }, entry); }); } } status.result = 'pass'; } else { result.i = 0; status.result = 'none'; } } catch (err) { // all failures are permanent in the scope of ARC result.i = data?.lastEntry?.i || false; status.result = 'fail'; // if last entry was listed as passing then add our seal even if the validation failed status.shouldSeal = ['pass', 'none'].includes(data?.lastEntry?.['arc-seal']?.parsed?.cv?.value); switch (err.code) { case 'invalid_arc_seal': case 'failing_arc_seal': case 'multiple_arc_keys': case 'invalid_arc_count': case 'invalid_arc_instance': case 'missing_arc_header': case 'invalid_cv_value': case 'unexpected_as_h_value': case 'invalid_ams_h_value': case 'missing_valid_ams': case 'unexpected_as_c_value': case 'unexpected_ams_c_value': status.comment = err.message .toLowerCase() .replace(/["'()]/g, ' ') .replace(/\s+/g, ' ') .trim() .substr(0, 128); break; case 'ENOTFOUND': case 'ENODATA': if (err.queryDomain) { status.comment = `no key for ${err.queryDomain}`; } break; case 'EINVALIDVER': if (err.queryDomain) { status.comment = `unknown key version for ${err.queryDomain}`; } break; case 'EINVALIDTYPE': if (err.queryDomain) { status.comment = `unknown key type for ${err.queryDomain}`; } break; case 'EINVALIDVAL': if (err.queryDomain) { status.comment = `invalid public key for ${err.queryDomain}`; } break; case 'ESHORTKEY': status.policy['dkim-rules'] = `weak-key`; if (err.queryDomain) { status.comment = `weak key for ${err.queryDomain}`; } break; } } if (status.result !== 'none') { if (status.result === 'pass' && result.authenticationResults) { let comment = [`i=${result.i}`, result.authenticationResults.spf ? `spf=${result.authenticationResults.spf.result}` : false]; if (result.authenticationResults.dkim && result.authenticationResults.dkim.length) { for (let entry of result.authenticationResults.dkim) { comment.push(`dkim=${entry.result}`); if (entry?.header?.i) { comment.push(`dkdomain=${entry.header.i.replace(/^@/, '')}`); } } } if (result.authenticationResults.dmarc) { comment.push(`dmarc=${result.authenticationResults.dmarc.result}`); if (result.authenticationResults.dmarc?.header?.from) { comment.push(`fromdomain=${result.authenticationResults.dmarc?.header?.from}`); } } status.comment = comment.filter(v => v).join(' '); } result.info = formatAuthHeaderRow('arc', status); } return result; }; const createSeal = async (input, data) => { let { headers, arc, seal } = data; let bodyHash = seal?.bodyHash; // Step 1. Calculate ARC-Message-Signature let dkimSigner = new DkimSigner({ // headers and bodyHash are prepared values if we do not have the source message anymore headers, bodyHash: seal.bodyHash, signTime: seal.signTime, // which headers to sign headerList: seal.headerList, arc: { instance: seal.i, // overriden if not set algorithm: seal.algorithm, signingDomain: seal.signingDomain, selector: seal.selector, privateKey: seal.privateKey }, getARChain // pass as a property so we do not have to use circular require() }); if (input) { await writeToStream(dkimSigner, input); let { hashAlgo } = dkimSigner.getAlgorithm(seal); let { bodyCanon } = dkimSigner.getCanonicalization(seal); let hashKey = `${bodyCanon}:${hashAlgo}:`; bodyHash = dkimSigner.bodyHashes.get(hashKey)?.hash; arc = arc || dkimSigner.arc; seal.i = arc.instance; } else { // this gives us dkimSigner.arc.messageSignature await dkimSigner.finalize(); } seal.i = seal.i || 1; const authResults = `ARC-Authentication-Results: i=${seal.i}; ${seal.authResults}`; // Step 2. Calculate ARC-Seal const arcSeal = await signAS( arc.chain, { 'arc-authentication-results': authResults, 'arc-message-signature': dkimSigner.arc?.messageSignature }, { instance: seal.i, algorithm: seal.algorithm || 'rsa-sha256', signingDomain: seal.signingDomain, selector: seal.selector, bodyHash, cv: seal.cv, signTime: seal.signTime, privateKey: seal.privateKey } ); return { headers: [arcSeal, dkimSigner?.arc?.messageSignature, authResults].map(v => v) }; }; const sealMessage = async (input, seal) => { const { headers } = await createSeal(input, { seal }); return headers.length ? Buffer.from(headers.join('\r\n') + '\r\n') : Buffer.from(''); }; module.exports = { getARChain, verifyASChain, arc, createSeal, sealMessage };