diff --git a/cmd/backup_impl.go b/cmd/backup_impl.go index 2adb60c..e6050af 100755 --- a/cmd/backup_impl.go +++ b/cmd/backup_impl.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "os" + "path/filepath" + "strings" + "time" "dbbackup/internal/backup" "dbbackup/internal/config" @@ -80,6 +83,15 @@ func runClusterBackup(ctx context.Context) error { return err } + // Apply encryption if requested + if isEncryptionEnabled() { + if err := encryptLatestClusterBackup(); err != nil { + log.Error("Failed to encrypt backup", "error", err) + return fmt.Errorf("backup succeeded but encryption failed: %w", err) + } + log.Info("Cluster backup encrypted successfully") + } + // Audit log: backup success auditLogger.LogBackupComplete(user, "all_databases", cfg.BackupDir, 0) @@ -218,6 +230,15 @@ func runSingleBackup(ctx context.Context, databaseName string) error { return backupErr } + // Apply encryption if requested + if isEncryptionEnabled() { + if err := encryptLatestBackup(databaseName); err != nil { + log.Error("Failed to encrypt backup", "error", err) + return fmt.Errorf("backup succeeded but encryption failed: %w", err) + } + log.Info("Backup encrypted successfully") + } + // Audit log: backup success auditLogger.LogBackupComplete(user, databaseName, cfg.BackupDir, 0) @@ -338,6 +359,15 @@ func runSampleBackup(ctx context.Context, databaseName string) error { return err } + // Apply encryption if requested + if isEncryptionEnabled() { + if err := encryptLatestBackup(databaseName); err != nil { + log.Error("Failed to encrypt backup", "error", err) + return fmt.Errorf("backup succeeded but encryption failed: %w", err) + } + log.Info("Sample backup encrypted successfully") + } + // Audit log: backup success auditLogger.LogBackupComplete(user, databaseName, cfg.BackupDir, 0) @@ -353,4 +383,125 @@ func runSampleBackup(ctx context.Context, databaseName string) error { } return nil -} \ No newline at end of file +} +// encryptLatestBackup finds and encrypts the most recent backup for a database +func encryptLatestBackup(databaseName string) error { + // Load encryption key + key, err := loadEncryptionKey(encryptionKeyFile, encryptionKeyEnv) + if err != nil { + return err + } + + // Find most recent backup file for this database + backupPath, err := findLatestBackup(cfg.BackupDir, databaseName) + if err != nil { + return err + } + + // Encrypt the backup + return backup.EncryptBackupFile(backupPath, key, log) +} + +// encryptLatestClusterBackup finds and encrypts the most recent cluster backup +func encryptLatestClusterBackup() error { + // Load encryption key + key, err := loadEncryptionKey(encryptionKeyFile, encryptionKeyEnv) + if err != nil { + return err + } + + // Find most recent cluster backup + backupPath, err := findLatestClusterBackup(cfg.BackupDir) + if err != nil { + return err + } + + // Encrypt the backup + return backup.EncryptBackupFile(backupPath, key, log) +} + +// findLatestBackup finds the most recently created backup file for a database +func findLatestBackup(backupDir, databaseName string) (string, error) { +entries, err := os.ReadDir(backupDir) +if err != nil { +return "", fmt.Errorf("failed to read backup directory: %w", err) +} + +var latestPath string +var latestTime time.Time + +prefix := "db_" + databaseName + "_" +for _, entry := range entries { +if entry.IsDir() { +continue +} + +name := entry.Name() +// Skip metadata files and already encrypted files +if strings.HasSuffix(name, ".meta.json") || strings.HasSuffix(name, ".encrypted") { +continue +} + +// Match database backup files +if strings.HasPrefix(name, prefix) && (strings.HasSuffix(name, ".dump") || +strings.HasSuffix(name, ".dump.gz") || strings.HasSuffix(name, ".sql.gz")) { +info, err := entry.Info() +if err != nil { +continue +} + +if info.ModTime().After(latestTime) { +latestTime = info.ModTime() +latestPath = filepath.Join(backupDir, name) +} +} +} + +if latestPath == "" { +return "", fmt.Errorf("no backup found for database: %s", databaseName) +} + +return latestPath, nil +} + +// findLatestClusterBackup finds the most recently created cluster backup +func findLatestClusterBackup(backupDir string) (string, error) { +entries, err := os.ReadDir(backupDir) +if err != nil { +return "", fmt.Errorf("failed to read backup directory: %w", err) +} + +var latestPath string +var latestTime time.Time + +for _, entry := range entries { +if entry.IsDir() { +continue +} + +name := entry.Name() +// Skip metadata files and already encrypted files +if strings.HasSuffix(name, ".meta.json") || strings.HasSuffix(name, ".encrypted") { +continue +} + +// Match cluster backup files +if strings.HasPrefix(name, "cluster_") && strings.HasSuffix(name, ".tar.gz") { +info, err := entry.Info() +if err != nil { +continue +} + +if info.ModTime().After(latestTime) { +latestTime = info.ModTime() +latestPath = filepath.Join(backupDir, name) +} +} +} + +if latestPath == "" { +return "", fmt.Errorf("no cluster backup found") +} + +return latestPath, nil +} diff --git a/cmd/encryption.go b/cmd/encryption.go new file mode 100644 index 0000000..ab03e79 --- /dev/null +++ b/cmd/encryption.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "encoding/base64" + "fmt" + "os" + "strings" + + "dbbackup/internal/crypto" +) + +// loadEncryptionKey loads encryption key from file or environment variable +func loadEncryptionKey(keyFile, keyEnvVar string) ([]byte, error) { + // Priority 1: Key file + if keyFile != "" { + keyData, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("failed to read encryption key file: %w", err) + } + + // Try to decode as base64 first + if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(keyData))); err == nil && len(decoded) == crypto.KeySize { + return decoded, nil + } + + // Use raw bytes if exactly 32 bytes + if len(keyData) == crypto.KeySize { + return keyData, nil + } + + // Otherwise treat as passphrase and derive key + salt, err := crypto.GenerateSalt() + if err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + key := crypto.DeriveKey([]byte(strings.TrimSpace(string(keyData))), salt) + return key, nil + } + + // Priority 2: Environment variable + if keyEnvVar != "" { + keyData := os.Getenv(keyEnvVar) + if keyData == "" { + return nil, fmt.Errorf("encryption enabled but %s environment variable not set", keyEnvVar) + } + + // Try to decode as base64 first + if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyData)); err == nil && len(decoded) == crypto.KeySize { + return decoded, nil + } + + // Otherwise treat as passphrase and derive key + salt, err := crypto.GenerateSalt() + if err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + key := crypto.DeriveKey([]byte(strings.TrimSpace(keyData)), salt) + return key, nil + } + + return nil, fmt.Errorf("encryption enabled but no key source specified (use --encryption-key-file or set %s)", keyEnvVar) +} + +// isEncryptionEnabled checks if encryption is requested +func isEncryptionEnabled() bool { + return encryptBackupFlag +} + +// generateEncryptionKey generates a new random encryption key +func generateEncryptionKey() ([]byte, error) { + salt, err := crypto.GenerateSalt() + if err != nil { + return nil, err + } + // For key generation, use salt as both password and salt (random) + return crypto.DeriveKey(salt, salt), nil +} diff --git a/internal/backup/encryption.go b/internal/backup/encryption.go new file mode 100644 index 0000000..5ee116e --- /dev/null +++ b/internal/backup/encryption.go @@ -0,0 +1,114 @@ +package backup + +import ( + "fmt" + "os" + "path/filepath" + + "dbbackup/internal/crypto" + "dbbackup/internal/logger" + "dbbackup/internal/metadata" +) + +// EncryptBackupFile encrypts a backup file in-place +// The original file is replaced with the encrypted version +func EncryptBackupFile(backupPath string, key []byte, log logger.Logger) error { + log.Info("Encrypting backup file", "file", filepath.Base(backupPath)) + + // Validate key + if err := crypto.ValidateKey(key); err != nil { + return fmt.Errorf("invalid encryption key: %w", err) + } + + // Create encryptor + encryptor := crypto.NewAESEncryptor() + + // Generate encrypted file path + encryptedPath := backupPath + ".encrypted.tmp" + + // Encrypt file + if err := encryptor.EncryptFile(backupPath, encryptedPath, key); err != nil { + // Clean up temp file on failure + os.Remove(encryptedPath) + return fmt.Errorf("encryption failed: %w", err) + } + + // Update metadata to indicate encryption + metaPath := backupPath + ".meta.json" + if _, err := os.Stat(metaPath); err == nil { + // Load existing metadata + meta, err := metadata.Load(metaPath) + if err != nil { + log.Warn("Failed to load metadata for encryption update", "error", err) + } else { + // Mark as encrypted + meta.Encrypted = true + meta.EncryptionAlgorithm = string(crypto.AlgorithmAES256GCM) + + // Save updated metadata + if err := metadata.Save(metaPath, meta); err != nil { + log.Warn("Failed to update metadata with encryption info", "error", err) + } + } + } + + // Remove original unencrypted file + if err := os.Remove(backupPath); err != nil { + log.Warn("Failed to remove original unencrypted file", "error", err) + // Don't fail - encrypted file exists + } + + // Rename encrypted file to original name + if err := os.Rename(encryptedPath, backupPath); err != nil { + return fmt.Errorf("failed to rename encrypted file: %w", err) + } + + log.Info("Backup encrypted successfully", "file", filepath.Base(backupPath)) + return nil +} + +// IsBackupEncrypted checks if a backup file is encrypted +func IsBackupEncrypted(backupPath string) bool { + // Check metadata first + metaPath := backupPath + ".meta.json" + if meta, err := metadata.Load(metaPath); err == nil { + return meta.Encrypted + } + + // Fallback: check if file starts with encryption nonce + file, err := os.Open(backupPath) + if err != nil { + return false + } + defer file.Close() + + // Try to read nonce - if it succeeds, likely encrypted + nonce := make([]byte, crypto.NonceSize) + if n, err := file.Read(nonce); err != nil || n != crypto.NonceSize { + return false + } + + return true +} + +// DecryptBackupFile decrypts an encrypted backup file +// Creates a new decrypted file +func DecryptBackupFile(encryptedPath, outputPath string, key []byte, log logger.Logger) error { + log.Info("Decrypting backup file", "file", filepath.Base(encryptedPath)) + + // Validate key + if err := crypto.ValidateKey(key); err != nil { + return fmt.Errorf("invalid decryption key: %w", err) + } + + // Create encryptor + encryptor := crypto.NewAESEncryptor() + + // Decrypt file + if err := encryptor.DecryptFile(encryptedPath, outputPath, key); err != nil { + return fmt.Errorf("decryption failed (wrong key?): %w", err) + } + + log.Info("Backup decrypted successfully", "output", filepath.Base(outputPath)) + return nil +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index c9b548f..03c5973 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -30,6 +30,10 @@ type BackupMetadata struct { Duration float64 `json:"duration_seconds"` ExtraInfo map[string]string `json:"extra_info,omitempty"` + // Encryption fields (v2.3+) + Encrypted bool `json:"encrypted"` // Whether backup is encrypted + EncryptionAlgorithm string `json:"encryption_algorithm,omitempty"` // e.g., "aes-256-gcm" + // Incremental backup fields (v2.2+) Incremental *IncrementalMetadata `json:"incremental,omitempty"` // Only present for incremental backups } diff --git a/internal/metadata/save.go b/internal/metadata/save.go new file mode 100644 index 0000000..c306fec --- /dev/null +++ b/internal/metadata/save.go @@ -0,0 +1,21 @@ +package metadata + +import ( + "encoding/json" + "fmt" + "os" +) + +// Save writes BackupMetadata to a .meta.json file +func Save(metaPath string, metadata *BackupMetadata) error { + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + if err := os.WriteFile(metaPath, data, 0644); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + return nil +} diff --git a/tests/encryption_smoke_test.sh b/tests/encryption_smoke_test.sh new file mode 100755 index 0000000..c0e0e29 --- /dev/null +++ b/tests/encryption_smoke_test.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Quick smoke test for encryption feature + +set -e + +echo "===================================" +echo "Encryption Feature Smoke Test" +echo "===================================" +echo + +# Setup +TEST_DIR="/tmp/dbbackup_encrypt_test_$$" +mkdir -p "$TEST_DIR" +cd "$TEST_DIR" + +# Generate test key +echo "Step 1: Generate test key..." +echo "test-encryption-key-32bytes!!" > key.txt +KEY_BASE64=$(base64 -w 0 < key.txt) +export DBBACKUP_ENCRYPTION_KEY="$KEY_BASE64" + +# Create test backup file +echo "Step 2: Create test backup..." +echo "This is test backup data for encryption testing." > test_backup.dump +echo "It contains multiple lines to ensure proper encryption." >> test_backup.dump +echo "$(date)" >> test_backup.dump + +# Create metadata +cat > test_backup.dump.meta.json < encrypt_test.go <<'GOCODE' +package main + +import ( + "fmt" + "os" + + "dbbackup/internal/backup" + "dbbackup/internal/crypto" + "dbbackup/internal/logger" +) + +func main() { + log := logger.New("info", "text") + + // Load key from env + keyB64 := os.Getenv("DBBACKUP_ENCRYPTION_KEY") + if keyB64 == "" { + fmt.Println("ERROR: DBBACKUP_ENCRYPTION_KEY not set") + os.Exit(1) + } + + // Derive key + salt, _ := crypto.GenerateSalt() + key := crypto.DeriveKey([]byte(keyB64), salt) + + // Encrypt + if err := backup.EncryptBackupFile("test_backup.dump", key, log); err != nil { + fmt.Printf("ERROR: Encryption failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("✅ Encryption successful") + + // Decrypt + if err := backup.DecryptBackupFile("test_backup.dump", "test_backup_decrypted.dump", key, log); err != nil { + fmt.Printf("ERROR: Decryption failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("✅ Decryption successful") +} +GOCODE + +# Temporarily copy go.mod +cp /root/dbbackup/go.mod . +cp /root/dbbackup/go.sum . 2>/dev/null || true + +# Run encryption test +echo "Running Go encryption test..." +go run encrypt_test.go +echo + +# Verify decrypted content +echo "Step 4: Verify decrypted content..." +if diff -q test_backup_decrypted.dump <(echo "This is test backup data for encryption testing."; echo "It contains multiple lines to ensure proper encryption.") >/dev/null 2>&1; then + echo "✅ Content verification: PASS (decrypted matches original - first 2 lines)" +else + echo "❌ Content verification: FAIL" + echo "Expected first 2 lines to match" + exit 1 +fi + +echo +echo "===================================" +echo "✅ All encryption tests PASSED" +echo "===================================" + +# Cleanup +cd / +rm -rf "$TEST_DIR"