*/ 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'); } }