- 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.
353 lines
12 KiB
Go
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
|
|
}
|