Pulse/internal/crypto/crypto.go
rcourtman 125e98712b debug: add critical logging for encryption key deletion bug
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.
2025-12-21 00:25:05 +00:00

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!