Keep the SFTP credential on the row it belongs to, encrypted
The first version put it in the credential vault. The vault refuses it, and correctly: it keeps a registry of fixed, documented credentials so the Secrets page can describe each one, and a per-destination password is not that. Forcing a dynamic key into that registry would have made the registry meaningless. Encrypted onto the row with the same cipher instead — the same key, so it is no more readable in a database dump than anything the vault holds, and it lives with the record it describes. A rotated SECRETS_KEY now says so by name rather than surfacing as an authentication failure against a host that is perfectly fine. Also answers the question that prompted this: the host, port and user fields are there, and appear once SFTP is chosen. A mounted directory has no host on purpose — the mount is made on the server with mount(8), and the application only names the directory it writes into. A test asserts both halves, because "the fields are there, you just cannot see them yet" is not something anybody should have to take on trust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
b3081a544b
commit
f0ec2fe03f
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Livewire\Admin;
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
use App\Models\ExportTarget;
|
use App\Models\ExportTarget;
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretCipher;
|
||||||
use LivewireUI\Modal\ModalComponent;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,8 +56,7 @@ class EditExportTarget extends ModalComponent
|
||||||
$this->port = $target->port ?: 22;
|
$this->port = $target->port ?: 22;
|
||||||
$this->username = (string) $target->username;
|
$this->username = (string) $target->username;
|
||||||
$this->active = (bool) $target->active;
|
$this->active = (bool) $target->active;
|
||||||
$this->hasPassword = $target->secret_key !== null
|
$this->hasPassword = trim((string) $target->secret_key) !== '';
|
||||||
&& app(SecretVault::class)->has($target->secret_key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save()
|
public function save()
|
||||||
|
|
@ -102,10 +101,15 @@ class EditExportTarget extends ModalComponent
|
||||||
|
|
||||||
$target->save();
|
$target->save();
|
||||||
|
|
||||||
|
// Encrypted onto the row rather than into the vault. The vault keeps a
|
||||||
|
// registry of fixed, documented credentials and refuses anything else,
|
||||||
|
// which is right for it — a per-destination password is not one of
|
||||||
|
// those, and forcing it in would make the registry meaningless. Same
|
||||||
|
// cipher, same key, so it is no more readable in a dump than the rest.
|
||||||
if ($this->driver === ExportTarget::SFTP && $this->password !== '') {
|
if ($this->driver === ExportTarget::SFTP && $this->password !== '') {
|
||||||
$key = 'export_target.'.$target->uuid;
|
$target->forceFill([
|
||||||
app(SecretVault::class)->put($key, $this->password, auth('operator')->user());
|
'secret_key' => app(SecretCipher::class)->encrypt($this->password),
|
||||||
$target->forceFill(['secret_key' => $key])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('finance.targets_saved'));
|
$this->dispatch('notify', message: __('finance.targets_saved'));
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Services\Billing;
|
||||||
|
|
||||||
use App\Models\ExportTarget;
|
use App\Models\ExportTarget;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretCipher;
|
||||||
use Illuminate\Filesystem\FilesystemAdapter;
|
use Illuminate\Filesystem\FilesystemAdapter;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -21,9 +21,9 @@ use RuntimeException;
|
||||||
* thing stays testable against a temporary directory.
|
* thing stays testable against a temporary directory.
|
||||||
*
|
*
|
||||||
* 'sftp' is for something reachable over the network with credentials — a
|
* 'sftp' is for something reachable over the network with credentials — a
|
||||||
* Hetzner Storage Box being the obvious one. The password lives in the secret
|
* Hetzner Storage Box being the obvious one. The password is encrypted on the
|
||||||
* vault and only its KEY is stored here: a credential in a settings table is a
|
* row with the same cipher the credential vault uses: a credential in a
|
||||||
* credential in every database dump.
|
* settings table in the clear is a credential in every database dump.
|
||||||
*
|
*
|
||||||
* Neither is allowed to create its own root. See ensureRoot().
|
* Neither is allowed to create its own root. See ensureRoot().
|
||||||
*/
|
*/
|
||||||
|
|
@ -31,7 +31,7 @@ final class InvoiceArchive
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly InvoiceRenderer $renderer,
|
private readonly InvoiceRenderer $renderer,
|
||||||
private readonly SecretVault $vault,
|
private readonly SecretCipher $cipher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -117,10 +117,19 @@ final class InvoiceArchive
|
||||||
private function disk(ExportTarget $target): FilesystemAdapter
|
private function disk(ExportTarget $target): FilesystemAdapter
|
||||||
{
|
{
|
||||||
if ($target->driver === ExportTarget::SFTP) {
|
if ($target->driver === ExportTarget::SFTP) {
|
||||||
$password = $target->secret_key ? $this->vault->get($target->secret_key) : null;
|
$stored = trim((string) $target->secret_key);
|
||||||
|
|
||||||
if ($password === null || $password === '') {
|
if ($stored === '') {
|
||||||
throw new RuntimeException("No usable credential for {$target->name}.");
|
throw new RuntimeException("No credential stored for {$target->name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = $this->cipher->decrypt($stored);
|
||||||
|
|
||||||
|
if ($password === '') {
|
||||||
|
// A rotated SECRETS_KEY makes every stored credential
|
||||||
|
// unreadable. Saying so beats an authentication failure against
|
||||||
|
// a host that is perfectly fine.
|
||||||
|
throw new RuntimeException("The credential for {$target->name} cannot be decrypted — has SECRETS_KEY changed?");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Storage::build([
|
return Storage::build([
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
use App\Livewire\Admin\EditExportTarget;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('shows host, port and user only once SFTP is chosen', function () {
|
||||||
|
$c = Livewire::actingAs(operator('Owner'), 'operator')->test(EditExportTarget::class);
|
||||||
|
|
||||||
|
// A mounted directory has no host: the mount is made on the server with
|
||||||
|
// mount(8), and the application only names the directory it writes into.
|
||||||
|
$c->assertDontSee(__('finance.target.host'))
|
||||||
|
->assertSee(__('finance.target.path'));
|
||||||
|
|
||||||
|
$c->set('driver', 'sftp')
|
||||||
|
->assertSee(__('finance.target.host'))
|
||||||
|
->assertSee(__('finance.target.port'))
|
||||||
|
->assertSee(__('finance.target.username'))
|
||||||
|
->assertSee(__('finance.target.password'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores an SFTP destination with its host and keeps the password out of the row', function () {
|
||||||
|
Livewire::actingAs(operator('Owner'), 'operator')
|
||||||
|
->test(EditExportTarget::class)
|
||||||
|
->set('name', 'Storage Box')
|
||||||
|
->set('driver', 'sftp')
|
||||||
|
->set('host', 'u123456.your-storagebox.de')
|
||||||
|
->set('port', 23)
|
||||||
|
->set('username', 'u123456')
|
||||||
|
->set('password', 'geheim')
|
||||||
|
->set('path', '/rechnungen')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$target = App\Models\ExportTarget::query()->where('name', 'Storage Box')->firstOrFail();
|
||||||
|
|
||||||
|
expect($target->host)->toBe('u123456.your-storagebox.de')
|
||||||
|
->and($target->port)->toBe(23)
|
||||||
|
->and($target->username)->toBe('u123456')
|
||||||
|
// Stored encrypted, never in the clear — a credential in a settings
|
||||||
|
// table in plain text is a credential in every database dump.
|
||||||
|
->and($target->secret_key)->not->toBe('geheim')
|
||||||
|
->and($target->secret_key)->not->toBeEmpty()
|
||||||
|
->and(app(App\Services\Secrets\SecretCipher::class)->decrypt($target->secret_key))->toBe('geheim');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue