Skip to content

Commit

Permalink
feat: Added encryption password support
Browse files Browse the repository at this point in the history
  • Loading branch information
lewislarsen committed Aug 13, 2024
1 parent 4729e3c commit c3b33ff
Show file tree
Hide file tree
Showing 15 changed files with 214 additions and 0 deletions.
1 change: 1 addition & 0 deletions app/Http/Controllers/Api/BackupTaskController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/BackupTaskResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions app/Livewire/BackupTasks/Forms/CreateBackupTaskForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class CreateBackupTaskForm extends Component

public ?string $isolatedPassword = null;

public ?string $encryptionPassword = null;

public int $currentStep = 1;

public int $totalSteps = 6;
Expand Down Expand Up @@ -112,6 +114,7 @@ class CreateBackupTaskForm extends Component
'frequency' => 'Backup Frequency',
'timeToRun' => 'Time to Backup',
'cronExpression' => 'Cron Expression',
'encryptionPassword' => 'Encryption Password',
];

/**
Expand Down Expand Up @@ -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_]+)*)$/'],
Expand Down Expand Up @@ -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(),
];
Expand Down Expand Up @@ -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,
];
}

Expand Down
11 changes: 11 additions & 0 deletions app/Livewire/BackupTasks/Forms/UpdateBackupTaskForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class UpdateBackupTaskForm extends Component

public ?string $isolatedPassword = null;

public ?string $encryptionPassword = null;

/** @var Collection<int, RemoteServer>|null */
public ?Collection $remoteServers = null;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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())],
Expand Down Expand Up @@ -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;

Expand Down
9 changes: 9 additions & 0 deletions app/Models/BackupTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -1055,6 +1063,7 @@ protected function casts(): array
return [
'last_run_at' => 'datetime',
'last_scheduled_weekly_run_at' => 'datetime',
'encryption_password' => 'encrypted',
];
}

Expand Down
121 changes: 121 additions & 0 deletions app/Services/Backup/Tasks/AbstractBackupTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions app/Services/Backup/Tasks/DatabaseBackupTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand Down
4 changes: 4 additions & 0 deletions app/Services/Backup/Tasks/FileBackupTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('backup_tasks', function (Blueprint $table) {
$table->string('encryption_password')->nullable();
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ class="h-full {{ $index < $currentStep - 1 ? 'bg-green-500' : 'bg-gray-300 dark:
<x-input-explain>{{ __('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.') }}</x-input-explain>
</div>
@endif
<div class="mt-4">
<x-input-label for="encryptionPassword" :value="__('Encryption Password')"/>
<x-text-input id="encryptionPassword" class="block mt-1 w-full" type="password"
wire:model="encryptionPassword"
name="encryptionPassword"/>
<x-input-error :messages="$errors->get('encryptionPassword')" class="mt-2"/>
<x-input-explain>{{ __('You can optionally set an encryption password which will enhance the security of this backup.') }}</x-input-explain>
</div>
<div class="mt-4">
<x-input-label for="appendedFileName" :value="__('Additional Filename Text')"/>
<x-text-input id="appendedFileName" class="block mt-1 w-full" type="text"
Expand Down Expand Up @@ -394,6 +402,10 @@ class="relative bg-gray-50 dark:bg-gray-700 rounded-lg p-4 transition-all durati
@svg('heroicon-o-archive-box', 'w-5 h-5 mr-2
text-yellow-500')
@break
@case(__('Supplied Encryption Password'))
@svg('heroicon-o-key', 'w-5 h-5 mr-2
text-yellow-500')
@break
@case(__('Backup Destination'))
@svg('heroicon-o-cloud', 'w-5 h-5 mr-2 text-indigo-500')
@break
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@
<x-input-explain>{{ __('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.') }}</x-input-explain>
</div>
@endif
<div class="mt-4">
<x-input-label for="encryptionPassword" :value="__('Encryption Password')"/>
<x-text-input id="encryptionPassword" class="block mt-1 w-full" type="password"
wire:model="encryptionPassword"
name="encryptionPassword"/>
<x-input-error :messages="$errors->get('encryptionPassword')" class="mt-2"/>
<x-input-explain>{{ __('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.') }}</x-input-explain>
</div>
<div class="mt-4">
<x-input-label for="appendedFileName" :value="__('Additional Filename Text')"/>
<x-text-input id="appendedFileName" class="block mt-1 w-full" type="text" wire:model="appendedFileName"
Expand Down
2 changes: 2 additions & 0 deletions tests/Feature/BackupTasks/Api/BackupTaskApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
'notification_streams_count',
'status',
'has_isolated_credentials',
'has_encryption_password',
'last_run_local_time',
'last_run_utc_time',
'created_at',
Expand Down Expand Up @@ -206,6 +207,7 @@
],
'status' => $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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
'excludedDatabaseTables' => 'table1,table2',
'selectedTags' => $tagIds,
'selectedStreams' => $notificationStreamIds,
'encryptionPassword' => 'password123456',
];

$testable->set($updatedData)->call('submit')->assertHasNoErrors();
Expand Down
18 changes: 18 additions & 0 deletions tests/Unit/Models/BackupTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});

0 comments on commit c3b33ff

Please sign in to comment.