Pull the secrets encrypter out where two callers can share it

feat/mailboxes
nexxo 2026-07-27 21:09:10 +02:00
parent 0b7961ec36
commit 93d409e18f
3 changed files with 104 additions and 40 deletions

View File

@ -0,0 +1,65 @@
<?php
// app/Services/Secrets/SecretCipher.php
namespace App\Services\Secrets;
use Illuminate\Encryption\Encrypter;
use RuntimeException;
/**
* The SECRETS_KEY encrypter, in one place.
*
* Its own key rather than APP_KEY, because rotating APP_KEY is ordinary
* maintenance and would otherwise render every stored credential unreadable
* discovered when Stripe stops answering, not when it happens.
*
* Extracted from SecretVault so mailbox passwords use the SAME key handling
* rather than a second copy of it. The copy is what would rot: the two
* spellings below exist because requiring the `base64:` prefix once made every
* read and write fail on exactly the setup the config comment documents.
*/
final class SecretCipher
{
public function encrypt(string $plain): string
{
return $this->encrypter()->encryptString($plain);
}
public function decrypt(string $cipher): string
{
return $this->encrypter()->decryptString($cipher);
}
/** Is storing credentials possible at all on this installation? */
public function isUsable(): bool
{
return (string) config('admin_access.secrets_key') !== '';
}
private function encrypter(): Encrypter
{
$key = (string) config('admin_access.secrets_key');
if ($key === '') {
throw new RuntimeException('SECRETS_KEY is not set — refusing to store or read credentials.');
}
if (str_starts_with($key, 'base64:')) {
$key = substr($key, 7);
}
if (strlen($key) !== 32) {
$decoded = base64_decode($key, true);
if ($decoded !== false && strlen($decoded) === 32) {
$key = $decoded;
}
}
if (strlen($key) !== 32) {
throw new RuntimeException('SECRETS_KEY must be 32 bytes (or base64 of 32 bytes).');
}
return new Encrypter($key, 'aes-256-cbc');
}
}

View File

@ -4,7 +4,6 @@ namespace App\Services\Secrets;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -97,7 +96,7 @@ final class SecretVault
}
try {
return $this->encrypter()->decryptString($row->value);
return app(SecretCipher::class)->decrypt($row->value);
} catch (DecryptException $e) {
Log::error('A stored secret could not be decrypted', ['key' => $key]);
@ -136,7 +135,7 @@ final class SecretVault
}
$attributes = [
'value' => $this->encrypter()->encryptString($value),
'value' => app(SecretCipher::class)->encrypt($value),
'outline' => $this->sketch($value),
'updated_by' => $by->id,
'updated_at' => now(),
@ -162,7 +161,7 @@ final class SecretVault
/** Is storing credentials possible at all on this installation? */
public function isUsable(): bool
{
return (string) config('admin_access.secrets_key') !== '';
return app(SecretCipher::class)->isUsable();
}
private function row(string $key): ?object
@ -184,40 +183,4 @@ final class SecretVault
? str_repeat('•', Str::length($value))
: Str::substr($value, 0, 3).str_repeat('•', 6).Str::substr($value, -4);
}
/**
* Its own key, and a refusal rather than a silent downgrade: falling back to
* APP_KEY would store payment credentials under a key that gets rotated as
* routine maintenance.
*/
private function encrypter(): Encrypter
{
$key = (string) config('admin_access.secrets_key');
if ($key === '') {
throw new RuntimeException('SECRETS_KEY is not set — refusing to store or read credentials.');
}
// Both spellings. The generator in the config comment produces raw
// base64 with no prefix, and requiring the prefix silently made every
// read and write fail on exactly the documented setup — a 44-byte key
// where AES-256 wants 32.
if (str_starts_with($key, 'base64:')) {
$key = substr($key, 7);
}
if (strlen($key) !== 32) {
$decoded = base64_decode($key, true);
if ($decoded !== false && strlen($decoded) === 32) {
$key = $decoded;
}
}
if (strlen($key) !== 32) {
throw new RuntimeException('SECRETS_KEY must be 32 bytes (or base64 of 32 bytes).');
}
return new Encrypter($key, 'aes-256-cbc');
}
}

View File

@ -0,0 +1,36 @@
<?php
// tests/Feature/Admin/SecretCipherTest.php
use App\Services\Secrets\SecretCipher;
it('round-trips a value with a raw base64 key', function () {
config()->set('admin_access.secrets_key', base64_encode(random_bytes(32)));
$cipher = app(SecretCipher::class);
expect($cipher->decrypt($cipher->encrypt('hunter2')))->toBe('hunter2');
});
it('accepts the base64: prefix as well, because the documented generator omits it', function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
$cipher = app(SecretCipher::class);
expect($cipher->decrypt($cipher->encrypt('hunter2')))->toBe('hunter2');
});
it('refuses rather than silently falling back to APP_KEY', function () {
config()->set('admin_access.secrets_key', '');
expect(fn () => app(SecretCipher::class)->encrypt('x'))
->toThrow(RuntimeException::class, 'SECRETS_KEY');
expect(app(SecretCipher::class)->isUsable())->toBeFalse();
});
it('refuses a key that is not 32 bytes', function () {
config()->set('admin_access.secrets_key', 'zu-kurz');
expect(fn () => app(SecretCipher::class)->encrypt('x'))
->toThrow(RuntimeException::class, '32 bytes');
});