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:
parent
8f746ede2a
commit
62b5580193
3 changed files with 71 additions and 2 deletions
|
|
@ -16,18 +16,25 @@ import (
|
||||||
|
|
||||||
// CryptoManager handles encryption/decryption of sensitive data
|
// CryptoManager handles encryption/decryption of sensitive data
|
||||||
type CryptoManager struct {
|
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.
|
// NewCryptoManagerAt creates a new crypto manager with an explicit data directory override.
|
||||||
func NewCryptoManagerAt(dataDir string) (*CryptoManager, error) {
|
func NewCryptoManagerAt(dataDir string) (*CryptoManager, error) {
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = utils.GetDataDir()
|
||||||
|
}
|
||||||
|
keyPath := filepath.Join(dataDir, ".encryption.key")
|
||||||
|
|
||||||
key, err := getOrCreateKeyAt(dataDir)
|
key, err := getOrCreateKeyAt(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CryptoManager{
|
return &CryptoManager{
|
||||||
key: key,
|
key: key,
|
||||||
|
keyPath: keyPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +158,20 @@ func getOrCreateKeyAt(dataDir string) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt encrypts data using AES-GCM
|
// 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) {
|
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)
|
block, err := aes.NewCipher(c.key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -260,3 +260,45 @@ func TestLargeDataEncryption(t *testing.T) {
|
||||||
t.Error("Large data round-trip failed")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,13 @@ else
|
||||||
log_info "Generated dev encryption key at ${DEV_KEY_FILE}"
|
log_info "Generated dev encryption key at ${DEV_KEY_FILE}"
|
||||||
fi
|
fi
|
||||||
export PULSE_ENCRYPTION_KEY="$(<"${DEV_KEY_FILE}")"
|
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
|
else
|
||||||
log_warn "No encryption key found for ${PULSE_DATA_DIR}. Encrypted config may fail to load."
|
log_warn "No encryption key found for ${PULSE_DATA_DIR}. Encrypted config may fail to load."
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue