Files
Charon/backend/internal/crypto/rotation_service.go
GitHub Actions 111a8cc1dc feat: implement encryption management features including key rotation, validation, and history tracking
- Add API functions for fetching encryption status, rotating keys, retrieving rotation history, and validating key configuration.
- Create custom hooks for managing encryption status and key operations.
- Develop the EncryptionManagement page with UI components for displaying status, actions, and rotation history.
- Implement confirmation dialog for key rotation and handle loading states and error messages.
- Add tests for the EncryptionManagement component to ensure functionality and error handling.
2026-01-04 03:08:40 +00:00

353 lines
12 KiB
Go

// Package crypto provides cryptographic services for sensitive data.
package crypto
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sort"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"gorm.io/gorm"
)
// RotationService manages encryption key rotation with multi-key version support.
// It supports loading multiple encryption keys from environment variables:
// - CHARON_ENCRYPTION_KEY: Current encryption key (version 1)
// - CHARON_ENCRYPTION_KEY_NEXT: Next key for rotation (becomes current after rotation)
// - CHARON_ENCRYPTION_KEY_V1 through CHARON_ENCRYPTION_KEY_V10: Legacy keys for decryption
//
// Zero-downtime rotation workflow:
// 1. Set CHARON_ENCRYPTION_KEY_NEXT with new key
// 2. Restart application (loads both keys)
// 3. Call RotateAllCredentials() to re-encrypt all credentials with NEXT key
// 4. Promote: NEXT → current, old current → V1
// 5. Restart application
type RotationService struct {
db *gorm.DB
currentKey *EncryptionService // Current encryption key
nextKey *EncryptionService // Next key for rotation (optional)
legacyKeys map[int]*EncryptionService // Legacy keys indexed by version
keyVersions []int // Sorted list of available key versions
}
// RotationResult contains the outcome of a rotation operation.
type RotationResult struct {
TotalProviders int `json:"total_providers"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
FailedProviders []uint `json:"failed_providers,omitempty"`
Duration string `json:"duration"`
NewKeyVersion int `json:"new_key_version"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
}
// RotationStatus describes the current state of encryption keys.
type RotationStatus struct {
CurrentVersion int `json:"current_version"`
NextKeyConfigured bool `json:"next_key_configured"`
LegacyKeyCount int `json:"legacy_key_count"`
LegacyKeyVersions []int `json:"legacy_key_versions"`
ProvidersOnCurrentVersion int `json:"providers_on_current_version"`
ProvidersOnOlderVersions int `json:"providers_on_older_versions"`
ProvidersByVersion map[int]int `json:"providers_by_version"`
}
// NewRotationService creates a new key rotation service.
// It loads the current key and any legacy/next keys from environment variables.
func NewRotationService(db *gorm.DB) (*RotationService, error) {
rs := &RotationService{
db: db,
legacyKeys: make(map[int]*EncryptionService),
}
// Load current key (required)
currentKeyB64 := os.Getenv("CHARON_ENCRYPTION_KEY")
if currentKeyB64 == "" {
return nil, fmt.Errorf("CHARON_ENCRYPTION_KEY is required")
}
currentKey, err := NewEncryptionService(currentKeyB64)
if err != nil {
return nil, fmt.Errorf("failed to load current encryption key: %w", err)
}
rs.currentKey = currentKey
// Load next key (optional, used during rotation)
nextKeyB64 := os.Getenv("CHARON_ENCRYPTION_KEY_NEXT")
if nextKeyB64 != "" {
nextKey, err := NewEncryptionService(nextKeyB64)
if err != nil {
return nil, fmt.Errorf("failed to load next encryption key: %w", err)
}
rs.nextKey = nextKey
}
// Load legacy keys V1 through V10 (optional, for backward compatibility)
for i := 1; i <= 10; i++ {
envKey := fmt.Sprintf("CHARON_ENCRYPTION_KEY_V%d", i)
keyB64 := os.Getenv(envKey)
if keyB64 == "" {
continue
}
legacyKey, err := NewEncryptionService(keyB64)
if err != nil {
// Log warning but continue - this allows partial key configurations
fmt.Printf("Warning: failed to load legacy key %s: %v\n", envKey, err)
continue
}
rs.legacyKeys[i] = legacyKey
}
// Build sorted list of available key versions
rs.keyVersions = []int{1} // Current key is always version 1
for v := range rs.legacyKeys {
rs.keyVersions = append(rs.keyVersions, v)
}
sort.Ints(rs.keyVersions)
return rs, nil
}
// DecryptWithVersion decrypts ciphertext using the specified key version.
// It automatically falls back to older versions if the specified version fails.
func (rs *RotationService) DecryptWithVersion(ciphertextB64 string, version int) ([]byte, error) {
// Try the specified version first
plaintext, err := rs.tryDecryptWithVersion(ciphertextB64, version)
if err == nil {
return plaintext, nil
}
// If specified version failed, try falling back to other versions
// This handles cases where KeyVersion may be incorrectly tracked
for _, v := range rs.keyVersions {
if v == version {
continue // Already tried this one
}
plaintext, err = rs.tryDecryptWithVersion(ciphertextB64, v)
if err == nil {
// Successfully decrypted with a different version
// Log this for audit purposes
fmt.Printf("Warning: credential decrypted with version %d but was tagged as version %d\n", v, version)
return plaintext, nil
}
}
return nil, fmt.Errorf("failed to decrypt with version %d or any fallback version", version)
}
// tryDecryptWithVersion attempts decryption with a specific key version.
func (rs *RotationService) tryDecryptWithVersion(ciphertextB64 string, version int) ([]byte, error) {
var encService *EncryptionService
if version == 1 {
encService = rs.currentKey
} else if legacy, ok := rs.legacyKeys[version]; ok {
encService = legacy
} else {
return nil, fmt.Errorf("encryption key version %d not available", version)
}
return encService.Decrypt(ciphertextB64)
}
// EncryptWithCurrentKey encrypts plaintext with the current (or next during rotation) key.
// Returns the ciphertext and the version number of the key used.
func (rs *RotationService) EncryptWithCurrentKey(plaintext []byte) (string, int, error) {
// During rotation, use next key if available
if rs.nextKey != nil {
ciphertext, err := rs.nextKey.Encrypt(plaintext)
if err != nil {
return "", 0, fmt.Errorf("failed to encrypt with next key: %w", err)
}
return ciphertext, 2, nil // Next key becomes version 2
}
// Normal operation: use current key
ciphertext, err := rs.currentKey.Encrypt(plaintext)
if err != nil {
return "", 0, fmt.Errorf("failed to encrypt with current key: %w", err)
}
return ciphertext, 1, nil
}
// RotateAllCredentials re-encrypts all DNS provider credentials with the next key.
// This operation is atomic per provider but not globally - failed providers can be retried.
// Returns detailed results including any failures.
func (rs *RotationService) RotateAllCredentials(ctx context.Context) (*RotationResult, error) {
if rs.nextKey == nil {
return nil, fmt.Errorf("CHARON_ENCRYPTION_KEY_NEXT not configured - cannot rotate")
}
startTime := time.Now()
result := &RotationResult{
NewKeyVersion: 2,
StartedAt: startTime,
FailedProviders: []uint{},
}
// Fetch all DNS providers
var providers []models.DNSProvider
if err := rs.db.WithContext(ctx).Find(&providers).Error; err != nil {
return nil, fmt.Errorf("failed to fetch providers: %w", err)
}
result.TotalProviders = len(providers)
// Re-encrypt each provider's credentials
for _, provider := range providers {
if err := rs.rotateProviderCredentials(ctx, &provider); err != nil {
result.FailureCount++
result.FailedProviders = append(result.FailedProviders, provider.ID)
fmt.Printf("Failed to rotate provider %d (%s): %v\n", provider.ID, provider.Name, err)
continue
}
result.SuccessCount++
}
result.CompletedAt = time.Now()
result.Duration = result.CompletedAt.Sub(startTime).String()
return result, nil
}
// rotateProviderCredentials re-encrypts a single provider's credentials.
func (rs *RotationService) rotateProviderCredentials(ctx context.Context, provider *models.DNSProvider) error {
// Decrypt with old key (using fallback mechanism)
plaintext, err := rs.DecryptWithVersion(provider.CredentialsEncrypted, provider.KeyVersion)
if err != nil {
return fmt.Errorf("failed to decrypt credentials: %w", err)
}
// Validate that decrypted data is valid JSON
var credentials map[string]string
if err := json.Unmarshal(plaintext, &credentials); err != nil {
return fmt.Errorf("invalid credential format after decryption: %w", err)
}
// Re-encrypt with next key
newCiphertext, err := rs.nextKey.Encrypt(plaintext)
if err != nil {
return fmt.Errorf("failed to encrypt with next key: %w", err)
}
// Update provider record atomically
updates := map[string]interface{}{
"credentials_encrypted": newCiphertext,
"key_version": 2, // Next key becomes version 2
"updated_at": time.Now(),
}
if err := rs.db.WithContext(ctx).Model(provider).Updates(updates).Error; err != nil {
return fmt.Errorf("failed to update provider record: %w", err)
}
return nil
}
// GetStatus returns the current rotation status including key configuration and provider distribution.
func (rs *RotationService) GetStatus() (*RotationStatus, error) {
status := &RotationStatus{
CurrentVersion: 1,
NextKeyConfigured: rs.nextKey != nil,
LegacyKeyCount: len(rs.legacyKeys),
LegacyKeyVersions: []int{},
ProvidersByVersion: make(map[int]int),
}
// Collect legacy key versions
for v := range rs.legacyKeys {
status.LegacyKeyVersions = append(status.LegacyKeyVersions, v)
}
sort.Ints(status.LegacyKeyVersions)
// Count providers by key version
var providers []models.DNSProvider
if err := rs.db.Select("key_version").Find(&providers).Error; err != nil {
return nil, fmt.Errorf("failed to count providers by version: %w", err)
}
for _, p := range providers {
status.ProvidersByVersion[p.KeyVersion]++
if p.KeyVersion == 1 {
status.ProvidersOnCurrentVersion++
} else {
status.ProvidersOnOlderVersions++
}
}
return status, nil
}
// ValidateKeyConfiguration checks all configured encryption keys for validity.
// Returns error if any key is invalid (wrong length, invalid base64, etc.).
func (rs *RotationService) ValidateKeyConfiguration() error {
// Current key is already validated during NewRotationService()
// Just verify it's still accessible
if rs.currentKey == nil {
return fmt.Errorf("current encryption key not loaded")
}
// Test encryption/decryption with current key
testData := []byte("validation_test")
ciphertext, err := rs.currentKey.Encrypt(testData)
if err != nil {
return fmt.Errorf("current key encryption test failed: %w", err)
}
plaintext, err := rs.currentKey.Decrypt(ciphertext)
if err != nil {
return fmt.Errorf("current key decryption test failed: %w", err)
}
if string(plaintext) != string(testData) {
return fmt.Errorf("current key round-trip test failed")
}
// Validate next key if configured
if rs.nextKey != nil {
ciphertext, err := rs.nextKey.Encrypt(testData)
if err != nil {
return fmt.Errorf("next key encryption test failed: %w", err)
}
plaintext, err := rs.nextKey.Decrypt(ciphertext)
if err != nil {
return fmt.Errorf("next key decryption test failed: %w", err)
}
if string(plaintext) != string(testData) {
return fmt.Errorf("next key round-trip test failed")
}
}
// Validate legacy keys
for version, legacyKey := range rs.legacyKeys {
ciphertext, err := legacyKey.Encrypt(testData)
if err != nil {
return fmt.Errorf("legacy key V%d encryption test failed: %w", version, err)
}
plaintext, err := legacyKey.Decrypt(ciphertext)
if err != nil {
return fmt.Errorf("legacy key V%d decryption test failed: %w", version, err)
}
if string(plaintext) != string(testData) {
return fmt.Errorf("legacy key V%d round-trip test failed", version)
}
}
return nil
}
// GenerateNewKey generates a new random 32-byte encryption key and returns it as base64.
// This is a utility function for administrators to generate keys for rotation.
func GenerateNewKey() (string, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return "", fmt.Errorf("failed to generate random key: %w", err)
}
return base64.StdEncoding.EncodeToString(key), nil
}