platformservice/ts/mta/classes.dkimverifier.ts

281 lines
8.8 KiB
TypeScript

import * as plugins from '../plugins.js';
import { MtaService } from './classes.mta.js';
import { logger } from '../logger.js';
/**
* Result of a DKIM verification
*/
export interface IDkimVerificationResult {
isValid: boolean;
domain?: string;
selector?: string;
status?: string;
details?: any;
errorMessage?: string;
signatureFields?: Record<string, string>;
}
/**
* Enhanced DKIM verifier using smartmail capabilities
*/
export class DKIMVerifier {
public mtaRef: MtaService;
// Cache verified results to avoid repeated verification
private verificationCache: Map<string, { result: IDkimVerificationResult, timestamp: number }> = new Map();
private cacheTtl = 30 * 60 * 1000; // 30 minutes cache
constructor(mtaRefArg: MtaService) {
this.mtaRef = mtaRefArg;
}
/**
* Verify DKIM signature for an email
* @param emailData The raw email data
* @param options Verification options
* @returns Verification result
*/
public async verify(
emailData: string,
options: {
useCache?: boolean;
returnDetails?: boolean;
} = {}
): Promise<IDkimVerificationResult> {
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: IDkimVerificationResult = {
isValid,
domain: dkimResult.domain,
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.domain}`);
return result;
}
} catch (mailauthError) {
logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`);
}
// 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') as string;
} else {
// No DKIM signature found
const result: IDkimVerificationResult = {
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: Record<string, string> = {};
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: IDkimVerificationResult = {
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: IDkimVerificationResult = {
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}`);
return result;
} else {
// Missing domain or selector
const result: IDkimVerificationResult = {
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`);
return result;
}
} catch (error) {
const result: IDkimVerificationResult = {
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}`);
return result;
}
} catch (error) {
logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`);
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
*/
private async fetchDkimKey(domain: string, selector: string): Promise<string | null> {
try {
const dkimRecord = `${selector}._domainkey.${domain}`;
// Use DNS lookup from plugins
const txtRecords = await new Promise<string[]>((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}`);
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}`);
return null;
} catch (error) {
logger.log('error', `Error fetching DKIM key: ${error.message}`);
return null;
}
}
/**
* Clear the verification cache
*/
public clearCache(): void {
this.verificationCache.clear();
logger.log('info', 'DKIM verification cache cleared');
}
/**
* Get the size of the verification cache
* @returns Number of cached items
*/
public getCacheSize(): number {
return this.verificationCache.size;
}
}