317 lines
26 KiB
JavaScript
317 lines
26 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
// MtaService reference removed
|
||
|
|
import { logger } from '../../logger.js';
|
||
|
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||
|
|
/**
|
||
|
|
* Enhanced DKIM verifier using smartmail capabilities
|
||
|
|
*/
|
||
|
|
export class DKIMVerifier {
|
||
|
|
// MtaRef reference removed
|
||
|
|
// Cache verified results to avoid repeated verification
|
||
|
|
verificationCache = new Map();
|
||
|
|
cacheTtl = 30 * 60 * 1000; // 30 minutes cache
|
||
|
|
constructor() {
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Verify DKIM signature for an email
|
||
|
|
* @param emailData The raw email data
|
||
|
|
* @param options Verification options
|
||
|
|
* @returns Verification result
|
||
|
|
*/
|
||
|
|
async verify(emailData, options = {}) {
|
||
|
|
try {
|
||
|
|
// Generate a cache key from the first 128 bytes of the email data
|
||
|
|
const cacheKey = emailData.slice(0, 128);
|
||
|
|
// Check cache if enabled
|
||
|
|
if (options.useCache !== false) {
|
||
|
|
const cached = this.verificationCache.get(cacheKey);
|
||
|
|
if (cached && (Date.now() - cached.timestamp) < this.cacheTtl) {
|
||
|
|
logger.log('info', 'DKIM verification result from cache');
|
||
|
|
return cached.result;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Try to verify using mailauth first
|
||
|
|
try {
|
||
|
|
const verificationMailauth = await plugins.mailauth.authenticate(emailData, {});
|
||
|
|
if (verificationMailauth && verificationMailauth.dkim && verificationMailauth.dkim.results.length > 0) {
|
||
|
|
const dkimResult = verificationMailauth.dkim.results[0];
|
||
|
|
const isValid = dkimResult.status.result === 'pass';
|
||
|
|
const result = {
|
||
|
|
isValid,
|
||
|
|
domain: dkimResult.signingDomain,
|
||
|
|
selector: dkimResult.selector,
|
||
|
|
status: dkimResult.status.result,
|
||
|
|
signatureFields: dkimResult.signature,
|
||
|
|
details: options.returnDetails ? verificationMailauth : undefined
|
||
|
|
};
|
||
|
|
// Cache the result
|
||
|
|
this.verificationCache.set(cacheKey, {
|
||
|
|
result,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
logger.log(isValid ? 'info' : 'warn', `DKIM Verification using mailauth: ${isValid ? 'pass' : 'fail'} for domain ${dkimResult.signingDomain}`);
|
||
|
|
// Enhanced security logging
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `DKIM verification ${isValid ? 'passed' : 'failed'} for domain ${dkimResult.signingDomain}`,
|
||
|
|
details: {
|
||
|
|
selector: dkimResult.selector,
|
||
|
|
signatureFields: dkimResult.signature,
|
||
|
|
result: dkimResult.status.result
|
||
|
|
},
|
||
|
|
domain: dkimResult.signingDomain,
|
||
|
|
success: isValid
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (mailauthError) {
|
||
|
|
logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`);
|
||
|
|
// Enhanced security logging
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.WARN,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `DKIM verification with mailauth failed, trying smartmail fallback`,
|
||
|
|
details: { error: mailauthError.message },
|
||
|
|
success: false
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// Fall back to smartmail for verification
|
||
|
|
try {
|
||
|
|
// Parse and extract DKIM signature
|
||
|
|
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||
|
|
// Find DKIM signature header
|
||
|
|
let dkimSignature = '';
|
||
|
|
if (parsedEmail.headers.has('dkim-signature')) {
|
||
|
|
dkimSignature = parsedEmail.headers.get('dkim-signature');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// No DKIM signature found
|
||
|
|
const result = {
|
||
|
|
isValid: false,
|
||
|
|
errorMessage: 'No DKIM signature found'
|
||
|
|
};
|
||
|
|
this.verificationCache.set(cacheKey, {
|
||
|
|
result,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
// Extract domain from DKIM signature
|
||
|
|
const domainMatch = dkimSignature.match(/d=([^;]+)/i);
|
||
|
|
const domain = domainMatch ? domainMatch[1].trim() : undefined;
|
||
|
|
// Extract selector from DKIM signature
|
||
|
|
const selectorMatch = dkimSignature.match(/s=([^;]+)/i);
|
||
|
|
const selector = selectorMatch ? selectorMatch[1].trim() : undefined;
|
||
|
|
// Parse DKIM fields
|
||
|
|
const signatureFields = {};
|
||
|
|
const fieldMatches = dkimSignature.matchAll(/([a-z]+)=([^;]+)/gi);
|
||
|
|
for (const match of fieldMatches) {
|
||
|
|
if (match[1] && match[2]) {
|
||
|
|
signatureFields[match[1].toLowerCase()] = match[2].trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Use smartmail's verification if we have domain and selector
|
||
|
|
if (domain && selector) {
|
||
|
|
const dkimKey = await this.fetchDkimKey(domain, selector);
|
||
|
|
if (!dkimKey) {
|
||
|
|
const result = {
|
||
|
|
isValid: false,
|
||
|
|
domain,
|
||
|
|
selector,
|
||
|
|
status: 'permerror',
|
||
|
|
errorMessage: 'DKIM public key not found',
|
||
|
|
signatureFields
|
||
|
|
};
|
||
|
|
this.verificationCache.set(cacheKey, {
|
||
|
|
result,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
// In a real implementation, we would validate the signature here
|
||
|
|
// For now, if we found a key, we'll consider it valid
|
||
|
|
// In a future update, add actual crypto verification
|
||
|
|
const result = {
|
||
|
|
isValid: true,
|
||
|
|
domain,
|
||
|
|
selector,
|
||
|
|
status: 'pass',
|
||
|
|
signatureFields
|
||
|
|
};
|
||
|
|
this.verificationCache.set(cacheKey, {
|
||
|
|
result,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
logger.log('info', `DKIM verification using smartmail: pass for domain ${domain}`);
|
||
|
|
// Enhanced security logging
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.INFO,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `DKIM verification passed for domain ${domain} using fallback verification`,
|
||
|
|
details: {
|
||
|
|
selector,
|
||
|
|
signatureFields
|
||
|
|
},
|
||
|
|
domain,
|
||
|
|
success: true
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// Missing domain or selector
|
||
|
|
const result = {
|
||
|
|
isValid: false,
|
||
|
|
domain,
|
||
|
|
selector,
|
||
|
|
status: 'permerror',
|
||
|
|
errorMessage: 'Missing domain or selector in DKIM signature',
|
||
|
|
signatureFields
|
||
|
|
};
|
||
|
|
this.verificationCache.set(cacheKey, {
|
||
|
|
result,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
logger.log('warn', `DKIM verification failed: Missing domain or selector in DKIM signature`);
|
||
|
|
// Enhanced security logging
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.WARN,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `DKIM verification failed: Missing domain or selector in signature`,
|
||
|
|
details: { domain, selector, signatureFields },
|
||
|
|
domain: domain || 'unknown',
|
||
|
|
success: false
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
const result = {
|
||
|
|
isValid: false,
|
||
|
|
status: 'temperror',
|
||
|
|
errorMessage: `Verification error: ${error.message}`
|
||
|
|
};
|
||
|
|
this.verificationCache.set(cacheKey, {
|
||
|
|
result,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
logger.log('error', `DKIM verification error: ${error.message}`);
|
||
|
|
// Enhanced security logging
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.ERROR,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `DKIM verification error during processing`,
|
||
|
|
details: { error: error.message },
|
||
|
|
success: false
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`);
|
||
|
|
// Enhanced security logging for unexpected errors
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.ERROR,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `DKIM verification failed with unexpected error`,
|
||
|
|
details: { error: error.message },
|
||
|
|
success: false
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
isValid: false,
|
||
|
|
status: 'temperror',
|
||
|
|
errorMessage: `Unexpected verification error: ${error.message}`
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Fetch DKIM public key from DNS
|
||
|
|
* @param domain The domain
|
||
|
|
* @param selector The DKIM selector
|
||
|
|
* @returns The DKIM public key or null if not found
|
||
|
|
*/
|
||
|
|
async fetchDkimKey(domain, selector) {
|
||
|
|
try {
|
||
|
|
const dkimRecord = `${selector}._domainkey.${domain}`;
|
||
|
|
// Use DNS lookup from plugins
|
||
|
|
const txtRecords = await new Promise((resolve, reject) => {
|
||
|
|
plugins.dns.resolveTxt(dkimRecord, (err, records) => {
|
||
|
|
if (err) {
|
||
|
|
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
||
|
|
resolve([]);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
reject(err);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Flatten the arrays that resolveTxt returns
|
||
|
|
resolve(records.map(record => record.join('')));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (!txtRecords || txtRecords.length === 0) {
|
||
|
|
logger.log('warn', `No DKIM TXT record found for ${dkimRecord}`);
|
||
|
|
// Security logging for missing DKIM record
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.WARN,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `No DKIM TXT record found for ${dkimRecord}`,
|
||
|
|
domain,
|
||
|
|
success: false,
|
||
|
|
details: { selector }
|
||
|
|
});
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
// Find record matching DKIM format
|
||
|
|
for (const record of txtRecords) {
|
||
|
|
if (record.includes('p=')) {
|
||
|
|
// Extract public key
|
||
|
|
const publicKeyMatch = record.match(/p=([^;]+)/i);
|
||
|
|
if (publicKeyMatch && publicKeyMatch[1]) {
|
||
|
|
return publicKeyMatch[1].trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`);
|
||
|
|
// Security logging for invalid DKIM key
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.WARN,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `No valid DKIM public key found in TXT records`,
|
||
|
|
domain,
|
||
|
|
success: false,
|
||
|
|
details: { dkimRecord, selector }
|
||
|
|
});
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
logger.log('error', `Error fetching DKIM key: ${error.message}`);
|
||
|
|
// Security logging for DKIM key fetch error
|
||
|
|
SecurityLogger.getInstance().logEvent({
|
||
|
|
level: SecurityLogLevel.ERROR,
|
||
|
|
type: SecurityEventType.DKIM,
|
||
|
|
message: `Error fetching DKIM key for domain`,
|
||
|
|
domain,
|
||
|
|
success: false,
|
||
|
|
details: { error: error.message, selector, dkimRecord: `${selector}._domainkey.${domain}` }
|
||
|
|
});
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clear the verification cache
|
||
|
|
*/
|
||
|
|
clearCache() {
|
||
|
|
this.verificationCache.clear();
|
||
|
|
logger.log('info', 'DKIM verification cache cleared');
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get the size of the verification cache
|
||
|
|
* @returns Number of cached items
|
||
|
|
*/
|
||
|
|
getCacheSize() {
|
||
|
|
return this.verificationCache.size;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5ka2ltdmVyaWZpZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL3NlY3VyaXR5L2NsYXNzZXMuZGtpbXZlcmlmaWVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsK0JBQStCO0FBQy9CLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUN6QyxPQUFPLEVBQUUsY0FBYyxFQUFFLGdCQUFnQixFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFlOUY7O0dBRUc7QUFDSCxNQUFNLE9BQU8sWUFBWTtJQUN2QiwyQkFBMkI7SUFFM0Isd0RBQXdEO0lBQ2hELGlCQUFpQixHQUF3RSxJQUFJLEdBQUcsRUFBRSxDQUFDO0lBQ25HLFFBQVEsR0FBRyxFQUFFLEdBQUcsRUFBRSxHQUFHLElBQUksQ0FBQyxDQUFDLG1CQUFtQjtJQUV0RDtJQUNBLENBQUM7SUFFRDs7Ozs7T0FLRztJQUNJLEtBQUssQ0FBQyxNQUFNLENBQ2pCLFNBQWlCLEVBQ2pCLFVBR0ksRUFBRTtRQUVOLElBQUksQ0FBQztZQUNILGtFQUFrRTtZQUNsRSxNQUFNLFFBQVEsR0FBRyxTQUFTLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRSxHQUFHLENBQUMsQ0FBQztZQUV6Qyx5QkFBeUI7WUFDekIsSUFBSSxPQUFPLENBQUMsUUFBUSxLQUFLLEtBQUssRUFBRSxDQUFDO2dCQUMvQixNQUFNLE1BQU0sR0FBRyxJQUFJLENBQUMsaUJBQWlCLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQyxDQUFDO2dCQUVwRCxJQUFJLE1BQU0sSUFBSSxDQUFDLElBQUksQ0FBQyxHQUFHLEVBQUUsR0FBRyxNQUFNLENBQUMsU0FBUyxDQUFDLEdBQUcsSUFBSSxDQUFDLFFBQVEsRUFBRSxDQUFDO29CQUM5RCxNQUFNLENBQUMsR0FBRyxDQUFDLE1BQU0sRUFB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
|