fix: Prevent orphaned encrypted data when encryption key is deleted

- crypto.go: Add runtime validation to Encrypt() that verifies the key file
  still exists on disk before encrypting. If the key was deleted while Pulse
  is running, encryption now fails with a clear error instead of creating
  orphaned data that can never be decrypted.

- hot-dev.sh: Auto-generate encryption key for production data directory
  (/etc/pulse) when HOT_DEV_USE_PROD_DATA=true and key is missing. This
  prevents startup failures and ensures encrypted data can be created.

- Added test TestEncryptRefusesAfterKeyDeleted to verify the protection works.
This commit is contained in:
rcourtman 2025-12-17 17:00:53 +00:00
parent 8f746ede2a
commit 62b5580193
3 changed files with 71 additions and 2 deletions

View file

@ -16,18 +16,25 @@ import (
// CryptoManager handles encryption/decryption of sensitive data
type CryptoManager struct {
key []byte
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,
key: key,
keyPath: keyPath,
}, nil
}
@ -151,7 +158,20 @@ func getOrCreateKeyAt(dataDir string) ([]byte, error) {
}
// 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

View file

@ -260,3 +260,45 @@ func TestLargeDataEncryption(t *testing.T) {
t.Error("Large data round-trip failed")
}
}
func TestEncryptRefusesAfterKeyDeleted(t *testing.T) {
tmpDir := t.TempDir()
cm, err := NewCryptoManagerAt(tmpDir)
if err != nil {
t.Fatalf("NewCryptoManagerAt() error: %v", err)
}
// Encrypt should work initially
plaintext := []byte("test data")
encrypted, err := cm.Encrypt(plaintext)
if err != nil {
t.Fatalf("Initial Encrypt() failed: %v", err)
}
// Decrypt should also work
_, err = cm.Decrypt(encrypted)
if err != nil {
t.Fatalf("Initial Decrypt() failed: %v", err)
}
// Now delete the key file (simulating what happened in the bug)
keyPath := filepath.Join(tmpDir, ".encryption.key")
if err := os.Remove(keyPath); err != nil {
t.Fatalf("Failed to remove key file: %v", err)
}
// Encrypt should now FAIL to prevent orphaned data
_, err = cm.Encrypt([]byte("new data"))
if err == nil {
t.Error("Encrypt() should fail after key file is deleted")
}
// Decrypt should still work (key is in memory)
decrypted, err := cm.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt() should still work with in-memory key: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Error("Decrypt() returned wrong data")
}
}

View file

@ -273,6 +273,13 @@ else
log_info "Generated dev encryption key at ${DEV_KEY_FILE}"
fi
export PULSE_ENCRYPTION_KEY="$(<"${DEV_KEY_FILE}")"
elif [[ ${HOT_DEV_USE_PROD_DATA:-false} == "true" ]]; then
# Production data mode but no key - generate one to prevent orphaned encrypted data
log_warn "No encryption key found for ${PULSE_DATA_DIR}. Generating new key..."
openssl rand -base64 32 > "${PULSE_DATA_DIR}/.encryption.key"
chmod 600 "${PULSE_DATA_DIR}/.encryption.key"
export PULSE_ENCRYPTION_KEY="$(<"${PULSE_DATA_DIR}/.encryption.key")"
log_info "Generated new encryption key at ${PULSE_DATA_DIR}/.encryption.key"
else
log_warn "No encryption key found for ${PULSE_DATA_DIR}. Encrypted config may fail to load."
fi