diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index b236edd..17d2ae0 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -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 diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index 483664f..a3cc6f6 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -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") + } +} diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index 4003906..5a88f7c 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -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