242 lines
5.7 KiB
TypeScript
242 lines
5.7 KiB
TypeScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||
|
|
import { CacheDb } from '../classes.cachedb.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper to get the smartdata database instance
|
||
|
|
*/
|
||
|
|
const getDb = () => CacheDb.getInstance().getDb();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* CachedDKIMKey - Stores DKIM key pairs for email signing
|
||
|
|
*
|
||
|
|
* Caches DKIM private/public key pairs per domain and selector.
|
||
|
|
* Default TTL is 90 days (typical key rotation interval).
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.Collection(() => getDb())
|
||
|
|
export class CachedDKIMKey extends CachedDocument<CachedDKIMKey> {
|
||
|
|
/**
|
||
|
|
* Composite key: domain:selector
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.unI()
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public domainSelector: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Domain for this DKIM key
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public domain: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DKIM selector (e.g., 'mta', 'default', '2024')
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public selector: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Private key in PEM format
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public privateKey: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Public key in PEM format
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public publicKey: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Public key for DNS TXT record (base64, no headers)
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public publicKeyDns: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Key size in bits (e.g., 1024, 2048)
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public keySize: number = 2048;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Key algorithm (e.g., 'rsa-sha256')
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public algorithm: string = 'rsa-sha256';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* When the key was generated
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public generatedAt: Date;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* When the key was last rotated
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public rotatedAt: Date;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Previous selector (for key rotation)
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public previousSelector: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Number of emails signed with this key
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public signCount: number = 0;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether this key is currently active
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public isActive: boolean = true;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
super();
|
||
|
|
this.setTTL(TTL.DAYS_90); // Default 90-day TTL
|
||
|
|
this.generatedAt = new Date();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create the composite key from domain and selector
|
||
|
|
*/
|
||
|
|
public static createDomainSelector(domain: string, selector: string): string {
|
||
|
|
return `${domain.toLowerCase()}:${selector.toLowerCase()}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a new DKIM key entry
|
||
|
|
*/
|
||
|
|
public static createNew(domain: string, selector: string): CachedDKIMKey {
|
||
|
|
const key = new CachedDKIMKey();
|
||
|
|
key.domain = domain.toLowerCase();
|
||
|
|
key.selector = selector.toLowerCase();
|
||
|
|
key.domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
|
||
|
|
return key;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find by domain and selector
|
||
|
|
*/
|
||
|
|
public static async findByDomainSelector(
|
||
|
|
domain: string,
|
||
|
|
selector: string
|
||
|
|
): Promise<CachedDKIMKey | null> {
|
||
|
|
const domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
|
||
|
|
return await CachedDKIMKey.getInstance({
|
||
|
|
domainSelector,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all keys for a domain
|
||
|
|
*/
|
||
|
|
public static async findByDomain(domain: string): Promise<CachedDKIMKey[]> {
|
||
|
|
return await CachedDKIMKey.getInstances({
|
||
|
|
domain: domain.toLowerCase(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find the active key for a domain
|
||
|
|
*/
|
||
|
|
public static async findActiveForDomain(domain: string): Promise<CachedDKIMKey | null> {
|
||
|
|
const keys = await CachedDKIMKey.getInstances({
|
||
|
|
domain: domain.toLowerCase(),
|
||
|
|
isActive: true,
|
||
|
|
});
|
||
|
|
return keys.length > 0 ? keys[0] : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all active keys
|
||
|
|
*/
|
||
|
|
public static async findAllActive(): Promise<CachedDKIMKey[]> {
|
||
|
|
return await CachedDKIMKey.getInstances({
|
||
|
|
isActive: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set the key pair
|
||
|
|
*/
|
||
|
|
public setKeyPair(privateKey: string, publicKey: string, publicKeyDns?: string): void {
|
||
|
|
this.privateKey = privateKey;
|
||
|
|
this.publicKey = publicKey;
|
||
|
|
this.publicKeyDns = publicKeyDns || this.extractPublicKeyDns(publicKey);
|
||
|
|
this.generatedAt = new Date();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract the base64 public key for DNS from PEM format
|
||
|
|
*/
|
||
|
|
private extractPublicKeyDns(publicKeyPem: string): string {
|
||
|
|
// Remove PEM headers and newlines
|
||
|
|
return publicKeyPem
|
||
|
|
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||
|
|
.replace(/-----END PUBLIC KEY-----/g, '')
|
||
|
|
.replace(/\s/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate the DNS TXT record value
|
||
|
|
*/
|
||
|
|
public getDnsTxtRecord(): string {
|
||
|
|
return `v=DKIM1; k=rsa; p=${this.publicKeyDns}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the full DNS record name
|
||
|
|
*/
|
||
|
|
public getDnsRecordName(): string {
|
||
|
|
return `${this.selector}._domainkey.${this.domain}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record that this key was used to sign an email
|
||
|
|
*/
|
||
|
|
public recordSign(): void {
|
||
|
|
this.signCount++;
|
||
|
|
this.touch();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Deactivate this key (e.g., during rotation)
|
||
|
|
*/
|
||
|
|
public deactivate(): void {
|
||
|
|
this.isActive = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Activate this key
|
||
|
|
*/
|
||
|
|
public activate(): void {
|
||
|
|
this.isActive = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Rotate to a new selector
|
||
|
|
*/
|
||
|
|
public rotate(newSelector: string): void {
|
||
|
|
this.previousSelector = this.selector;
|
||
|
|
this.selector = newSelector.toLowerCase();
|
||
|
|
this.domainSelector = CachedDKIMKey.createDomainSelector(this.domain, this.selector);
|
||
|
|
this.rotatedAt = new Date();
|
||
|
|
this.signCount = 0;
|
||
|
|
// Reset TTL on rotation
|
||
|
|
this.setTTL(TTL.DAYS_90);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if key needs rotation (based on age or sign count)
|
||
|
|
*/
|
||
|
|
public needsRotation(maxAgeDays: number = 90, maxSignCount: number = 1000000): boolean {
|
||
|
|
const ageMs = Date.now() - this.generatedAt.getTime();
|
||
|
|
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||
|
|
return ageDays > maxAgeDays || this.signCount > maxSignCount;
|
||
|
|
}
|
||
|
|
}
|