374 lines
16 KiB
PHP
374 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Secrets;
|
|
|
|
use App\Models\Operator;
|
|
use App\Services\Stripe\StripeCheck;
|
|
use App\Support\OperatingMode;
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
|
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}> */
|
|
/**
|
|
* What the owner may change here — curated, never "everything in config".
|
|
*
|
|
* The list is the whole point of the area. With one entry it was a page
|
|
* that existed to hold a single Stripe key, which is not worth a password
|
|
* gate; these are the credentials that actually stop the business when
|
|
* they expire or leak, and none of them can otherwise be rotated without a
|
|
* shell on the server. ssh.private_key is the most sensitive value here —
|
|
* it opens every server this installation owns.
|
|
*
|
|
* mail.password lived here too, until the mailboxes table (see its own
|
|
* migration) took over sending credentials — a single SMTP password could
|
|
* not say that an invoice comes from billing@ while a maintenance notice
|
|
* comes from no-reply@.
|
|
*
|
|
* `check` is optional and names a class that can verify the value before it
|
|
* is stored. Where there is none, no test button is offered — a button that
|
|
* silently checks something else is worse than no button.
|
|
*
|
|
* `env_key` is the literal .env variable name the raw editor on the console
|
|
* page (App\Livewire\Admin\Integrations) marks as vault-managed — shown so
|
|
* an operator editing that file directly can see why a changed line has no
|
|
* visible effect (get() prefers a stored row over config() every time; see
|
|
* the docblock above). Which console SECTION an entry belongs beside is not
|
|
* metadata here — Integrations' own view picks each entry up by key, so the
|
|
* grouping lives in one place, the template, rather than two.
|
|
*
|
|
* `config_test` is the .env fallback for the TEST slot, where an entry has a
|
|
* separate variable per mode. Only Stripe's signing secret does — Stripe
|
|
* issues one per mode, so the server file has always carried two lines — and
|
|
* without it the test slot would fall back to the LIVE line and the console
|
|
* would report a value that verifies nothing.
|
|
*
|
|
* STRIPE_WEBHOOK_SECRET used to be deliberately absent here, on the grounds
|
|
* that it is read on every incoming payment event and a database problem
|
|
* would make signature verification fail silently. That did not survive
|
|
* being looked at: `stripe.secret` — the key that actually takes the money —
|
|
* is read from this very table on every charge, and the CHOICE between the
|
|
* two signing secrets already asked the database (OperatingMode::current()).
|
|
* The reasoning protected nothing and cost the operator a credential they
|
|
* could only set by editing a file, under a readiness check that called it
|
|
* blocking. The .env lines still work as the fallback below.
|
|
*
|
|
* Also deliberately NOT here: KUMA_PASSWORD / KUMA_TOTP. They authenticate
|
|
* docker/kuma-bridge/app.py — a separate Python container — to Uptime Kuma
|
|
* at process boot; nothing in this Laravel app ever reads them, so storing
|
|
* them here would recreate exactly the decorative-field problem this
|
|
* registry exists to avoid. They stay in the server file.
|
|
*/
|
|
public const REGISTRY = [
|
|
'stripe.secret' => [
|
|
'config' => 'services.stripe.secret',
|
|
'label' => 'secrets.item.stripe_secret',
|
|
'check' => StripeCheck::class,
|
|
'env_key' => 'STRIPE_SECRET',
|
|
// Kein Rückfall, in keine Richtung, und auch nicht auf die Umgebung.
|
|
// Ohne das benutzt ein Testkauf bei fehlendem Testschlüssel still den
|
|
// Live-Schlüssel und bucht echtes Geld ab, während die Konsole
|
|
// „Testbetrieb aktiv" anzeigt. Jeder andere Eintrag hier bezeichnet
|
|
// dasselbe Konto in beiden Modi; dieser nicht.
|
|
'strict' => true,
|
|
],
|
|
'stripe.webhook_secret' => [
|
|
'config' => 'services.stripe.webhook_secret',
|
|
// Stripe issues a SEPARATE signing secret per mode — see the note
|
|
// on config_test above.
|
|
'config_test' => 'services.stripe.webhook_secret_test',
|
|
'label' => 'secrets.item.stripe_webhook_secret',
|
|
'env_key' => 'STRIPE_WEBHOOK_SECRET / STRIPE_WEBHOOK_SECRET_TEST',
|
|
// Kein Einspringen des Live-Platzes für einen leeren Testplatz —
|
|
// siehe isPerMode(). Stripe stellt je Modus ein eigenes Geheimnis
|
|
// aus; der Live-Wert prüft ein Testereignis nicht, er lässt es nur
|
|
// scheitern, während die Konsole „belegt" meldet.
|
|
'per_mode' => true,
|
|
],
|
|
'dns.token' => [
|
|
'config' => 'provisioning.dns.token',
|
|
'label' => 'secrets.item.dns_token',
|
|
'env_key' => 'HETZNER_DNS_TOKEN',
|
|
],
|
|
'monitoring.token' => [
|
|
'config' => 'services.monitoring.token',
|
|
'label' => 'secrets.item.monitoring_token',
|
|
'env_key' => 'MONITORING_API_TOKEN',
|
|
],
|
|
'inbound_mail.password' => [
|
|
'config' => 'services.inbound_mail.password',
|
|
'label' => 'secrets.item.inbound_mail_password',
|
|
'env_key' => 'INBOUND_MAIL_PASSWORD',
|
|
],
|
|
'ssh.private_key' => [
|
|
'config' => 'provisioning.ssh.private_key',
|
|
'label' => 'secrets.item.ssh_private_key',
|
|
// File-or-inline (see config/provisioning.php's $secretFrom) — the
|
|
// editor names both, since either can be the one actually in force.
|
|
'env_key' => 'CLUPILOT_SSH_PRIVATE_KEY / CLUPILOT_SSH_PRIVATE_KEY_PATH',
|
|
],
|
|
];
|
|
|
|
public function has(string $key): bool
|
|
{
|
|
return $this->row($key) !== null;
|
|
}
|
|
|
|
/**
|
|
* The stored value, or the configured one when nothing is stored.
|
|
*
|
|
* Signature unchanged from before the vault grew a test/live split — the
|
|
* three callers (HttpStripeClient, StripeCheck, SshTraefikWriter) have no
|
|
* business knowing which mode is active; that is resolved in here.
|
|
*
|
|
* 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->inForce($key, OperatingMode::current());
|
|
|
|
if ($row === null) {
|
|
// Strict entries never see the environment: see isStrict() below.
|
|
if ($this->isStrict($key)) {
|
|
return null;
|
|
}
|
|
|
|
$configured = config(self::configKey($key));
|
|
|
|
return $configured === null ? null : (string) $configured;
|
|
}
|
|
|
|
try {
|
|
return app(SecretCipher::class)->decrypt($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 under this mode comes from — four answers now.
|
|
*
|
|
* 'stored_live' is the third, and it exists because the second-best answers
|
|
* were both lies. The measured case: mode test, dns.token only in the :live
|
|
* slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live
|
|
* value (the fallback it has always had), while this method reported
|
|
* 'environment' — the console then read "Aus der Serverdatei" over a
|
|
* credential that is in force from the database, and an operator changing
|
|
* the .env line would have seen no effect at all. That is R19's dummy in
|
|
* miniature: a display that reads like an answer, is wrong, and stops the
|
|
* next person looking.
|
|
*
|
|
* Returning 'stored' instead would trade one lie for another: the console
|
|
* shows the "Vergessen" button only for 'stored', and it would then have
|
|
* pointed at the EMPTY test slot, which forget() would happily delete
|
|
* nothing from.
|
|
*
|
|
* The $mode argument names which mode is IN FORCE, not which row to look
|
|
* at — same question get() answers, so that the two cannot disagree.
|
|
*/
|
|
public function source(string $key, ?OperatingMode $mode = null): string
|
|
{
|
|
$this->assertKnown($key);
|
|
|
|
return match (true) {
|
|
$this->row($key, $mode) !== null => 'stored',
|
|
$this->fallback($key, $mode) !== null => 'stored_live',
|
|
$this->isStrict($key) => 'none',
|
|
// The line for THIS mode, not just any line: Stripe's signing
|
|
// secret has one per mode, and reporting "aus der Serverdatei" off
|
|
// the live line while the test slot is what is in force is the same
|
|
// dummy source() was fixed for once already.
|
|
filled(config(self::configKey($key, $mode))) => 'environment',
|
|
default => 'none',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Which .env-backed config key backs this entry under this mode.
|
|
*
|
|
* One key for almost everything — the same credential names the same
|
|
* account in both modes. Stripe's signing secret is the exception: Stripe
|
|
* issues one per mode, the server file has always held two lines, and
|
|
* falling the test slot back to the live line would hand out a secret that
|
|
* verifies nothing while the console called it set.
|
|
*/
|
|
private static function configKey(string $key, ?OperatingMode $mode = null): string
|
|
{
|
|
$meta = self::REGISTRY[$key];
|
|
$mode ??= OperatingMode::current();
|
|
|
|
return $mode->isTest() && isset($meta['config_test'])
|
|
? $meta['config_test']
|
|
: $meta['config'];
|
|
}
|
|
|
|
public function outline(string $key, ?OperatingMode $mode = null): ?string
|
|
{
|
|
return $this->inForce($key, $mode)?->outline;
|
|
}
|
|
|
|
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
|
|
{
|
|
return $this->inForce($key, $mode)?->updated_at;
|
|
}
|
|
|
|
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
|
|
{
|
|
$this->assertKnown($key);
|
|
|
|
if (trim($value) === '') {
|
|
throw new RuntimeException('Refusing to store an empty secret.');
|
|
}
|
|
|
|
$attributes = [
|
|
'value' => app(SecretCipher::class)->encrypt($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, $mode) === null) {
|
|
$attributes['created_at'] = now();
|
|
}
|
|
|
|
DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes);
|
|
}
|
|
|
|
/** Remove the stored value, falling back to whatever the environment says. */
|
|
public function forget(string $key, ?OperatingMode $mode = null): void
|
|
{
|
|
$this->assertKnown($key);
|
|
DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->delete();
|
|
}
|
|
|
|
/** Is storing credentials possible at all on this installation? */
|
|
public function isUsable(): bool
|
|
{
|
|
return app(SecretCipher::class)->isUsable();
|
|
}
|
|
|
|
/** The storage key for one entry: an entry has two slots, test and live. */
|
|
private function rowKey(string $key, ?OperatingMode $mode = null): string
|
|
{
|
|
return $key.':'.($mode ?? OperatingMode::current())->value;
|
|
}
|
|
|
|
private function row(string $key, ?OperatingMode $mode = null): ?object
|
|
{
|
|
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
|
|
}
|
|
|
|
/**
|
|
* The stored row that actually applies under this mode: its own slot, or
|
|
* the live slot standing in for it.
|
|
*
|
|
* ONE place, called by get(), outline() and updatedAt() — and asked by
|
|
* source() through fallback() — because these four answering the same
|
|
* question separately is precisely how the console came to say "nothing
|
|
* stored" about a credential the provisioning run was using.
|
|
*/
|
|
private function inForce(string $key, ?OperatingMode $mode = null): ?object
|
|
{
|
|
return $this->row($key, $mode) ?? $this->fallback($key, $mode);
|
|
}
|
|
|
|
/**
|
|
* The live slot filling in for an empty test slot.
|
|
*
|
|
* ONLY from test to live, never the other way: using a test credential in
|
|
* live operation because the real one is missing is the dangerous
|
|
* direction, and must stay an explicit, visible gap instead. Strict entries
|
|
* (Stripe) have no fallback at all, in either direction.
|
|
*/
|
|
private function fallback(string $key, ?OperatingMode $mode = null): ?object
|
|
{
|
|
$mode ??= OperatingMode::current();
|
|
|
|
if (! $mode->isTest() || $this->isStrict($key) || $this->isPerMode($key)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->row($key, OperatingMode::Live);
|
|
}
|
|
|
|
/**
|
|
* Entries whose value is issued SEPARATELY per mode — no stored row of one
|
|
* mode may ever stand in for the other.
|
|
*
|
|
* Different from `strict` in exactly one respect, and that respect is the
|
|
* point: a strict entry sees no environment at all, this one still falls
|
|
* back to the server file — to ITS OWN line for this mode (`config_test`).
|
|
* Stripe's signing secret is the case: two variables have always been in
|
|
* the .env, one per mode, so the file half was never ambiguous. It was the
|
|
* STORED half that could cross over, and it did so invisibly — test-mode
|
|
* webhooks would be verified with the live secret, fail every one, while
|
|
* source() reported the slot as filled from the live one.
|
|
*
|
|
* Codex review 2026-07-31, P1. `strict` alone would have been the wrong
|
|
* cure: it would have cut the .env fallback too, and every installation
|
|
* carrying STRIPE_WEBHOOK_SECRET in its server file would have started
|
|
* rejecting live payment events on upgrade.
|
|
*/
|
|
private function isPerMode(string $key): bool
|
|
{
|
|
return (bool) (self::REGISTRY[$key]['per_mode'] ?? false);
|
|
}
|
|
|
|
/** Does this entry carry the strict trait? Populated in task 3. */
|
|
private function isStrict(string $key): bool
|
|
{
|
|
return (bool) (self::REGISTRY[$key]['strict'] ?? false);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|