2024-02-16 13:41:04 +01:00
|
|
|
import * as plugins from '../plugins.js';
|
2025-03-15 16:21:37 +00:00
|
|
|
import { MtaService } from './classes.mta.js';
|
2025-05-07 17:41:04 +00:00
|
|
|
import { logger } from '../logger.js';
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-05-07 17:41:04 +00:00
|
|
|
/**
|
|
|
|
* 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 {
|
2024-02-16 20:42:26 +01:00
|
|
|
public mtaRef: MtaService;
|
2025-05-07 17:41:04 +00:00
|
|
|
|
|
|
|
// 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
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2024-02-16 20:42:26 +01:00
|
|
|
constructor(mtaRefArg: MtaService) {
|
2024-02-16 13:28:40 +01:00
|
|
|
this.mtaRef = mtaRefArg;
|
|
|
|
}
|
|
|
|
|
2025-05-07 17:41:04 +00:00
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-05-07 17:41:04 +00:00
|
|
|
// 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> {
|
2024-02-16 13:28:40 +01:00
|
|
|
try {
|
2025-05-07 17:41:04 +00:00
|
|
|
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('')));
|
|
|
|
});
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
2025-05-07 17:41:04 +00:00
|
|
|
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
}
|
2025-05-07 17:41:04 +00:00
|
|
|
|
|
|
|
logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`);
|
|
|
|
return null;
|
2024-02-16 13:28:40 +01:00
|
|
|
} catch (error) {
|
2025-05-07 17:41:04 +00:00
|
|
|
logger.log('error', `Error fetching DKIM key: ${error.message}`);
|
|
|
|
return null;
|
2024-02-16 13:28:40 +01:00
|
|
|
}
|
|
|
|
}
|
2025-05-07 17:41:04 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
|
|
|
}
|