From 93d409e18f994a4680047ae999daeb8b1b469cd6 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 21:09:10 +0200 Subject: [PATCH] Pull the secrets encrypter out where two callers can share it --- app/Services/Secrets/SecretCipher.php | 65 ++++++++++++++++++++++++ app/Services/Secrets/SecretVault.php | 43 ++-------------- tests/Feature/Admin/SecretCipherTest.php | 36 +++++++++++++ 3 files changed, 104 insertions(+), 40 deletions(-) create mode 100644 app/Services/Secrets/SecretCipher.php create mode 100644 tests/Feature/Admin/SecretCipherTest.php diff --git a/app/Services/Secrets/SecretCipher.php b/app/Services/Secrets/SecretCipher.php new file mode 100644 index 0000000..c9b6cd6 --- /dev/null +++ b/app/Services/Secrets/SecretCipher.php @@ -0,0 +1,65 @@ +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'); + } +} diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index 1398df9..97d4292 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -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'); - } } diff --git a/tests/Feature/Admin/SecretCipherTest.php b/tests/Feature/Admin/SecretCipherTest.php new file mode 100644 index 0000000..e95fba6 --- /dev/null +++ b/tests/Feature/Admin/SecretCipherTest.php @@ -0,0 +1,36 @@ +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'); +});