191 lines
6.3 KiB
PHP
191 lines
6.3 KiB
PHP
<?php
|
|
|
|
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;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Credentials the owner can change from the console instead of over SSH.
|
|
*
|
|
* Three rules, each because the obvious alternative is worse:
|
|
*
|
|
* 1. A CURATED registry, never arbitrary key/value. A form that can set any
|
|
* environment variable is a privilege-escalation primitive — APP_KEY,
|
|
* DB_PASSWORD, MAIL_* — and one bad value bricks the installation with no
|
|
* way back through that same form.
|
|
*
|
|
* 2. Its own encryption key (SECRETS_KEY), not APP_KEY. Rotating APP_KEY is
|
|
* ordinary maintenance and would otherwise render every stored credential
|
|
* unreadable — discovered when Stripe stops answering, not when it happens.
|
|
*
|
|
* 3. Read where it is USED, never overlaid onto config at boot. An overlay adds
|
|
* a query to every request including the public site, and leaves long-running
|
|
* queue workers holding whatever was true when they started.
|
|
*
|
|
* The webhook signing secret is deliberately NOT in here. It is read on every
|
|
* incoming payment event; moving it into the database would turn a database
|
|
* problem into "webhooks silently fail signature verification", which is the
|
|
* one failure mode that must stay loud.
|
|
*/
|
|
final class SecretVault
|
|
{
|
|
/** @var array<string, array{config: string, label: string}> */
|
|
public const REGISTRY = [
|
|
'stripe.secret' => ['config' => 'services.stripe.secret', 'label' => 'Stripe Secret Key'],
|
|
];
|
|
|
|
public function has(string $key): bool
|
|
{
|
|
return $this->row($key) !== null;
|
|
}
|
|
|
|
/**
|
|
* The stored value, or the configured one when nothing is stored.
|
|
*
|
|
* Falls back only when there is genuinely no row — never on a decryption
|
|
* failure. Falling back then would quietly resume using an old key from the
|
|
* environment while the console shows the new one.
|
|
*/
|
|
public function get(string $key): ?string
|
|
{
|
|
$this->assertKnown($key);
|
|
$row = $this->row($key);
|
|
|
|
if ($row === null) {
|
|
$configured = config(self::REGISTRY[$key]['config']);
|
|
|
|
return $configured === null ? null : (string) $configured;
|
|
}
|
|
|
|
try {
|
|
return $this->encrypter()->decryptString($row->value);
|
|
} catch (DecryptException $e) {
|
|
Log::error('A stored secret could not be decrypted', ['key' => $key]);
|
|
|
|
throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e);
|
|
}
|
|
}
|
|
|
|
/** Where the value in force comes from: stored, environment, or nowhere. */
|
|
public function source(string $key): string
|
|
{
|
|
$this->assertKnown($key);
|
|
|
|
return match (true) {
|
|
$this->row($key) !== null => 'stored',
|
|
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
|
|
default => 'none',
|
|
};
|
|
}
|
|
|
|
public function outline(string $key): ?string
|
|
{
|
|
return $this->row($key)?->outline;
|
|
}
|
|
|
|
public function updatedAt(string $key): ?string
|
|
{
|
|
return $this->row($key)?->updated_at;
|
|
}
|
|
|
|
public function put(string $key, string $value, User $by): void
|
|
{
|
|
$this->assertKnown($key);
|
|
|
|
if (trim($value) === '') {
|
|
throw new RuntimeException('Refusing to store an empty secret.');
|
|
}
|
|
|
|
$attributes = [
|
|
'value' => $this->encrypter()->encryptString($value),
|
|
'outline' => $this->sketch($value),
|
|
'updated_by' => $by->id,
|
|
'updated_at' => now(),
|
|
];
|
|
|
|
// created_at only on the first write: rotating a key must not rewrite
|
|
// when it was first set, which is the one piece of audit metadata that
|
|
// answers "how long has this been here?".
|
|
if ($this->row($key) === null) {
|
|
$attributes['created_at'] = now();
|
|
}
|
|
|
|
DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes);
|
|
}
|
|
|
|
/** Remove the stored value, falling back to whatever the environment says. */
|
|
public function forget(string $key): void
|
|
{
|
|
$this->assertKnown($key);
|
|
DB::table('app_secrets')->where('key', $key)->delete();
|
|
}
|
|
|
|
/** Is storing credentials possible at all on this installation? */
|
|
public function isUsable(): bool
|
|
{
|
|
return (string) config('admin_access.secrets_key') !== '';
|
|
}
|
|
|
|
private function row(string $key): ?object
|
|
{
|
|
return DB::table('app_secrets')->where('key', $key)->first();
|
|
}
|
|
|
|
private function assertKnown(string $key): void
|
|
{
|
|
if (! array_key_exists($key, self::REGISTRY)) {
|
|
throw new RuntimeException("Unknown secret [{$key}].");
|
|
}
|
|
}
|
|
|
|
/** Enough to tell two keys apart, useless to anyone reading over a shoulder. */
|
|
private function sketch(string $value): string
|
|
{
|
|
return Str::length($value) <= 8
|
|
? 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');
|
|
}
|
|
}
|