Pull the secrets encrypter out where two callers can share it
parent
0b7961ec36
commit
93d409e18f
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue