Skip to content

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

  1. DKIM Architecture Overview
  2. Key Storage Abstraction
  3. Key Generation Workflow
  4. Signing Process
  5. Multi-Domain Pool Pattern
  6. CLI Commands
  7. DNS Record Format
  8. Admin Integration
  9. Security Considerations
  10. 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

LayerComponentPurpose
Key StorageDKIMKeyStoreAbstracted key access
SigningDKIMSignerPer-domain signing logic
ManagementDKIMSignerPoolMulti-domain signer management
CLImailserver dkimKey generation and rotation
Admin/admin/domains/dkimWeb 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
ParameterMeaning
vDKIM version (1)
aAlgorithm (rsa-sha256, ed25519-sha256)
cCanonicalization (simple/relaxed)
dSigning domain
sSelector
hSigned headers
bhBody hash (SHA-256)
bHeader 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

TagValueRequiredDescription
vDKIM1YesDKIM version
krsa, ed25519NoKey type
pBase64-encoded public keyYesPublic key
hsha256, sha1NoHash algorithm
ts, yNoFlags (s=testing, y=y)
nNotesNoHuman-readable notes
gkey granularityNoSubdomains 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 SizeSecurity LevelUse Case
1024WeakLegacy (not recommended)
2048StrongCurrent recommended
4096Very StrongHigh-security env
Ed25519StrongModern 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

  1. Key storage abstraction enables flexible migration between file, database, and hybrid storage strategies
  2. Pool pattern manages multiple domain signers with thread-safe access and dynamic loading
  3. Selector-based rotation allows smooth key transitions without service interruption
  4. Public key caching in database avoids parsing private keys for DNS record generation
  5. Automated rotation with 90-day cycles maintains security compliance
  6. Graceful fallback ensures email delivery even if DKIM signing fails
  7. Comprehensive CLI and admin integration simplifies key management operations
  8. 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.

Go Backend Security Email Server DKIM Cryptography RSA DNS Key Management Email Security
Share:

Continue Reading

IMAP IDLE Implementation: From Crashes to Production

A deep dive into implementing real-time email notifications using IMAP IDLE, chronicling three crashes, library bugs, and the journey to production-grade instant email delivery with Go. Learn about goroutine race conditions, go-imap v1 vs v2, and O(1) file access optimizations.

Read article
GoIMAPIDLE

Building a Production-Ready Email Delivery Engine

Learn how to build a robust email delivery engine with circuit breakers, exponential backoff, MX resolution, and comprehensive monitoring. Based on a real production email server implementation.

Read article
GoBackendEmail Server

Debugging IMAP Crashes: The Nil Pointer Nightmare

A deep dive into debugging three critical IMAP server crashes caused by nil pointer dereferences. Learn how we tracked down and fixed SELECT response crashes, BODYSTRUCTURE panics, and capability advertising issues in production.

Read article
GoIMAPDebugging