*/ /** * 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. * * Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every * incoming payment event, and a database problem would turn signature * verification into a silent failure. It stays in the server file. * * 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, ], '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::REGISTRY[$key]['config']); 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', filled(config(self::REGISTRY[$key]['config'])) => 'environment', default => 'none', }; } 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)) { return null; } return $this->row($key, OperatingMode::Live); } /** 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); } }