From c3b33ff4876ef96e4bd437161cda4fc60f93ff34 Mon Sep 17 00:00:00 2001 From: Lewis Larsen Date: Tue, 13 Aug 2024 20:34:30 +0100 Subject: [PATCH] feat: Added encryption password support --- .../Controllers/Api/BackupTaskController.php | 1 + app/Http/Resources/BackupTaskResource.php | 1 + .../Forms/CreateBackupTaskForm.php | 6 + .../Forms/UpdateBackupTaskForm.php | 11 ++ app/Models/BackupTask.php | 9 ++ .../Backup/Tasks/AbstractBackupTask.php | 121 ++++++++++++++++++ .../Backup/Tasks/DatabaseBackupTask.php | 4 + app/Services/Backup/Tasks/FileBackupTask.php | 4 + ...ryption_password_to_backup_tasks_table.php | 15 +++ .../forms/create-backup-task-form.blade.php | 12 ++ .../forms/update-backup-task-form.blade.php | 8 ++ .../BackupTasks/Api/BackupTaskApiTest.php | 2 + .../Livewire/CreateBackupTaskFormTest.php | 1 + .../Livewire/UpdateBackupTaskFormTest.php | 1 + tests/Unit/Models/BackupTaskTest.php | 18 +++ 15 files changed, 214 insertions(+) create mode 100644 database/migrations/2024_08_13_180556_add_encryption_password_to_backup_tasks_table.php diff --git a/app/Http/Controllers/Api/BackupTaskController.php b/app/Http/Controllers/Api/BackupTaskController.php index 2e3b720a..d4cb6d7e 100644 --- a/app/Http/Controllers/Api/BackupTaskController.php +++ b/app/Http/Controllers/Api/BackupTaskController.php @@ -213,6 +213,7 @@ private function validateBackupTask(Request $request, bool $isUpdate = false): a 'excluded_database_tables' => ['nullable', 'string'], 'isolated_username' => ['nullable', 'string'], 'isolated_password' => ['nullable', 'string'], + 'encryption_password' => ['nullable', 'string'], 'time_to_run_at' => ['required_without:custom_cron_expression', 'nullable', 'date_format:H:i'], 'custom_cron_expression' => ['required_without:time_to_run_at', 'nullable', 'string'], ]; diff --git a/app/Http/Resources/BackupTaskResource.php b/app/Http/Resources/BackupTaskResource.php index cc827fde..844ca89c 100644 --- a/app/Http/Resources/BackupTaskResource.php +++ b/app/Http/Resources/BackupTaskResource.php @@ -43,6 +43,7 @@ public function toArray(Request $request): array 'notification_streams_count' => $this->resource->notificationStreams()->count ?? 0, 'status' => $this->resource->status, 'has_isolated_credentials' => ! is_null($this->resource->isolated_username) && ! is_null($this->resource->isolated_password), + 'has_encryption_password' => ! is_null($this->resource->encryption_password), 'last_run_local_time' => $this->resource->lastRunFormatted(), 'last_run_utc_time' => $this->resource->last_run_at, 'paused_at' => $this->resource->paused_at, diff --git a/app/Livewire/BackupTasks/Forms/CreateBackupTaskForm.php b/app/Livewire/BackupTasks/Forms/CreateBackupTaskForm.php index 679d7cd1..15b062a9 100644 --- a/app/Livewire/BackupTasks/Forms/CreateBackupTaskForm.php +++ b/app/Livewire/BackupTasks/Forms/CreateBackupTaskForm.php @@ -67,6 +67,8 @@ class CreateBackupTaskForm extends Component public ?string $isolatedPassword = null; + public ?string $encryptionPassword = null; + public int $currentStep = 1; public int $totalSteps = 6; @@ -112,6 +114,7 @@ class CreateBackupTaskForm extends Component 'frequency' => 'Backup Frequency', 'timeToRun' => 'Time to Backup', 'cronExpression' => 'Cron Expression', + 'encryptionPassword' => 'Encryption Password', ]; /** @@ -310,6 +313,7 @@ public function rules(): array $baseRules = [ 'isolatedUsername' => ['nullable', 'string'], 'isolatedPassword' => ['nullable', 'string'], + 'encryptionPassword' => ['nullable', 'string'], 'selectedStreams' => ['nullable', 'array', Rule::exists('notification_streams', 'id')->where('user_id', Auth::id())], 'selectedTags' => ['nullable', 'array', Rule::exists('tags', 'id')->where('user_id', Auth::id())], 'excludedDatabaseTables' => ['nullable', 'string', 'regex:/^([a-zA-Z0-9_]+(,[a-zA-Z0-9_]+)*)$/'], @@ -395,6 +399,7 @@ public function getSummary(): array ? "Custom: {$this->cronExpression}" : ucfirst((string) $this->frequency) . " at {$this->timeToRun}", 'Using Isolated Environment' => $this->useIsolatedCredentials ? 'Yes' : 'No', + 'Supplied Encryption Password' => $this->encryptionPassword ? 'Yes' : 'No', 'Tags' => $this->getSelectedTagLabels(), 'Notification Streams' => $this->getSelectedStreamLabels(), ]; @@ -652,6 +657,7 @@ private function prepareBackupTaskData(): array 'excluded_database_tables' => $this->excludedDatabaseTables, 'isolated_username' => $this->isolatedUsername, 'isolated_password' => $this->isolatedPassword ? Crypt::encryptString($this->isolatedPassword) : null, + 'encryption_password' => $this->encryptionPassword, ]; } diff --git a/app/Livewire/BackupTasks/Forms/UpdateBackupTaskForm.php b/app/Livewire/BackupTasks/Forms/UpdateBackupTaskForm.php index 13c16985..ec187b5e 100644 --- a/app/Livewire/BackupTasks/Forms/UpdateBackupTaskForm.php +++ b/app/Livewire/BackupTasks/Forms/UpdateBackupTaskForm.php @@ -69,6 +69,8 @@ class UpdateBackupTaskForm extends Component public ?string $isolatedPassword = null; + public ?string $encryptionPassword = null; + /** @var Collection|null */ public ?Collection $remoteServers = null; @@ -186,6 +188,7 @@ private function fillFormFromBackupTask(): void 'store_path' => 'storePath', 'excluded_database_tables' => 'excludedDatabaseTables', 'isolated_username' => 'isolatedUsername', + 'encryption_password' => null, ]; foreach ($attributeMap as $modelAttribute => $formProperty) { @@ -232,6 +235,7 @@ private function processScheduleSettings(): void private function rules(): array { $baseRules = [ + 'encryptionPassword' => ['nullable', 'string'], 'isolatedUsername' => ['nullable', 'string'], 'isolatedPassword' => ['nullable', 'string'], 'selectedStreams' => ['nullable', 'array', Rule::exists('notification_streams', 'id')->where('user_id', Auth::id())], @@ -328,6 +332,13 @@ private function updateBackupTask(): void ]); } + if (! is_null($this->encryptionPassword)) { + $this->backupTask->updateQuietly([ + // We're using 'encryption' cast here. + 'encryption_password' => $this->encryptionPassword, + ]); + } + /** @var Tag $tags */ $tags = $this->selectedTags; diff --git a/app/Models/BackupTask.php b/app/Models/BackupTask.php index a0793aae..8c1a4788 100644 --- a/app/Models/BackupTask.php +++ b/app/Models/BackupTask.php @@ -986,6 +986,14 @@ public function hasIsolatedCredentials(): bool return $this->getAttribute('isolated_username') !== null && $this->getAttribute('isolated_password') !== null; } + /** + * Check if the task has an encryption password set. + */ + public function hasEncryptionPassword(): bool + { + return $this->getAttribute('encryption_password') !== null; + } + /** * Format the backup task's last run time according to the user's locale preferences. */ @@ -1055,6 +1063,7 @@ protected function casts(): array return [ 'last_run_at' => 'datetime', 'last_scheduled_weekly_run_at' => 'datetime', + 'encryption_password' => 'encrypted', ]; } diff --git a/app/Services/Backup/Tasks/AbstractBackupTask.php b/app/Services/Backup/Tasks/AbstractBackupTask.php index d9f8c663..f127df99 100644 --- a/app/Services/Backup/Tasks/AbstractBackupTask.php +++ b/app/Services/Backup/Tasks/AbstractBackupTask.php @@ -10,6 +10,7 @@ use App\Models\BackupTaskLog; use App\Models\User; use App\Services\Backup\Backup; +use App\Services\Backup\Contracts\SFTPInterface; use Carbon\Carbon; use Exception; use Illuminate\Support\Facades\Log; @@ -192,4 +193,124 @@ protected function logMessage(string $message): void $this->logWithTimestamp($message, $user->getAttribute('timezone')); } + + /** + * Encrypts the backup file on the remote server using the specified password. + * + * @param SFTPInterface $sftp The SFTP connection + * @param string $remoteFilePath The path to the file to be encrypted + * + * @throws RuntimeException|Exception If encryption fails + */ + protected function setFileEncryption(SFTPInterface $sftp, string $remoteFilePath): void + { + $this->ensureEncryptionPassword(); + $this->ensureOpensslCommandExists($sftp); + + $iv = $this->generateSecureIV(); + $encryptCommand = $this->buildEncryptCommand($remoteFilePath, $iv); + + $this->logMessage('Encrypting backup file.'); + $result = $sftp->exec($encryptCommand); + + if ($result === false) { + $this->handleEncryptionFailure($sftp->getLastError() ?: 'Unknown error during encryption'); + } + + if (is_string($result) && stripos($result, 'error') !== false) { + $this->handleEncryptionFailure($result); + } + + $this->logMessage('Backup file encrypted successfully.'); + } + + /** + * Ensures that the required 'openssl' command is available on the remote system. + * + * @param SFTPInterface $sftp The SFTP connection to use for command execution + * + * @throws RuntimeException If the 'openssl' command is not available + */ + private function ensureOpensslCommandExists(SFTPInterface $sftp): void + { + $result = $sftp->exec('command -v openssl'); + if ($result === false || ($result === '' || $result === '0')) { + $this->logError('The openssl command is not available on the remote system.'); + throw new RuntimeException('Required openssl command not found on the remote system.'); + } + } + + /** + * Ensures that an encryption password is set for the backup task. + * + * @throws RuntimeException If no encryption password is set + */ + private function ensureEncryptionPassword(): void + { + if (! $this->backupTask->hasEncryptionPassword()) { + $this->logError('Attempted to set encryption for this backup but no encryption password was supplied.', ['backup_task' => $this->backupTask]); + throw new RuntimeException('Encryption password is missing.'); + } + } + + /** + * Generates a cryptographically secure initialization vector (IV) for encryption. + * + * @return string A 16-byte string to be used as the IV + * + * @throws RuntimeException If a secure IV cannot be generated + */ + private function generateSecureIV(): string + { + /** @var string|false $iv */ + $iv = openssl_random_pseudo_bytes(16, $strong); + + if ($iv === false || ! $strong) { + $this->logError('Failed to generate a cryptographically strong IV.'); + throw new RuntimeException('Failed to generate a secure initialization vector.'); + } + + return $iv; + } + + /** + * Builds the OpenSSL command for encrypting the backup file. + * + * @param string $remoteFilePath The path to the file to be encrypted on the remote server + * @param string $iv The initialization vector to use for encryption + * @return string The complete OpenSSL command for file encryption + */ + private function buildEncryptCommand(string $remoteFilePath, string $iv): string + { + $encryptionPassword = $this->backupTask->getAttribute('encryption_password'); + $ivHex = bin2hex($iv); + + return sprintf( + 'openssl enc -aes-256-cbc -in %s -out %s.enc -pass pass:%s -pbkdf2 -iter 100000 -nosalt && ' . + 'echo -n %s | xxd -r -p | cat - %s.enc > %s.tmp && ' . + 'mv %s.tmp %s && rm %s.enc', + escapeshellarg($remoteFilePath), + escapeshellarg($remoteFilePath), + escapeshellarg((string) $encryptionPassword), + $ivHex, + escapeshellarg($remoteFilePath), + escapeshellarg($remoteFilePath), + escapeshellarg($remoteFilePath), + escapeshellarg($remoteFilePath), + escapeshellarg($remoteFilePath) + ); + } + + /** + * Handles encryption failure by logging the error and throwing an exception. + * + * @param string $error The error message describing why encryption failed + * + * @throws RuntimeException Always thrown to indicate encryption failure + */ + private function handleEncryptionFailure(string $error): void + { + $this->logError('Failed to encrypt the backup file.', ['error' => $error]); + throw new RuntimeException('Failed to encrypt the backup file: ' . $error); + } } diff --git a/app/Services/Backup/Tasks/DatabaseBackupTask.php b/app/Services/Backup/Tasks/DatabaseBackupTask.php index bf3ef22a..e242ae2b 100644 --- a/app/Services/Backup/Tasks/DatabaseBackupTask.php +++ b/app/Services/Backup/Tasks/DatabaseBackupTask.php @@ -62,6 +62,10 @@ protected function performBackup(): void $this->dumpRemoteDatabase($sftp, $databaseType, $remoteDumpPath, $databasePassword, $databaseName, $this->backupTask->getAttribute('excluded_database_tables')); + if ($this->backupTask->hasEncryptionPassword()) { + $this->setFileEncryption($sftp, $remoteDumpPath); + } + if (! $this->backupDestinationDriver($backupDestinationModel->type, $sftp, $remoteDumpPath, $backupDestinationModel, $dumpFileName, $storagePath)) { throw new RuntimeException('Failed to upload the dump file to destination.'); } diff --git a/app/Services/Backup/Tasks/FileBackupTask.php b/app/Services/Backup/Tasks/FileBackupTask.php index 8d466807..8e491200 100644 --- a/app/Services/Backup/Tasks/FileBackupTask.php +++ b/app/Services/Backup/Tasks/FileBackupTask.php @@ -75,6 +75,10 @@ protected function performBackup(): void $this->zipRemoteDirectory($sftp, $sourcePath, $remoteZipPath, $excludeDirs); $this->logMessage(sprintf('Directory compression complete. Archive location: %s.', $remoteZipPath)); + if ($this->backupTask->hasEncryptionPassword()) { + $this->setFileEncryption($sftp, $remoteZipPath); + } + $this->backupTask->setScriptUpdateTime(); if (! $this->backupDestinationDriver($backupDestinationModel->type, $sftp, $remoteZipPath, $backupDestinationModel, $zipFileName, $storagePath)) { diff --git a/database/migrations/2024_08_13_180556_add_encryption_password_to_backup_tasks_table.php b/database/migrations/2024_08_13_180556_add_encryption_password_to_backup_tasks_table.php new file mode 100644 index 00000000..cee52e48 --- /dev/null +++ b/database/migrations/2024_08_13_180556_add_encryption_password_to_backup_tasks_table.php @@ -0,0 +1,15 @@ +string('encryption_password')->nullable(); + }); + } +}; diff --git a/resources/views/livewire/backup-tasks/forms/create-backup-task-form.blade.php b/resources/views/livewire/backup-tasks/forms/create-backup-task-form.blade.php index e47fbc4e..57d520c5 100644 --- a/resources/views/livewire/backup-tasks/forms/create-backup-task-form.blade.php +++ b/resources/views/livewire/backup-tasks/forms/create-backup-task-form.blade.php @@ -217,6 +217,14 @@ class="h-full {{ $index < $currentStep - 1 ? 'bg-green-500' : 'bg-gray-300 dark: {{ __('If you want to exclude certain tables from the backup, you can list them here, separated by commas. This can be useful if you have large tables that you don\'t need to back up.') }} @endif +
+ + + + {{ __('You can optionally set an encryption password which will enhance the security of this backup.') }} +
+ + + + {{ __('You can optionally set an encryption password which will enhance the security of this backup. If you have an encrypted password already, you can change it by updating this field.') }} +
$task->status, 'has_isolated_credentials' => ! is_null($task->isolated_username) && ! is_null($task->isolated_password), + 'has_encryption_password' => ! is_null($task->encryption_password), 'last_run_local_time' => $task->lastRunFormatted(), 'last_run_utc_time' => $task->last_run_at, 'paused_at' => $task->paused_at, diff --git a/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php b/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php index e90ba67e..f0f5df0a 100644 --- a/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php +++ b/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php @@ -52,6 +52,7 @@ ->set('excludedDatabaseTables', 'table1,table2') ->set('selectedTags', $tags->pluck('id')->toArray()) ->set('selectedStreams', $notificationStreams->pluck('id')->toArray()) + ->set('encryptionPassword', 'password123') ->call('submit'); $this->assertDatabaseHas('backup_tasks', [ diff --git a/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php b/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php index 6d7da255..2ba9ed79 100644 --- a/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php +++ b/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php @@ -54,6 +54,7 @@ 'excludedDatabaseTables' => 'table1,table2', 'selectedTags' => $tagIds, 'selectedStreams' => $notificationStreamIds, + 'encryptionPassword' => 'password123456', ]; $testable->set($updatedData)->call('submit')->assertHasNoErrors(); diff --git a/tests/Unit/Models/BackupTaskTest.php b/tests/Unit/Models/BackupTaskTest.php index 6634ef3c..fc15e9b5 100644 --- a/tests/Unit/Models/BackupTaskTest.php +++ b/tests/Unit/Models/BackupTaskTest.php @@ -1592,3 +1592,21 @@ BackupTaskLog::query()->delete(); } }); + +it('returns true if an encryption password is set', function (): void { + + $task = BackupTask::factory()->create([ + 'encryption_password' => 'password123', + ]); + + $this->assertTrue($task->hasEncryptionPassword()); +}); + +it('returns false if an encryption password is not set', function (): void { + + $task = BackupTask::factory()->create([ + 'isolated_username' => null, + ]); + + $this->assertFalse($task->hasEncryptionPassword()); +});