Skip to content

Security: Add key rotation mechanism for email tracking encryption #293

@joshuayoes

Description

@joshuayoes

Summary

The email tracking system uses a single AES-256-GCM key (TRACKING_KEY) that is set once during deploy and has no rotation mechanism. A compromised key allows decrypting all past and future tracking pixel payloads (recipient email, subject hash, timestamp).

Affected Files

  • internal/tracking/crypto.go
  • internal/tracking/deploy.go (lines 87-88)

Current Code

crypto.go — Single key, no key ID or versioning:

func Encrypt(payload *PixelPayload, keyBase64 string) (string, error) {
    key, err := base64.StdEncoding.DecodeString(keyBase64)
    // ... single key used for all encryptions
    ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
    return base64.RawURLEncoding.EncodeToString(ciphertext), nil
}

deploy.go — Key set once as a Cloudflare secret with no rotation:

runWranglerCommand(ctx, workerDir, 
    strings.NewReader(opts.TrackingKey+"\n"), 
    "secret", "put", "TRACKING_KEY", "--name", opts.WorkerName)

Risk

  1. No forward secrecy: A compromised key decrypts all historical tracking IDs (every pixel URL ever generated)
  2. No rotation path: Rotating the key invalidates all outstanding tracking pixels in already-delivered emails
  3. Key exposure: The key is passed via stdin to wrangler — it could appear in process listings or shell history

Remediation

  1. Add a key version prefix to the encrypted blob:

    <key-version-byte><nonce><ciphertext>
    
  2. Support multiple active keys in the worker:

    // Worker environment
    TRACKING_KEY_V1: "base64..."
    TRACKING_KEY_V2: "base64..."
    TRACKING_CURRENT_KEY_VERSION: "2"
    
    // Encrypt with current version, decrypt tries all versions
  3. Add a tracking key-rotate command that:

    • Generates a new key
    • Deploys it as the next version
    • Updates the current version pointer
    • Old keys remain for decrypting historical tracking pixels
  4. Add key expiry warnings in the CLI when keys are older than a configurable threshold.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions