348 lines
30 KiB
JavaScript
348 lines
30 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import * as paths from '../../paths.js';
|
||
|
|
import { Email } from '../core/classes.email.js';
|
||
|
|
// MtaService reference removed
|
||
|
|
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
||
|
|
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
||
|
|
const generateKeyPair = plugins.util.promisify(plugins.crypto.generateKeyPair);
|
||
|
|
export class DKIMCreator {
|
||
|
|
keysDir;
|
||
|
|
storageManager; // StorageManager instance
|
||
|
|
constructor(keysDir = paths.keysDir, storageManager) {
|
||
|
|
this.keysDir = keysDir;
|
||
|
|
this.storageManager = storageManager;
|
||
|
|
}
|
||
|
|
async getKeyPathsForDomain(domainArg) {
|
||
|
|
return {
|
||
|
|
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
|
||
|
|
publicKeyPath: plugins.path.join(this.keysDir, `${domainArg}-public.pem`),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// Check if a DKIM key is present and creates one and stores it to disk otherwise
|
||
|
|
async handleDKIMKeysForDomain(domainArg) {
|
||
|
|
try {
|
||
|
|
await this.readDKIMKeys(domainArg);
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
console.log(`No DKIM keys found for ${domainArg}. Generating...`);
|
||
|
|
await this.createAndStoreDKIMKeys(domainArg);
|
||
|
|
const dnsValue = await this.getDNSRecordForDomain(domainArg);
|
||
|
|
await plugins.smartfs.directory(paths.dnsRecordsDir).recursive().create();
|
||
|
|
await plugins.smartfs.file(plugins.path.join(paths.dnsRecordsDir, `${domainArg}.dkimrecord.json`)).write(JSON.stringify(dnsValue, null, 2));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async handleDKIMKeysForEmail(email) {
|
||
|
|
const domain = email.from.split('@')[1];
|
||
|
|
await this.handleDKIMKeysForDomain(domain);
|
||
|
|
}
|
||
|
|
// Read DKIM keys - always use storage manager, migrate from filesystem if needed
|
||
|
|
async readDKIMKeys(domainArg) {
|
||
|
|
// Try to read from storage manager first
|
||
|
|
if (this.storageManager) {
|
||
|
|
try {
|
||
|
|
const [privateKey, publicKey] = await Promise.all([
|
||
|
|
this.storageManager.get(`/email/dkim/${domainArg}/private.key`),
|
||
|
|
this.storageManager.get(`/email/dkim/${domainArg}/public.key`)
|
||
|
|
]);
|
||
|
|
if (privateKey && publicKey) {
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
// Fall through to migration check
|
||
|
|
}
|
||
|
|
// Check if keys exist in filesystem and migrate them to storage manager
|
||
|
|
const keyPaths = await this.getKeyPathsForDomain(domainArg);
|
||
|
|
try {
|
||
|
|
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||
|
|
readFile(keyPaths.privateKeyPath),
|
||
|
|
readFile(keyPaths.publicKeyPath),
|
||
|
|
]);
|
||
|
|
// Convert the buffers to strings
|
||
|
|
const privateKey = privateKeyBuffer.toString();
|
||
|
|
const publicKey = publicKeyBuffer.toString();
|
||
|
|
// Migrate to storage manager
|
||
|
|
console.log(`Migrating DKIM keys for ${domainArg} from filesystem to StorageManager`);
|
||
|
|
await Promise.all([
|
||
|
|
this.storageManager.set(`/email/dkim/${domainArg}/private.key`, privateKey),
|
||
|
|
this.storageManager.set(`/email/dkim/${domainArg}/public.key`, publicKey)
|
||
|
|
]);
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
if (error.code === 'ENOENT') {
|
||
|
|
// Keys don't exist anywhere
|
||
|
|
throw new Error(`DKIM keys not found for domain ${domainArg}`);
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// No storage manager, use filesystem directly
|
||
|
|
const keyPaths = await this.getKeyPathsForDomain(domainArg);
|
||
|
|
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||
|
|
readFile(keyPaths.privateKeyPath),
|
||
|
|
readFile(keyPaths.publicKeyPath),
|
||
|
|
]);
|
||
|
|
const privateKey = privateKeyBuffer.toString();
|
||
|
|
const publicKey = publicKeyBuffer.toString();
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Create a DKIM key pair - changed to public for API access
|
||
|
|
async createDKIMKeys() {
|
||
|
|
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||
|
|
modulusLength: 2048,
|
||
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||
|
|
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||
|
|
});
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
// Store a DKIM key pair - uses storage manager if available, else disk
|
||
|
|
async storeDKIMKeys(privateKey, publicKey, privateKeyPath, publicKeyPath) {
|
||
|
|
// Store in storage manager if available
|
||
|
|
if (this.storageManager) {
|
||
|
|
// Extract domain from path (e.g., /path/to/keys/example.com-private.pem -> example.com)
|
||
|
|
const match = privateKeyPath.match(/\/([^\/]+)-private\.pem$/);
|
||
|
|
if (match) {
|
||
|
|
const domain = match[1];
|
||
|
|
await Promise.all([
|
||
|
|
this.storageManager.set(`/email/dkim/${domain}/private.key`, privateKey),
|
||
|
|
this.storageManager.set(`/email/dkim/${domain}/public.key`, publicKey)
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Also store to filesystem for backward compatibility
|
||
|
|
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
|
||
|
|
}
|
||
|
|
// Create a DKIM key pair and store it to disk - changed to public for API access
|
||
|
|
async createAndStoreDKIMKeys(domain) {
|
||
|
|
const { privateKey, publicKey } = await this.createDKIMKeys();
|
||
|
|
const keyPaths = await this.getKeyPathsForDomain(domain);
|
||
|
|
await this.storeDKIMKeys(privateKey, publicKey, keyPaths.privateKeyPath, keyPaths.publicKeyPath);
|
||
|
|
console.log(`DKIM keys for ${domain} created and stored.`);
|
||
|
|
}
|
||
|
|
// Changed to public for API access
|
||
|
|
async getDNSRecordForDomain(domainArg) {
|
||
|
|
await this.handleDKIMKeysForDomain(domainArg);
|
||
|
|
const keys = await this.readDKIMKeys(domainArg);
|
||
|
|
// Remove the PEM header and footer and newlines
|
||
|
|
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||
|
|
const pemFooter = '-----END PUBLIC KEY-----';
|
||
|
|
const keyContents = keys.publicKey
|
||
|
|
.replace(pemHeader, '')
|
||
|
|
.replace(pemFooter, '')
|
||
|
|
.replace(/\n/g, '');
|
||
|
|
// Now generate the DKIM DNS TXT record
|
||
|
|
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||
|
|
return {
|
||
|
|
name: `mta._domainkey.${domainArg}`,
|
||
|
|
type: 'TXT',
|
||
|
|
dnsSecEnabled: null,
|
||
|
|
value: dnsRecordValue,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get DKIM key metadata for a domain
|
||
|
|
*/
|
||
|
|
async getKeyMetadata(domain, selector = 'default') {
|
||
|
|
if (!this.storageManager) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const metadataKey = `/email/dkim/${domain}/${selector}/metadata`;
|
||
|
|
const metadataStr = await this.storageManager.get(metadataKey);
|
||
|
|
if (!metadataStr) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return JSON.parse(metadataStr);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Save DKIM key metadata
|
||
|
|
*/
|
||
|
|
async saveKeyMetadata(metadata) {
|
||
|
|
if (!this.storageManager) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const metadataKey = `/email/dkim/${metadata.domain}/${metadata.selector}/metadata`;
|
||
|
|
await this.storageManager.set(metadataKey, JSON.stringify(metadata));
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if DKIM keys need rotation
|
||
|
|
*/
|
||
|
|
async needsRotation(domain, selector = 'default', rotationIntervalDays = 90) {
|
||
|
|
const metadata = await this.getKeyMetadata(domain, selector);
|
||
|
|
if (!metadata) {
|
||
|
|
// No metadata means old keys, should rotate
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
const now = Date.now();
|
||
|
|
const keyAgeMs = now - metadata.createdAt;
|
||
|
|
const keyAgeDays = keyAgeMs / (1000 * 60 * 60 * 24);
|
||
|
|
return keyAgeDays >= rotationIntervalDays;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Rotate DKIM keys for a domain
|
||
|
|
*/
|
||
|
|
async rotateDkimKeys(domain, currentSelector = 'default', keySize = 2048) {
|
||
|
|
console.log(`Rotating DKIM keys for ${domain}...`);
|
||
|
|
// Generate new selector based on date
|
||
|
|
const now = new Date();
|
||
|
|
const newSelector = `key${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||
|
|
// Create new keys with custom key size
|
||
|
|
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||
|
|
modulusLength: keySize,
|
||
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||
|
|
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||
|
|
});
|
||
|
|
// Store new keys with new selector
|
||
|
|
const newKeyPaths = await this.getKeyPathsForSelector(domain, newSelector);
|
||
|
|
// Store in storage manager if available
|
||
|
|
if (this.storageManager) {
|
||
|
|
await Promise.all([
|
||
|
|
this.storageManager.set(`/email/dkim/${domain}/${newSelector}/private.key`, privateKey),
|
||
|
|
this.storageManager.set(`/email/dkim/${domain}/${newSelector}/public.key`, publicKey)
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
// Also store to filesystem
|
||
|
|
await this.storeDKIMKeys(privateKey, publicKey, newKeyPaths.privateKeyPath, newKeyPaths.publicKeyPath);
|
||
|
|
// Save metadata for new keys
|
||
|
|
const metadata = {
|
||
|
|
domain,
|
||
|
|
selector: newSelector,
|
||
|
|
createdAt: Date.now(),
|
||
|
|
previousSelector: currentSelector,
|
||
|
|
keySize
|
||
|
|
};
|
||
|
|
await this.saveKeyMetadata(metadata);
|
||
|
|
// Update metadata for old keys
|
||
|
|
const oldMetadata = await this.getKeyMetadata(domain, currentSelector);
|
||
|
|
if (oldMetadata) {
|
||
|
|
oldMetadata.rotatedAt = Date.now();
|
||
|
|
await this.saveKeyMetadata(oldMetadata);
|
||
|
|
}
|
||
|
|
console.log(`DKIM keys rotated for ${domain}. New selector: ${newSelector}`);
|
||
|
|
return newSelector;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get key paths for a specific selector
|
||
|
|
*/
|
||
|
|
async getKeyPathsForSelector(domain, selector) {
|
||
|
|
return {
|
||
|
|
privateKeyPath: plugins.path.join(this.keysDir, `${domain}-${selector}-private.pem`),
|
||
|
|
publicKeyPath: plugins.path.join(this.keysDir, `${domain}-${selector}-public.pem`),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Read DKIM keys for a specific selector
|
||
|
|
*/
|
||
|
|
async readDKIMKeysForSelector(domain, selector) {
|
||
|
|
// Try to read from storage manager first
|
||
|
|
if (this.storageManager) {
|
||
|
|
try {
|
||
|
|
const [privateKey, publicKey] = await Promise.all([
|
||
|
|
this.storageManager.get(`/email/dkim/${domain}/${selector}/private.key`),
|
||
|
|
this.storageManager.get(`/email/dkim/${domain}/${selector}/public.key`)
|
||
|
|
]);
|
||
|
|
if (privateKey && publicKey) {
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
// Fall through to migration check
|
||
|
|
}
|
||
|
|
// Check if keys exist in filesystem and migrate them to storage manager
|
||
|
|
const keyPaths = await this.getKeyPathsForSelector(domain, selector);
|
||
|
|
try {
|
||
|
|
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||
|
|
readFile(keyPaths.privateKeyPath),
|
||
|
|
readFile(keyPaths.publicKeyPath),
|
||
|
|
]);
|
||
|
|
const privateKey = privateKeyBuffer.toString();
|
||
|
|
const publicKey = publicKeyBuffer.toString();
|
||
|
|
// Migrate to storage manager
|
||
|
|
console.log(`Migrating DKIM keys for ${domain}/${selector} from filesystem to StorageManager`);
|
||
|
|
await Promise.all([
|
||
|
|
this.storageManager.set(`/email/dkim/${domain}/${selector}/private.key`, privateKey),
|
||
|
|
this.storageManager.set(`/email/dkim/${domain}/${selector}/public.key`, publicKey)
|
||
|
|
]);
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
if (error.code === 'ENOENT') {
|
||
|
|
throw new Error(`DKIM keys not found for domain ${domain} with selector ${selector}`);
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// No storage manager, use filesystem directly
|
||
|
|
const keyPaths = await this.getKeyPathsForSelector(domain, selector);
|
||
|
|
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||
|
|
readFile(keyPaths.privateKeyPath),
|
||
|
|
readFile(keyPaths.publicKeyPath),
|
||
|
|
]);
|
||
|
|
const privateKey = privateKeyBuffer.toString();
|
||
|
|
const publicKey = publicKeyBuffer.toString();
|
||
|
|
return { privateKey, publicKey };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get DNS record for a specific selector
|
||
|
|
*/
|
||
|
|
async getDNSRecordForSelector(domain, selector) {
|
||
|
|
const keys = await this.readDKIMKeysForSelector(domain, selector);
|
||
|
|
// Remove the PEM header and footer and newlines
|
||
|
|
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||
|
|
const pemFooter = '-----END PUBLIC KEY-----';
|
||
|
|
const keyContents = keys.publicKey
|
||
|
|
.replace(pemHeader, '')
|
||
|
|
.replace(pemFooter, '')
|
||
|
|
.replace(/\n/g, '');
|
||
|
|
// Generate the DKIM DNS TXT record
|
||
|
|
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||
|
|
return {
|
||
|
|
name: `${selector}._domainkey.${domain}`,
|
||
|
|
type: 'TXT',
|
||
|
|
dnsSecEnabled: null,
|
||
|
|
value: dnsRecordValue,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clean up old DKIM keys after grace period
|
||
|
|
*/
|
||
|
|
async cleanupOldKeys(domain, gracePeriodDays = 30) {
|
||
|
|
if (!this.storageManager) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// List all selectors for the domain
|
||
|
|
const metadataKeys = await this.storageManager.list(`/email/dkim/${domain}/`);
|
||
|
|
for (const key of metadataKeys) {
|
||
|
|
if (key.endsWith('/metadata')) {
|
||
|
|
const metadataStr = await this.storageManager.get(key);
|
||
|
|
if (metadataStr) {
|
||
|
|
const metadata = JSON.parse(metadataStr);
|
||
|
|
// Check if key is rotated and past grace period
|
||
|
|
if (metadata.rotatedAt) {
|
||
|
|
const gracePeriodMs = gracePeriodDays * 24 * 60 * 60 * 1000;
|
||
|
|
const now = Date.now();
|
||
|
|
if (now - metadata.rotatedAt > gracePeriodMs) {
|
||
|
|
console.log(`Cleaning up old DKIM keys for ${domain} selector ${metadata.selector}`);
|
||
|
|
// Delete key files
|
||
|
|
const keyPaths = await this.getKeyPathsForSelector(domain, metadata.selector);
|
||
|
|
try {
|
||
|
|
await plugins.fs.promises.unlink(keyPaths.privateKeyPath);
|
||
|
|
await plugins.fs.promises.unlink(keyPaths.publicKeyPath);
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
console.warn(`Failed to delete old key files: ${error.message}`);
|
||
|
|
}
|
||
|
|
// Delete metadata
|
||
|
|
await this.storageManager.delete(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5ka2ltY3JlYXRvci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzL21haWwvc2VjdXJpdHkvY2xhc3Nlcy5ka2ltY3JlYXRvci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGtCQUFrQixDQUFDO0FBQzVDLE9BQU8sS0FBSyxLQUFLLE1BQU0sZ0JBQWdCLENBQUM7QUFFeEMsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBQ2pELCtCQUErQjtBQUUvQixNQUFNLFFBQVEsR0FBRyxPQUFPLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxPQUFPLENBQUMsRUFBRSxDQUFDLFFBQVEsQ0FBQyxDQUFDO0FBQzdELE1BQU0sU0FBUyxHQUFHLE9BQU8sQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLE9BQU8sQ0FBQyxFQUFFLENBQUMsU0FBUyxDQUFDLENBQUM7QUFDL0QsTUFBTSxlQUFlLEdBQUcsT0FBTyxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxlQUFlLENBQUMsQ0FBQztBQWdCL0UsTUFBTSxPQUFPLFdBQVc7SUFDZCxPQUFPLENBQVM7SUFDaEIsY0FBYyxDQUFPLENBQUMsMEJBQTBCO0lBRXhELFlBQVksT0FBTyxHQUFHLEtBQUssQ0FBQyxPQUFPLEVBQUUsY0FBb0I7UUFDdkQsSUFBSSxDQUFDLE9BQU8sR0FBRyxPQUFPLENBQUM7UUFDdkIsSUFBSSxDQUFDLGNBQWMsR0FBRyxjQUFjLENBQUM7SUFDdkMsQ0FBQztJQUVNLEtBQUssQ0FBQyxvQkFBb0IsQ0FBQyxTQUFpQjtRQUNqRCxPQUFPO1lBQ0wsY0FBYyxFQUFFLE9BQU8sQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxPQUFPLEVBQUUsR0FBRyxTQUFTLGNBQWMsQ0FBQztZQUMzRSxhQUFhLEVBQUUsT0FBTyxDQUFDLElBQUksQ0FBQyxJQUFJ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
|