DKIM Implementation: From Signing to Auto-Rotation
DKIM Implementation: From Signing to Auto-Rotation
DomainKeys Identified Mail (DKIM) is a critical email authentication mechanism that allows senders to take responsibility for messages in transit. This article provides a comprehensive guide to implementing DKIM in a production email server, covering key storage abstraction, signing workflows, multi-domain pool management, and automated key rotation.
Table of Contents
- DKIM Architecture Overview
- Key Storage Abstraction
- Key Generation Workflow
- Signing Process
- Multi-Domain Pool Pattern
- CLI Commands
- DNS Record Format
- Admin Integration
- Security Considerations
- Testing DKIM Signatures
DKIM Architecture Overview
DKIM provides cryptographic email authentication through a public-key infrastructure. The sender signs outbound emails with a private key, while receivers verify signatures using a public key published in DNS.
Core Components
graph TD
A[Outbound Email] --> B[DKIM Signer]
B --> C[Private Key Store]
B --> D[Sign Message Headers]
D --> E[Add DKIM-Signature Header]
E --> F[Transit Email]
F --> G[Recipient Server]
G --> H[DNS Lookup]
H --> I[DKIM TXT Record]
I --> J[Public Key]
J --> K[Verify Signature]
K --> L{Valid?}
L -->|Yes| M[Accept Message]
L -->|No| N[Reject or Quarantine]
Implementation Layers
| Layer | Component | Purpose |
|---|---|---|
| Key Storage | DKIMKeyStore | Abstracted key access |
| Signing | DKIMSigner | Per-domain signing logic |
| Management | DKIMSignerPool | Multi-domain signer management |
| CLI | mailserver dkim | Key generation and rotation |
| Admin | /admin/domains/dkim | Web UI for key management |
Key Storage Abstraction
The implementation uses an interface-based storage abstraction (Migration 005) to support flexible key storage strategies: file-based, database-based, or hybrid for redundancy.
Migration 005 Schema
-- Enhanced DKIM key management
ALTER TABLE domains ADD COLUMN dkim_public_key TEXT;
ALTER TABLE domains ADD COLUMN dkim_key_created_at DATETIME;
ALTER TABLE domains ADD COLUMN dkim_key_algorithm TEXT DEFAULT 'RSA-2048';
ALTER TABLE domains ADD COLUMN dkim_storage_type TEXT DEFAULT 'file';
ALTER TABLE domains ADD COLUMN dkim_key_file TEXT;
CREATE INDEX IF NOT EXISTS idx_domains_dkim_storage ON domains(dkim_storage_type);
Storage Interface
// @filename: main.go
// DKIMKeyStore provides abstracted key storage
type DKIMKeyStore interface {
SaveKey(ctx context.Context, domain string, privateKey *rsa.PrivateKey, selector string, algorithm string) error
LoadKey(ctx context.Context, domain string) (*rsa.PrivateKey, string, error)
DeleteKey(ctx context.Context, domain string) error
KeyExists(ctx context.Context, domain string) bool
GetPublicKeyDNS(ctx context.Context, domain string) (string, error)
GetKeyMetadata(ctx context.Context, domain string) (*KeyMetadata, error)
ListDomains(ctx context.Context) ([]KeyMetadata, error)
}
type KeyMetadata struct {
Domain string
Selector string
Algorithm string
CreatedAt time.Time
StorageType string
KeyFile string
HasKey bool
}
Storage Strategies
1. File-Based Storage
// @filename: main.go
type FileKeyStore struct {
basePath string
db *sql.DB
}
func (s *FileKeyStore) SaveKey(ctx context.Context, domain string, privateKey *rsa.PrivateKey, selector string, algorithm string) error {
// Ensure directory exists
if err := os.MkdirAll(s.basePath, 0700); err != nil {
return fmt.Errorf("failed to create DKIM key directory: %w", err)
}
// Encode private key to PEM (PKCS#1)
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privBytes,
})
// Write private key with restricted permissions (0600)
keyFile := filepath.Join(s.basePath, domain+".key")
if err := os.WriteFile(keyFile, privPEM, 0600); err != nil {
return fmt.Errorf("failed to write private key: %w", err)
}
// Write public key
pubBytes, _ := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
pubPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubBytes,
})
pubFile := filepath.Join(s.basePath, domain+".pub")
os.WriteFile(pubFile, pubPEM, 0644)
// Update database with metadata
_, err := s.db.ExecContext(ctx, `
UPDATE domains
SET dkim_selector = ?, dkim_key_algorithm = ?, dkim_key_created_at = ?,
dkim_storage_type = 'file', dkim_key_file = ?, dkim_public_key = ?
WHERE name = ?
`, selector, algorithm, time.Now(), keyFile, string(pubPEM), domain)
return err
}
2. Database Storage
// @filename: main.go
type DatabaseKeyStore struct {
db *sql.DB
}
func (s *DatabaseKeyStore) SaveKey(ctx context.Context, domain string, privateKey *rsa.PrivateKey, selector string, algorithm string) error {
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privBytes,
})
pubBytes, _ := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
pubPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubBytes,
})
result, err := s.db.ExecContext(ctx, `
UPDATE domains
SET dkim_selector = ?, dkim_private_key = ?, dkim_public_key = ?,
dkim_key_algorithm = ?, dkim_key_created_at = ?,
dkim_storage_type = 'database', dkim_key_file = NULL
WHERE name = ?
`, selector, privPEM, pubPEM, algorithm, time.Now(), domain)
if affected, _ := result.RowsAffected(); affected == 0 {
return fmt.Errorf("domain not found: %s", domain)
}
return err
}
3. Hybrid Storage
// @filename: main.go
type HybridKeyStore struct {
file *FileKeyStore
database *DatabaseKeyStore
}
func (s *HybridKeyStore) SaveKey(ctx context.Context, domain string, privateKey *rsa.PrivateKey, selector string, algorithm string) error {
// Save to both stores for redundancy
if err := s.file.SaveKey(ctx, domain, privateKey, selector, algorithm); err != nil {
return err
}
if err := s.database.SaveKey(ctx, domain, privateKey, selector, algorithm); err != nil {
return err
}
// Update storage type to hybrid
_, err := s.database.db.ExecContext(ctx,
"UPDATE domains SET dkim_storage_type = 'hybrid', dkim_key_file = ? WHERE name = ?",
s.file.keyPath(domain), domain)
return err
}
func (s *HybridKeyStore) LoadKey(ctx context.Context, domain string) (*rsa.PrivateKey, string, error) {
// Try database first (faster)
if key, selector, err := s.database.LoadKey(ctx, domain); err == nil {
return key, selector, nil
}
// Fall back to file
return s.file.LoadKey(ctx, domain)
}
Key Generation Workflow
DKIM key generation creates RSA key pairs with configurable bit length (2048 or 4096 bits) and assigns domain-specific selectors for easy rotation.
Key Generation
// @filename: handlers.go
func GenerateDKIMKey(bits int) (*rsa.PrivateKey, error) {
if bits < 1024 {
bits = 2048 // Default to 2048 bits
}
return rsa.GenerateKey(rand.Reader, bits)
}
func GenerateAndSaveKey(ctx context.Context, store DKIMKeyStore, domain, selector string, bits int) (*rsa.PrivateKey, error) {
if bits < 2048 {
bits = 2048
}
algorithm := fmt.Sprintf("RSA-%d", bits)
privateKey, err := GenerateDKIMKey(bits)
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
if err := store.SaveKey(ctx, domain, privateKey, selector, algorithm); err != nil {
return nil, fmt.Errorf("failed to save key: %w", err)
}
return privateKey, nil
}
Selector Naming Convention
Selectors enable key rotation without service interruption by allowing multiple keys to coexist.
// @filename: main.go
// Default selector
selector := "mail"
// Timestamp-based selector for rotation
newSelector := fmt.Sprintf("mail%d", time.Now().Unix())
// Example: mail1736659200
// Custom selector
selector := "jan2025" // Manual rotation naming
Public Key DNS Formatting
// @filename: handlers.go
func FormatDKIMPublicKey(key *rsa.PublicKey) (string, error) {
pubBytes, err := x509.MarshalPKIXPublicKey(key)
if err != nil {
return "", err
}
block := &pem.Block{
Type: "PUBLIC KEY",
Bytes: pubBytes,
}
pemData := pem.EncodeToMemory(block)
// Remove PEM headers and newlines for DNS
pubStr := string(pemData)
pubStr = strings.ReplaceAll(pubStr, "-----BEGIN PUBLIC KEY-----", "")
pubStr = strings.ReplaceAll(pubStr, "-----END PUBLIC KEY-----", "")
pubStr = strings.ReplaceAll(pubStr, "\n", "")
return fmt.Sprintf("v=DKIM1; k=rsa; p=%s", pubStr), nil
}
Signing Process
The DKIM signer adds a cryptographic signature to outbound emails by hashing selected headers and signing them with the domain’s private key.
Header Selection
DKIM signs specific email headers to prevent tampering while allowing legitimate modifications like Received: headers.
// @filename: main.go
type DKIMSigner struct {
domain string
selector string
privateKey *rsa.PrivateKey
}
func (s *DKIMSigner) Sign(w io.Writer, r io.Reader) error {
options := &dkim.SignOptions{
Domain: s.domain,
Selector: s.selector,
Signer: s.privateKey,
Hash: crypto.SHA256,
HeaderKeys: []string{
"From", // Sender identity
"To", // Recipient identity
"Subject", // Email subject
"Date", // When sent
"Message-ID", // Unique identifier
"Content-Type", // MIME type
"MIME-Version", // MIME standard version
},
}
return dkim.Sign(w, r, options)
}
Signature Header Format
The DKIM-Signature header contains all verification parameters:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=example.com; s=mail;
h=from:to:subject:date:message-id:content-type:mime-version;
bh=Base64HashOfBody;
b=Base64SignatureOfHeaders
| Parameter | Meaning |
|---|---|
v | DKIM version (1) |
a | Algorithm (rsa-sha256, ed25519-sha256) |
c | Canonicalization (simple/relaxed) |
d | Signing domain |
s | Selector |
h | Signed headers |
bh | Body hash (SHA-256) |
b | Header signature |
Integration with Delivery Engine
// @filename: main.go
func (e *Engine) readAndSignMessage(ctx context.Context, msg *queue.Message) ([]byte, error) {
messageData, err := os.ReadFile(msg.MessagePath)
if err != nil {
return nil, err
}
// Sign with DKIM if available
if e.dkimPool != nil {
signer := e.dkimPool.GetSigner(e.extractDomain(msg.Sender))
if signer != nil {
var signedBuf bytes.Buffer
if err := signer.Sign(&signedBuf, bytes.NewReader(messageData)); err != nil {
e.logger.WarnContext(ctx, "DKIM signing failed", "error", err.Error())
// Continue without DKIM rather than fail delivery
} else {
messageData = signedBuf.Bytes()
}
}
}
return messageData, nil
}
Multi-Domain Pool Pattern
The DKIMSignerPool manages multiple domain signers with thread-safe access, supporting dynamic loading from key stores.
Pool Implementation
// @filename: handlers.go
type DKIMSignerPool struct {
signers map[string]*DKIMSigner
store DKIMKeyStore
mu sync.RWMutex
}
func NewDKIMSignerPoolWithStore(store DKIMKeyStore) *DKIMSignerPool {
return &DKIMSignerPool{
signers: make(map[string]*DKIMSigner),
store: store,
}
}
func (p *DKIMSignerPool) GetSigner(domain string) *DKIMSigner {
p.mu.RLock()
defer p.mu.RUnlock()
return p.signers[strings.ToLower(domain)]
}
func (p *DKIMSignerPool) AddSignerFromStore(ctx context.Context, domain string) error {
if p.store == nil {
return fmt.Errorf("no key store configured")
}
privateKey, selector, err := p.store.LoadKey(ctx, domain)
if err != nil {
return fmt.Errorf("failed to load key from store: %w", err)
}
signer := &DKIMSigner{
domain: domain,
selector: selector,
privateKey: privateKey,
}
p.mu.Lock()
p.signers[strings.ToLower(domain)] = signer
p.mu.Unlock()
return nil
}
func (p *DKIMSignerPool) LoadAllFromStore(ctx context.Context) error {
if p.store == nil {
return fmt.Errorf("no key store configured")
}
domains, err := p.store.ListDomains(ctx)
if err != nil {
return fmt.Errorf("failed to list domains: %w", err)
}
var lastErr error
for _, meta := range domains {
if meta.HasKey {
if err := p.AddSignerFromStore(ctx, meta.Domain); err != nil {
lastErr = err
}
}
}
return lastErr
}
Thread-Safe Operations
// @filename: main.go
// Reload specific domain signer
func (p *DKIMSignerPool) ReloadSigner(ctx context.Context, domain string) error {
return p.AddSignerFromStore(ctx, domain)
}
// Remove domain signer
func (p *DKIMSignerPool) RemoveSigner(domain string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.signers, strings.ToLower(domain))
}
// List all loaded domains
func (p *DKIMSignerPool) ListDomains() []string {
p.mu.RLock()
defer p.mu.RUnlock()
domains := make([]string, 0, len(p.signers))
for domain := range p.signers {
domains = append(domains, domain)
}
return domains
}
CLI Commands
The email server provides comprehensive CLI commands for DKIM key management through mailserver dkim.
Generate Key
# @filename: script.sh
mailserver dkim generate example.com
mailserver dkim generate example.com --selector mail --bits 2048
mailserver dkim generate example.com --storage hybrid
Implementation:
// @filename: main.go
var dkimGenerateCmd = &cobra.Command{
Use: "generate <domain>",
Short: "Generate new DKIM key for a domain",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domainName := args[0]
// Open database
db, _ := metadata.Open(cfg.Storage.DatabasePath)
defer db.Close()
// Create key store
dkimPath := filepath.Join(cfg.Storage.DataDir, "dkim")
store := security.NewKeyStore(dkimStorage, dkimPath, db.DB)
// Check if key exists
if store.KeyExists(context.Background(), domainName) && !dkimForce {
return fmt.Errorf("DKIM key already exists. Use --force to overwrite")
}
// Generate and save key
fmt.Printf("Generating %d-bit DKIM key for %s...\n", dkimBits, domainName)
_, err = security.GenerateAndSaveKey(context.Background(), store, domainName, dkimSelector, dkimBits)
if err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Get DNS record
recordName, recordValue, err := security.GetDNSRecord(context.Background(), store, domainName)
if err != nil {
return fmt.Errorf("failed to get DNS record: %w", err)
}
fmt.Printf("\nDKIM key generated successfully!\n\n")
fmt.Printf("Add this DNS TXT record to your domain:\n\n")
fmt.Printf("Name: %s\n", recordName)
fmt.Printf("Type: TXT\n")
fmt.Printf("Value: %s\n\n", recordValue)
return nil
},
}
Show Key
# @filename: script.sh
mailserver dkim show example.com
mailserver dkim show example.com --format dns # Default
mailserver dkim show example.com --format bind # BIND zone file
mailserver dkim show example.com --format raw # Base64 only
// @filename: main.go
var dkimShowCmd = &cobra.Command{
Use: "show <domain>",
Short: "Show DKIM public key and DNS record",
RunE: func(cmd *cobra.Command, args []string) error {
domainName := args[0]
store := security.NewKeyStore(storageType, dkimPath, db.DB)
meta, _ := store.GetKeyMetadata(context.Background(), domainName)
if !meta.HasKey {
return fmt.Errorf("no DKIM key found for domain '%s'. Generate one with: mailserver dkim generate %s", domainName, domainName)
}
recordName, recordValue, _ := security.GetDNSRecord(context.Background(), store, domainName)
switch dkimFormat {
case "raw":
// Extract just public key value
parts := strings.Split(recordValue, "p=")
if len(parts) == 2 {
fmt.Println(parts[1])
}
case "bind":
fmt.Printf("; DKIM record for %s\n", domainName)
fmt.Printf("%s. IN TXT \"%s\"\n", recordName, recordValue)
default:
fmt.Printf("DKIM DNS Record for %s\n", domainName)
fmt.Printf("============================\n\n")
fmt.Printf("Selector: %s\n", meta.Selector)
fmt.Printf("Algorithm: %s\n", meta.Algorithm)
fmt.Printf("Created: %s\n", meta.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Printf("Storage: %s\n\n", meta.StorageType)
fmt.Printf("DNS Record Name:\n %s\n\n", recordName)
fmt.Printf("DNS Record Type:\n TXT\n\n")
fmt.Printf("DNS Record Value:\n %s\n", recordValue)
}
return nil
},
}
Rotate Key
mailserver dkim rotate example.com
mailserver dkim rotate example.com --bits 4096
// @filename: handlers.go
func RotateKey(ctx context.Context, store DKIMKeyStore, domain string, bits int) (string, *rsa.PrivateKey, error) {
if bits < 2048 {
bits = 2048
}
// Check if domain exists
_, err := store.GetKeyMetadata(ctx, domain)
if err != nil {
key, err := GenerateAndSaveKey(ctx, store, domain, "mail", bits)
return "mail", key, err
}
// Generate new selector based on timestamp
newSelector := fmt.Sprintf("mail%d", time.Now().Unix())
// Generate new key with new selector
key, err := GenerateAndSaveKey(ctx, store, domain, newSelector, bits)
if err != nil {
return "", nil, err
}
return newSelector, key, nil
}
List Keys
mailserver dkim list
Output:
DOMAIN SELECTOR HAS KEY STORAGE CREATED
-------------------------------------------------------------------------------
example.com mail yes database 2024-12-15
example.org default yes file 2024-12-10
mail.example.com jan2025 yes hybrid 2025-01-01
Auto-Rotate
mailserver dkim auto-rotate --days 90
// @filename: main.go
var dkimAutoRotateCmd = &cobra.Command{
Use: "auto-rotate",
Short: "Automatically rotate DKIM keys older than specified days",
RunE: func(cmd *cobra.Command, args []string) error {
store := security.NewFileKeyStore(dkimPath, db.DB)
domains, _ := store.ListDomains(context.Background())
rotationThreshold := time.Now().AddDate(0, 0, -dkimAutoRotateDays)
rotatedCount := 0
for _, meta := range domains {
if !meta.HasKey {
continue
}
if meta.CreatedAt.IsZero() || meta.CreatedAt.After(rotationThreshold) {
continue
}
fmt.Printf("Rotating key for %s (created: %s)...\n", meta.Domain, meta.CreatedAt.Format("2006-01-02"))
domainStore := security.NewKeyStore(storageType, dkimPath, db.DB)
newSelector, _, err := security.RotateKey(context.Background(), domainStore, meta.Domain, 2048)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
continue
}
fmt.Printf(" SUCCESS: New selector: %s\n", newSelector)
rotatedCount++
}
if rotatedCount == 0 {
fmt.Println("No keys needed rotation.")
} else {
fmt.Printf("\nRotated %d key(s). Remember to update DNS records!\n", rotatedCount)
}
return nil
},
}
DNS Record Format
DKIM requires publishing the public key in a specific DNS TXT record format.
Complete DKIM TXT Record
Name: mail._domainkey.example.com
Type: TXT
Value: v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
Record Components
| Tag | Value | Required | Description |
|---|---|---|---|
v | DKIM1 | Yes | DKIM version |
k | rsa, ed25519 | No | Key type |
p | Base64-encoded public key | Yes | Public key |
h | sha256, sha1 | No | Hash algorithm |
t | s, y | No | Flags (s=testing, y=y) |
n | Notes | No | Human-readable notes |
g | key granularity | No | Subdomains matching pattern |
Verification DNS Query
# @filename: script.sh
# DIG command
dig +short txt mail._domainkey.example.com
# NSLOOKUP
nslookup -type=TXT mail._domainkey.example.com
# Host command
host -t TXT mail._domainkey.example.com
DNS Caching Considerations
DKIM DNS records typically propagate within minutes to hours. Monitor caching:
# @filename: script.sh
# Check DNS propagation
for server in 8.8.8.8 1.1.1.1 208.67.222.222; do
echo "Querying $server:"
dig @$server txt mail._domainkey.example.com +short
echo
done
Admin Integration
The web admin panel provides a user-friendly interface for DKIM key management.
API Endpoints
// @filename: main.go
mux.HandleFunc("/admin/domains/dkim/generate/", s.withAuth(s.handleDKIMGenerate))
mux.HandleFunc("/admin/domains/dkim/show/", s.withAuth(s.handleDKIMShow))
mux.HandleFunc("/admin/domains/dkim/rotate/", s.withAuth(s.handleDKIMRotate))
mux.HandleFunc("/admin/system/dkim-autorotate", s.withAuth(s.handleDKIMAutoRotate))
Generate Key Handler
// @filename: query.sql
func (s *Server) handleDKIMGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract domain ID from path
parts := strings.Split(r.URL.Path, "/")
domainID, _ := strconv.ParseInt(parts[5], 10, 64)
// Get domain name
var domainName string
s.db.QueryRowContext(r.Context(), "SELECT name FROM domains WHERE id = ?", domainID).Scan(&domainName)
// Parse form values
selector := r.FormValue("selector")
if selector == "" {
selector = "mail"
}
bits := 2048
if r.FormValue("bits") == "4096" {
bits = 4096
}
storageType := r.FormValue("storage")
if storageType == "" {
storageType = "database"
}
// Create key store and generate
dkimPath := s.getDKIMPath()
store := security.NewKeyStore(storageType, dkimPath, s.db)
_, err := security.GenerateAndSaveKey(r.Context(), store, domainName, selector, bits)
if err != nil {
s.logger.ErrorContext(r.Context(), "Failed to generate DKIM key", err)
http.Error(w, "Failed to generate DKIM key", http.StatusInternalServerError)
return
}
// Audit log
adminUser := getSessionUser(r)
s.auditLogger.Log(r.Context(), adminUser, audit.EventConfigChange, domainName, map[string]interface{}{
"action": "dkim_generate",
"selector": selector,
"bits": bits,
}, getIP(r))
http.Redirect(w, r, "/admin/domains", http.StatusSeeOther)
}
Show Key Handler (JSON)
// @filename: query.sql
func (s *Server) handleDKIMShow(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
domainID, _ := strconv.ParseInt(parts[5], 10, 64)
// Get domain info
var domainName, selector string
var storageType sql.NullString
s.db.QueryRowContext(r.Context(),
"SELECT name, COALESCE(dkim_selector, 'mail'), dkim_storage_type FROM domains WHERE id = ?",
domainID).Scan(&domainName, &selector, &storageType)
storage := "file"
if storageType.Valid && storageType.String != "" {
storage = storageType.String
}
dkimPath := s.getDKIMPath()
store := security.NewKeyStore(storage, dkimPath, s.db)
meta, err := store.GetKeyMetadata(r.Context(), domainName)
if err != nil || !meta.HasKey {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"hasKey": false,
"domain": domainName,
})
return
}
recordName, recordValue, _ := security.GetDNSRecord(r.Context(), store, domainName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"hasKey": true,
"domain": domainName,
"selector": meta.Selector,
"algorithm": meta.Algorithm,
"createdAt": meta.CreatedAt.Format("2006-01-02 15:04:05"),
"storageType": meta.StorageType,
"recordName": recordName,
"recordValue": recordValue,
})
}
Security Considerations
Key Size Recommendations
| Key Size | Security Level | Use Case |
|---|---|---|
| 1024 | Weak | Legacy (not recommended) |
| 2048 | Strong | Current recommended |
| 4096 | Very Strong | High-security env |
| Ed25519 | Strong | Modern alternative |
Key Rotation Strategy
// @filename: handlers.go
// Recommended rotation schedule
type RotationPolicy struct {
KeySize int // 2048 or 4096
RotationPeriod time.Duration // 90 days default
MaxKeyAge time.Duration // 1 year maximum
GracePeriod time.Duration // 48 hours for old key
}
// Check if rotation needed
func ShouldRotate(meta KeyMetadata, policy RotationPolicy) bool {
if !meta.HasKey {
return false
}
keyAge := time.Since(meta.CreatedAt)
// Rotate if key is too old
if keyAge > policy.MaxKeyAge {
return true
}
// Rotate if key is approaching rotation period
if keyAge > policy.RotationPeriod {
return true
}
return false
}
Selector Naming Security
// @filename: main.go
// Good selector names
goodSelectors := []string{
"mail", // Simple, standard
"mail2025", // Year-based
"jan2025", // Month-based
"key1", // Sequential
}
// Bad selector names (avoid)
badSelectors := []string{
"default", // Too generic
"rsa2048", // Reveals algorithm and size
"admin", // Reveals purpose
"test123", // Looks like temporary
}
File Permissions
# @filename: script.sh
# Private key - strict permissions
chmod 600 /path/to/dkim/example.com.key
chown root:root /path/to/dkim/example.com.key
# Public key - readable
chmod 644 /path/to/dkim/example.com.pub
# Directory - restricted
chmod 700 /path/to/dkim
Key Backup Strategy
# @filename: script.sh
# Backup DKIM keys regularly
tar -czf dkim-backup-$(date +%Y%m%d).tar.gz /path/to/dkim/
# Encrypt backup
gpg --symmetric --cipher-algo AES256 dkim-backup-$(date +%Y%m%d).tar.gz
# Store backup securely (offsite, air-gapped)
scp dkim-backup-*.tar.gz.gpg backup@secure-host:/backups/
Security Checklist
- Use RSA-2048 or RSA-4096 keys
- Implement 90-day key rotation
- Keep old keys active for 48 hours during rotation
- Restrict private key file permissions (0600)
- Store keys in separate database from user data
- Enable audit logging for key operations
- Use non-guessable selector names
- Monitor DNS for unauthorized changes
- Test DKIM verification regularly
- Maintain offline key backups
Testing DKIM Signatures
Manual Verification
# @filename: script.sh
# Send test email
swaks --to recipient@example.com --from sender@example.com \
--server localhost:25 --auth LOGIN --auth-user test
# Check headers in received email
cat /var/mail/user | grep -A 20 "DKIM-Signature"
Online Verification Tools
Automated Testing
// @filename: handlers.go
func TestDKIMSignature(t *testing.T) {
keyPath, _ := generateTestKey(t)
defer os.Remove(keyPath)
signer, _ := NewDKIMSigner("example.com", "mail", keyPath)
email := `From: sender@example.com
To: recipient@example.com
Subject: Test Message
Date: Thu, 19 Dec 2024 12:00:00 +0000
Message-ID: <test@example.com>
Content-Type: text/plain
This is a test message.
`
var signedBuf bytes.Buffer
err := signer.Sign(&signedBuf, strings.NewReader(email))
if err != nil {
t.Fatalf("Sign failed: %v", err)
}
signed := signedBuf.String()
// Verify DKIM-Signature header exists
if !strings.Contains(signed, "DKIM-Signature:") {
t.Error("Expected DKIM-Signature header in signed message")
}
// Verify signature contains expected fields
if !strings.Contains(signed, "d=example.com") {
t.Error("Expected domain in DKIM signature")
}
if !strings.Contains(signed, "s=mail") {
t.Error("Expected selector in DKIM signature")
}
if !strings.Contains(signed, "a=rsa-sha256") {
t.Error("Expected algorithm in DKIM signature")
}
}
Integration Test
// @filename: handlers.go
func TestDKIMIntegration(t *testing.T) {
// Setup test database
db := setupTestDB(t)
// Generate key
store := security.NewKeyStore("database", "", db)
key, err := security.GenerateAndSaveKey(context.Background(), store, "test.com", "mail", 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
// Create signer pool
pool := security.NewDKIMSignerPoolWithStore(store)
pool.AddSignerFromStore(context.Background(), "test.com")
// Sign test email
email := `From: sender@test.com
To: recipient@example.com
Subject: Test
Body`
var buf bytes.Buffer
err = pool.Sign("test.com", &buf, strings.NewReader(email))
if err != nil {
t.Fatalf("Pool Sign failed: %v", err)
}
// Verify signature
signed := buf.String()
if !strings.Contains(signed, "DKIM-Signature:") {
t.Error("Expected DKIM-Signature in signed message")
}
}
Key Takeaways
- Key storage abstraction enables flexible migration between file, database, and hybrid storage strategies
- Pool pattern manages multiple domain signers with thread-safe access and dynamic loading
- Selector-based rotation allows smooth key transitions without service interruption
- Public key caching in database avoids parsing private keys for DNS record generation
- Automated rotation with 90-day cycles maintains security compliance
- Graceful fallback ensures email delivery even if DKIM signing fails
- Comprehensive CLI and admin integration simplifies key management operations
- Security best practices include proper file permissions, key size selection, and audit logging
This DKIM implementation provides a robust, production-ready foundation for email authentication, supporting multiple domains, flexible storage strategies, and automated key rotation to maintain long-term security.
