Added extensive logging to crypto.go to trace when the encryption key migration code runs and when it deletes the key. This is to diagnose a recurring bug where the encryption key mysteriously disappears. The logs will show: - When migration is being considered (dataDir != /etc/pulse) - When migration is skipped (dataDir == /etc/pulse) - CRITICAL log when key is about to be deleted - CRITICAL log when key has been deleted This will help identify whether it's the Go code or something external deleting the key.
272 lines
8.4 KiB
Go
272 lines
8.4 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
|
|
keyPath string // Path to the encryption key file for runtime validation
|
|
}
|
|
|
|
// NewCryptoManagerAt creates a new crypto manager with an explicit data directory override.
|
|
func NewCryptoManagerAt(dataDir string) (*CryptoManager, error) {
|
|
if dataDir == "" {
|
|
dataDir = utils.GetDataDir()
|
|
}
|
|
keyPath := filepath.Join(dataDir, ".encryption.key")
|
|
|
|
key, err := getOrCreateKeyAt(dataDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
|
}
|
|
|
|
return &CryptoManager{
|
|
key: key,
|
|
keyPath: keyPath,
|
|
}, nil
|
|
}
|
|
|
|
// getOrCreateKeyAt gets the encryption key or creates one if it doesn't exist
|
|
func getOrCreateKeyAt(dataDir string) ([]byte, error) {
|
|
if dataDir == "" {
|
|
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 {
|
|
// Use DecodedLen to allocate sufficient space, then slice to actual length
|
|
// This prevents panics if the file contains more data than expected
|
|
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(data)))
|
|
n, err := base64.StdEncoding.Decode(decoded, data)
|
|
if err == nil {
|
|
if n == 32 {
|
|
log.Debug().Msg("Found and loaded existing encryption key")
|
|
return decoded[:n], nil
|
|
}
|
|
log.Warn().
|
|
Int("decodedBytes", n).
|
|
Msg("Encryption key has invalid length (expected 32 bytes)")
|
|
} else {
|
|
log.Warn().
|
|
Err(err).
|
|
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)
|
|
// CRITICAL: This code deletes the encryption key at oldKeyPath after migrating it.
|
|
// Adding extensive logging to diagnose recurring key deletion bug.
|
|
if dataDir != "/etc/pulse" && keyPath != oldKeyPath {
|
|
log.Warn().
|
|
Str("dataDir", dataDir).
|
|
Str("keyPath", keyPath).
|
|
Str("oldKeyPath", oldKeyPath).
|
|
Msg("ENCRYPTION KEY MIGRATION: Checking if old key exists for migration (this code path CAN delete the key!)")
|
|
|
|
if data, err := os.ReadFile(oldKeyPath); err == nil {
|
|
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(data)))
|
|
n, err := base64.StdEncoding.Decode(decoded, data)
|
|
if err == nil && n == 32 {
|
|
key := decoded[:n]
|
|
// 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")
|
|
|
|
// CRITICAL: This is the ONLY place in the codebase that deletes the encryption key!
|
|
log.Error().
|
|
Str("oldKeyPath", oldKeyPath).
|
|
Str("newKeyPath", keyPath).
|
|
Str("dataDir", dataDir).
|
|
Msg("CRITICAL: ABOUT TO DELETE ENCRYPTION KEY FROM OLD LOCATION")
|
|
|
|
// 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)")
|
|
} else {
|
|
log.Error().
|
|
Str("deletedPath", oldKeyPath).
|
|
Msg("CRITICAL: ENCRYPTION KEY HAS BEEN DELETED")
|
|
}
|
|
return key, nil
|
|
}
|
|
}
|
|
} else {
|
|
log.Debug().
|
|
Str("dataDir", dataDir).
|
|
Str("keyPath", keyPath).
|
|
Bool("sameAsOldPath", dataDir == "/etc/pulse").
|
|
Msg("Skipping key migration check (dataDir is /etc/pulse or paths match)")
|
|
}
|
|
|
|
// 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
|
|
// SAFETY: Verifies the encryption key file still exists on disk before encrypting.
|
|
// This prevents orphaned encrypted data if the key was deleted while Pulse was running.
|
|
func (c *CryptoManager) Encrypt(plaintext []byte) ([]byte, error) {
|
|
// CRITICAL: Verify the key file still exists on disk before encrypting
|
|
// This prevents creating orphaned encrypted data that can never be decrypted
|
|
if c.keyPath != "" {
|
|
if _, err := os.Stat(c.keyPath); os.IsNotExist(err) {
|
|
log.Error().
|
|
Str("keyPath", c.keyPath).
|
|
Msg("CRITICAL: Encryption key file has been deleted - refusing to encrypt to prevent orphaned data")
|
|
return nil, fmt.Errorf("encryption key file deleted - cannot encrypt (would create orphaned data)")
|
|
}
|
|
}
|
|
|
|
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!
|