Files
pulse/internal/crypto/crypto.go
T
rcourtman c3deb6170e fix: prevent catastrophic data loss from encryption key regeneration
CRITICAL FIX: Prevents nodes.enc configuration from being permanently lost
when decryption fails due to encryption key regeneration or corruption.

Root Cause Analysis:
1. If .encryption.key is deleted/regenerated, existing .enc files become unreadable
2. Previous code would fail to decrypt, try backup (also fails), then return error
3. This left NO nodes.enc file on disk
4. Next startup would see no .enc files and happily generate a new encryption key
5. User's node configuration was permanently lost

Changes Made:

1. **persistence.go (lines 600-645)**: When decryption fails for BOTH main file
   and backup, instead of returning error and leaving no file:
   - Log CRITICAL error with clear message about encryption key issue
   - Move corrupted file to timestamped .corrupted file for forensics
   - Create EMPTY but VALID encrypted nodes.enc file
   - Return empty config so system can start
   - This prevents encryption key regeneration on next startup

2. **crypto.go (lines 93-121)**: Enhanced encryption key generation checks:
   - Now checks for nodes.enc* (including .backup, .corrupted files)
   - Uses glob patterns to find ANY encrypted file remnants
   - Refuses to generate new key if ANY .enc* files exist
   - Provides clear error message listing all found files
   - Forces manual intervention before allowing key regeneration

Benefits:
- System can still start even if decryption fails
- Corrupted files are preserved with timestamps for forensic analysis
- Encryption key cannot be silently regenerated if ANY encrypted data exists
- Clear, prominent error logging helps diagnose the root cause
- User is forced to manually address the issue rather than silently losing data

This should prevent the recurring issue where node configurations mysteriously
disappear, requiring manual reconfiguration through the UI.
2025-10-01 18:52:10 +00:00

218 lines
6.2 KiB
Go

package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// CryptoManager handles encryption/decryption of sensitive data
type CryptoManager struct {
key []byte
}
// NewCryptoManager creates a new crypto manager
func NewCryptoManager() (*CryptoManager, error) {
key, err := getOrCreateKey()
if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err)
}
return &CryptoManager{
key: key,
}, nil
}
// getOrCreateKey gets the encryption key or creates one if it doesn't exist
func getOrCreateKey() ([]byte, error) {
// Use data directory for key storage (for Docker persistence)
dataDir := utils.GetDataDir()
keyPath := filepath.Join(dataDir, ".encryption.key")
oldKeyPath := "/etc/pulse/.encryption.key"
log.Debug().
Str("dataDir", dataDir).
Str("keyPath", keyPath).
Msg("Looking for encryption key")
// Try to read existing key from new location
if data, err := os.ReadFile(keyPath); err == nil {
key := make([]byte, 32)
n, err := base64.StdEncoding.Decode(key, data)
if err == nil && n == 32 {
log.Debug().Msg("Found and loaded existing encryption key")
return key, nil
} else {
log.Warn().
Err(err).
Int("decodedBytes", n).
Msg("Failed to decode encryption key")
}
} else {
log.Debug().Err(err).Str("path", keyPath).Msg("Could not read encryption key file")
}
// Check for key in old location and migrate if found (only if paths differ)
if dataDir != "/etc/pulse" && keyPath != oldKeyPath {
if data, err := os.ReadFile(oldKeyPath); err == nil {
key := make([]byte, 32)
n, err := base64.StdEncoding.Decode(key, data)
if err == nil && n == 32 {
// Migrate key to new location
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
// Migration failed, but we can still use the old key
log.Warn().Err(err).Msg("Failed to create directory for key migration, using old location")
return key, nil
}
if err := os.WriteFile(keyPath, data, 0600); err != nil {
// Migration failed, but we can still use the old key
log.Warn().Err(err).Msg("Failed to migrate encryption key, using old location")
return key, nil
}
log.Info().
Str("from", oldKeyPath).
Str("to", keyPath).
Msg("Successfully migrated encryption key to data directory")
// Try to remove old key (ignore errors as this is cleanup)
if err := os.Remove(oldKeyPath); err != nil {
log.Debug().Err(err).Msg("Could not remove old encryption key (may lack permissions)")
}
return key, nil
}
}
}
// Before generating a new key, check if encrypted data exists OR if there are any backup/corrupted files
// This prevents silently orphaning existing encrypted configurations
// CRITICAL: Also check for .backup and .corrupted files to prevent data loss
checkPatterns := []string{
"nodes.enc*",
"email.enc*",
"webhooks.enc*",
"oidc.enc*",
}
hasEncryptedData := false
var foundFiles []string
for _, pattern := range checkPatterns {
matches, _ := filepath.Glob(filepath.Join(dataDir, pattern))
for _, file := range matches {
if info, err := os.Stat(file); err == nil && info.Size() > 0 {
hasEncryptedData = true
foundFiles = append(foundFiles, filepath.Base(file))
}
}
}
if hasEncryptedData {
log.Error().
Strs("foundFiles", foundFiles).
Str("dataDir", dataDir).
Msg("⚠️ CRITICAL: Encryption key not found but encrypted/backup/corrupted files exist")
return nil, fmt.Errorf("encryption key not found but encrypted data exists (%v) - cannot generate new key as it would orphan existing data. Please restore the encryption key from backup or delete ALL .enc* files to start fresh", foundFiles)
}
// Generate new key (only if no encrypted data exists)
key := make([]byte, 32) // AES-256
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
// Save key with restricted permissions
encoded := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(keyPath, []byte(encoded), 0600); err != nil {
return nil, fmt.Errorf("failed to save key: %w", err)
}
log.Info().Msg("Generated new encryption key")
return key, nil
}
// Encrypt encrypts data using AES-GCM
func (c *CryptoManager) Encrypt(plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(c.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// Decrypt decrypts data using AES-GCM
func (c *CryptoManager) Decrypt(ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(c.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
// EncryptString encrypts a string and returns base64
func (c *CryptoManager) EncryptString(plaintext string) (string, error) {
encrypted, err := c.Encrypt([]byte(plaintext))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
// DecryptString decrypts a base64 string
func (c *CryptoManager) DecryptString(ciphertext string) (string, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
decrypted, err := c.Decrypt(data)
if err != nil {
return "", err
}
return string(decrypted), nil
}
// Note: Password hashing has been moved to the auth package
// which uses bcrypt for secure password hashing.
// Never use SHA256 or other fast hashes for passwords!