From a58faf3f852f869f32ec6d9eb02d786a3335c722 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 09:01:16 +0200 Subject: [PATCH] Manage the Stripe key from the console, behind a password and a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing a key meant a shell, a file edit and a cache rebuild — and the person who owns the Stripe account is not necessarily the person who owns the server. Two gates, not one. The capability decides who may open the page; every operator has console.view, and that must not mean "can read the payment key". The password decides whether this SESSION may see or change anything, because the realistic threat is not a stranger but an unlocked machine, and a session is exactly what that hands over. Both are re-checked server-side on every action — a Livewire action is reachable by anyone who can post to /livewire/update. The value is stored encrypted under a key of its own, SECRETS_KEY, and the vault refuses to work without it rather than falling back to APP_KEY: rotating APP_KEY is ordinary maintenance and would otherwise destroy every stored credential, discovered when Stripe stops answering. It is read where it is used, not overlaid onto config at boot — an overlay adds a query to every request including the public site, and leaves queue workers holding whatever was true when they started. It is never shown again, only outlined, and never enters a Livewire property that would carry it to the browser and back in the snapshot. A registry, not an env editor: a form that can set any environment variable is a privilege-escalation primitive, and one bad value bricks the installation with no way back through that same form. The test button is the part that matters. It reports which Stripe account the key belongs to, whether it is LIVE or test — the most expensive mistake here is pasting one where the other belongs, and both look identical in a form — and which webhook endpoints exist with the events each subscribes to. A key can be perfectly valid while the endpoint listens for the wrong five events, and nothing fails until a payment goes unrecorded. The webhook signing secret deliberately stays in .env. It is read on every incoming payment event; in the database, a database problem becomes silently failing signature checks. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Secrets.php | 136 +++++++++++++ app/Services/Secrets/SecretVault.php | 190 ++++++++++++++++++ app/Services/Stripe/HttpStripeClient.php | 17 +- app/Services/Stripe/StripeCheck.php | 97 +++++++++ config/admin_access.php | 13 ++ ..._07_27_060000_create_app_secrets_table.php | 38 ++++ ...26_07_27_060100_add_secrets_capability.php | 59 ++++++ lang/de/admin.php | 1 + lang/de/secrets.php | 46 +++++ lang/en/admin.php | 1 + lang/en/secrets.php | 46 +++++ resources/views/layouts/admin.blade.php | 7 + .../views/livewire/admin/secrets.blade.php | 146 ++++++++++++++ routes/admin.php | 1 + tests/Feature/Admin/SecretVaultTest.php | 124 ++++++++++++ tests/Feature/Admin/SecretsPageTest.php | 102 ++++++++++ 16 files changed, 1022 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Admin/Secrets.php create mode 100644 app/Services/Secrets/SecretVault.php create mode 100644 app/Services/Stripe/StripeCheck.php create mode 100644 database/migrations/2026_07_27_060000_create_app_secrets_table.php create mode 100644 database/migrations/2026_07_27_060100_add_secrets_capability.php create mode 100644 lang/de/secrets.php create mode 100644 lang/en/secrets.php create mode 100644 resources/views/livewire/admin/secrets.blade.php create mode 100644 tests/Feature/Admin/SecretVaultTest.php create mode 100644 tests/Feature/Admin/SecretsPageTest.php diff --git a/app/Livewire/Admin/Secrets.php b/app/Livewire/Admin/Secrets.php new file mode 100644 index 0000000..84ae6e3 --- /dev/null +++ b/app/Livewire/Admin/Secrets.php @@ -0,0 +1,136 @@ +authorize('secrets.manage'); + } + + public function save(string $key): void + { + $this->guard(); + + $field = self::field($key); + $value = trim((string) ($this->entered[$field] ?? '')); + + if ($value === '') { + $this->addError('entered.'.$field, __('secrets.empty')); + + return; + } + + try { + app(SecretVault::class)->put($key, $value, auth()->user()); + } catch (Throwable $e) { + $this->addError('entered.'.$field, $e->getMessage()); + + return; + } + + // Out of the component state as soon as it is stored. + $this->entered[$field] = ''; + $this->check = null; + $this->dispatch('notify', message: __('secrets.saved')); + } + + public function forget(string $key): void + { + $this->guard(); + + app(SecretVault::class)->forget($key); + $this->check = null; + $this->dispatch('notify', message: __('secrets.removed')); + } + + /** + * Try the key that is in force — or the one being typed, before storing it. + * + * Checking the candidate first is the point: a key that is saved and wrong + * fails later, somewhere else, usually in front of a customer. + */ + public function test(string $key): void + { + $this->guard(); + + $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null; + $this->check = app(StripeCheck::class)->run($candidate); + } + + /** The dotless form key for a registry key. */ + public static function field(string $key): string + { + return str_replace('.', '_', $key); + } + + /** Capability AND a recent password, on every single action. */ + private function guard(): void + { + $this->authorize('secrets.manage'); + abort_unless($this->passwordRecentlyConfirmed(), 403); + } + + public function render() + { + $vault = app(SecretVault::class); + $unlocked = $this->passwordRecentlyConfirmed(); + + return view('livewire.admin.secrets', [ + 'unlocked' => $unlocked, + 'usable' => $vault->isUsable(), + 'entries' => collect(SecretVault::REGISTRY) + ->map(fn (array $meta, string $key) => [ + 'key' => $key, + 'field' => self::field($key), + 'label' => $meta['label'], + 'source' => $vault->source($key), + // Only ever an outline, and only once unlocked. + 'outline' => $unlocked ? $vault->outline($key) : null, + 'updated_at' => $unlocked ? $vault->updatedAt($key) : null, + ]) + ->values() + ->all(), + ]); + } +} diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php new file mode 100644 index 0000000..fc4df9e --- /dev/null +++ b/app/Services/Secrets/SecretVault.php @@ -0,0 +1,190 @@ + */ + 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'); + } +} diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index 24ef015..3430593 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -15,7 +15,7 @@ class HttpStripeClient implements StripeClient { public function isConfigured(): bool { - return filled(config('services.stripe.secret')); + return filled($this->secret()); } public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string @@ -67,7 +67,7 @@ class HttpStripeClient implements StripeClient private function request(?string $idempotencyKey = null): PendingRequest { - $secret = (string) config('services.stripe.secret'); + $secret = (string) $this->secret(); if (blank($secret)) { throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).'); @@ -85,6 +85,19 @@ class HttpStripeClient implements StripeClient : $request; } + /** + * The key in force: the one stored in the console if there is one, else the + * environment. + * + * Resolved HERE, at the point of use, rather than overlaid onto config at + * boot — an overlay would add a query to every request including the public + * site, and a queue worker would keep whatever was true when it started. + */ + private function secret(): ?string + { + return app(\App\Services\Secrets\SecretVault::class)->get('stripe.secret'); + } + private function url(string $path): string { return rtrim((string) config('services.stripe.api_base'), '/').'/'.$path; diff --git a/app/Services/Stripe/StripeCheck.php b/app/Services/Stripe/StripeCheck.php new file mode 100644 index 0000000..ac0338e --- /dev/null +++ b/app/Services/Stripe/StripeCheck.php @@ -0,0 +1,97 @@ + */ + public function run(?string $candidate = null): array + { + $secret = $candidate ?: app(SecretVault::class)->get('stripe.secret'); + + if (blank($secret)) { + return ['ok' => false, 'reason' => 'missing']; + } + + try { + $account = Http::withToken($secret)->acceptJson()->timeout(15) + ->get($this->url('account')); + } catch (Throwable) { + return ['ok' => false, 'reason' => 'unreachable']; + } + + if ($account->status() === 401) { + return ['ok' => false, 'reason' => 'rejected']; + } + + if (! $account->successful()) { + return ['ok' => false, 'reason' => 'error', 'status' => $account->status()]; + } + + return [ + 'ok' => true, + 'account' => $account->json('id'), + 'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'), + // Stripe does not label the key; the mode is inferred from the + // prefix, which is the only thing that is true of both key kinds. + 'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'), + 'restricted' => str_starts_with($secret, 'rk_'), + 'webhooks' => $this->webhooks($secret), + ]; + } + + /** + * The configured webhook endpoints and which events each one subscribes to. + * + * This is the part nobody can see from the outside: a key can be perfectly + * valid while the endpoint listens for the wrong five events, and nothing + * fails until a payment is not recorded. + * + * @return array>|null null when the key may not read them + */ + private function webhooks(string $secret): ?array + { + try { + $response = Http::withToken($secret)->acceptJson()->timeout(15) + ->get($this->url('webhook_endpoints'), ['limit' => 10]); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + // A restricted key legitimately cannot list these. Saying "unknown" + // beats reporting "none configured", which would send someone + // hunting for a problem that is not there. + return null; + } + + return collect($response->json('data', [])) + ->map(fn (array $endpoint) => [ + 'url' => $endpoint['url'] ?? '—', + 'status' => $endpoint['status'] ?? 'unknown', + 'events' => $endpoint['enabled_events'] ?? [], + ]) + ->all(); + } + + private function url(string $path): string + { + return rtrim((string) config('services.stripe.api_base', 'https://api.stripe.com/v1'), '/').'/'.$path; + } +} diff --git a/config/admin_access.php b/config/admin_access.php index 9b033c5..bb22304 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -53,6 +53,19 @@ return [ */ 'vpn_config_key' => env('VPN_CONFIG_KEY', ''), + /* + | Key for credentials stored in the console (32 bytes, base64). + | + | Separate from APP_KEY on purpose: rotating APP_KEY is ordinary + | maintenance, and it would otherwise render every stored credential + | unreadable — discovered when Stripe stops answering. Empty means storing + | credentials is switched off, and the console says so rather than falling + | back to a key that gets rotated. + | + | Generate with: head -c 32 /dev/urandom | base64 + */ + 'secrets_key' => env('SECRETS_KEY', ''), + /* | Networks that count as "us" while the public site is hidden | (PublicSiteGate). The WireGuard management subnet by default, so anyone on diff --git a/database/migrations/2026_07_27_060000_create_app_secrets_table.php b/database/migrations/2026_07_27_060000_create_app_secrets_table.php new file mode 100644 index 0000000..c13296a --- /dev/null +++ b/database/migrations/2026_07_27_060000_create_app_secrets_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('key')->unique(); + $table->text('value'); + // Enough to tell two keys apart at a glance, useless to a shoulder. + $table->string('outline', 32)->nullable(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_secrets'); + } +}; diff --git a/database/migrations/2026_07_27_060100_add_secrets_capability.php b/database/migrations/2026_07_27_060100_add_secrets_capability.php new file mode 100644 index 0000000..7f3f502 --- /dev/null +++ b/database/migrations/2026_07_27_060100_add_secrets_capability.php @@ -0,0 +1,59 @@ +forgetCachedPermissions(); + + // Whether it was ours to create decides whether it is ours to remove: + // rolling back must not delete a permission — and every role assignment + // hanging off it — that existed before this migration ran. + $exists = DB::table('permissions')->where('name', 'secrets.manage')->exists(); + + if (! $exists) { + DB::table('permissions')->insert([ + 'name' => 'secrets.manage', 'guard_name' => 'web', + 'created_at' => now(), 'updated_at' => now(), + ]); + } + + // Owner only. Deliberately not granted to Admin: this is the one page + // where the blast radius is someone else's money. + $permission = DB::table('permissions')->where('name', 'secrets.manage')->value('id'); + $owner = DB::table('roles')->where('name', 'Owner')->value('id'); + + if ($permission && $owner) { + DB::table('role_has_permissions')->updateOrInsert( + ['permission_id' => $permission, 'role_id' => $owner], + ); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + // Deliberately removes nothing. + // + // up() reuses the permission and the grant when they already exist, so + // by the time down() runs there is no way to tell what this migration + // created from what it merely found. Taking a capability away that + // somebody else granted is worse than leaving one in place, and this is + // a grant to the Owner role — the account that would have to fix it. + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index b5f9b7a..cde5116 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -16,6 +16,7 @@ return [ 'maintenance' => 'Wartungen', 'vpn' => 'VPN', 'revenue' => 'Umsatz', + 'secrets' => 'Zugangsdaten', 'settings' => 'Einstellungen', ], diff --git a/lang/de/secrets.php b/lang/de/secrets.php new file mode 100644 index 0000000..ade0f0d --- /dev/null +++ b/lang/de/secrets.php @@ -0,0 +1,46 @@ + 'Zugangsdaten', + 'subtitle' => 'Schlüssel für angebundene Dienste — hier änderbar, ohne Zugriff auf den Server.', + 'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Zugangsdaten gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.', + + 'locked_title' => 'Gesperrt', + 'locked_body' => 'Bitte bestätigen Sie Ihr Passwort. Angemeldet zu sein genügt hier nicht — der realistische Fall ist ein offener Rechner, und genau den gibt eine Sitzung her.', + 'unlock' => 'Entsperren', + 'unlocked_note' => 'Entsperrt. Änderungen wirken sofort auf den laufenden Betrieb.', + 'lock_again' => 'Wieder sperren', + + 'source_stored' => 'Hier hinterlegt', + 'source_environment' => 'Aus der Serverdatei', + 'source_none' => 'Nicht gesetzt', + 'stored_value' => 'Hinterlegt', + 'changed' => 'Geändert', + 'new_value' => 'Neuer Wert', + 'new_value_hint' => 'Wird verschlüsselt gespeichert und nie wieder vollständig angezeigt.', + + 'test' => 'Verbindung testen', + 'save' => 'Speichern', + 'save_confirm' => 'Diesen Schlüssel wirklich übernehmen? Er wirkt sofort — ein falscher Wert legt Zahlungen still.', + 'forget' => 'Hier entfernen', + 'forget_confirm' => 'Hinterlegten Wert entfernen? Danach gilt wieder der Wert aus der Serverdatei — falls dort einer steht.', + 'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', + 'removed' => 'Entfernt. Es gilt wieder der Wert aus der Serverdatei.', + 'empty' => 'Bitte einen Wert eingeben.', + + 'check_title' => 'Ergebnis der Prüfung', + 'check_missing' => 'Es ist kein Schlüssel hinterlegt und auch keiner in der Serverdatei.', + 'check_unreachable' => 'Stripe war nicht erreichbar. Das sagt nichts über den Schlüssel aus.', + 'check_rejected' => 'Stripe hat den Schlüssel abgelehnt. Er ist falsch, widerrufen oder gehört zu einem anderen Konto.', + 'check_error' => 'Stripe hat unerwartet geantwortet.', + 'check_account' => 'Konto', + 'check_mode' => 'Modus', + 'mode_live' => 'LIVE — echte Zahlungen', + 'mode_test' => 'Test', + 'mode_restricted' => 'eingeschränkter Schlüssel', + 'check_webhooks' => 'Hinterlegte Webhooks und ihre Ereignisse', + 'check_webhooks_unknown' => 'Dieser Schlüssel darf die Webhooks nicht lesen — das ist bei eingeschränkten Schlüsseln normal.', + 'check_webhooks_none' => 'Bei Stripe ist kein Webhook eingetragen. Ohne ihn werden Zahlungen nicht verbucht.', + + 'webhook_secret_note' => 'Das Webhook-Signatur-Secret bleibt bewusst in der Serverdatei (.env). Es wird bei jedem eingehenden Zahlungsereignis gelesen — läge es hier, würde ein Datenbankproblem zu stillschweigend fehlschlagenden Signaturprüfungen.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 307c27d..d099a51 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -16,6 +16,7 @@ return [ 'maintenance' => 'Maintenance', 'vpn' => 'VPN', 'revenue' => 'Revenue', + 'secrets' => 'Credentials', 'settings' => 'Settings', ], diff --git a/lang/en/secrets.php b/lang/en/secrets.php new file mode 100644 index 0000000..c757486 --- /dev/null +++ b/lang/en/secrets.php @@ -0,0 +1,46 @@ + 'Credentials', + 'subtitle' => 'Keys for connected services — changeable here, without server access.', + 'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own nothing is stored here — deliberately, because APP_KEY is rotated as routine maintenance.', + + 'locked_title' => 'Locked', + 'locked_body' => 'Confirm your password. Being signed in is not enough here — the realistic case is an unlocked machine, and that is exactly what a session hands over.', + 'unlock' => 'Unlock', + 'unlocked_note' => 'Unlocked. Changes take effect on the running system immediately.', + 'lock_again' => 'Lock again', + + 'source_stored' => 'Stored here', + 'source_environment' => 'From the server file', + 'source_none' => 'Not set', + 'stored_value' => 'Stored', + 'changed' => 'Changed', + 'new_value' => 'New value', + 'new_value_hint' => 'Stored encrypted and never shown in full again.', + + 'test' => 'Test connection', + 'save' => 'Save', + 'save_confirm' => 'Really apply this key? It takes effect immediately — a wrong value stops payments.', + 'forget' => 'Remove from here', + 'forget_confirm' => 'Remove the stored value? The server file applies again — if it has one.', + 'saved' => 'Saved. The new value applies immediately.', + 'removed' => 'Removed. The server file applies again.', + 'empty' => 'Enter a value.', + + 'check_title' => 'Result', + 'check_missing' => 'No key is stored here and none is in the server file.', + 'check_unreachable' => 'Stripe could not be reached. That says nothing about the key.', + 'check_rejected' => 'Stripe rejected the key. It is wrong, revoked, or belongs to another account.', + 'check_error' => 'Stripe answered unexpectedly.', + 'check_account' => 'Account', + 'check_mode' => 'Mode', + 'mode_live' => 'LIVE — real payments', + 'mode_test' => 'Test', + 'mode_restricted' => 'restricted key', + 'check_webhooks' => 'Configured webhooks and their events', + 'check_webhooks_unknown' => 'This key may not read webhooks — normal for a restricted key.', + 'check_webhooks_none' => 'Stripe has no webhook configured. Without one, payments are not recorded.', + + 'webhook_secret_note' => 'The webhook signing secret deliberately stays in the server file (.env). It is read on every incoming payment event — stored here, a database problem would turn into silently failing signature checks.', +]; diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 1644d05..9284f7f 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -48,6 +48,13 @@ @endforeach
+ @can('secrets.manage') + {{-- Only where the capability is held: "can open the console" + must not mean "can read the payment key". --}} + + {{ __('admin.nav.secrets') }} + + @endcan {{ __('admin.nav.settings') }} diff --git a/resources/views/livewire/admin/secrets.blade.php b/resources/views/livewire/admin/secrets.blade.php new file mode 100644 index 0000000..9d491d9 --- /dev/null +++ b/resources/views/livewire/admin/secrets.blade.php @@ -0,0 +1,146 @@ +
+
+

{{ __('secrets.title') }}

+

{{ __('secrets.subtitle') }}

+
+ + @if (! $usable) + {{ __('secrets.no_key') }} + @endif + + @if (! $unlocked) + {{-- The second gate. Being signed in is not enough to read a payment + key: the realistic threat is an unlocked machine, and a session is + exactly what that hands over. --}} +
+

{{ __('secrets.locked_title') }}

+

{{ __('secrets.locked_body') }}

+ +
+
+ +
+ + {{ __('secrets.unlock') }} + +
+
+ @else +
+

{{ __('secrets.unlocked_note') }}

+ +
+ + @foreach ($entries as $entry) +
+
+
+

{{ $entry['label'] }}

+

{{ $entry['key'] }}

+
+ + + {{ __('secrets.source_'.$entry['source']) }} + +
+ + @if ($entry['outline']) +
+
+
{{ __('secrets.stored_value') }}
+
{{ $entry['outline'] }}
+
+ @if ($entry['updated_at']) +
+
{{ __('secrets.changed') }}
+
{{ \Illuminate\Support\Carbon::parse($entry['updated_at'])->diffForHumans() }}
+
+ @endif +
+ @endif + +
+ +
+ +
+ {{-- Test first: a key that is saved and wrong fails later, + somewhere else, usually in front of a customer. --}} + + {{ __('secrets.test') }} + + + + {{ __('secrets.save') }} + + + @if ($entry['source'] === 'stored') + + {{ __('secrets.forget') }} + + @endif +
+
+ @endforeach + + @if ($check !== null) +
+

{{ __('secrets.check_title') }}

+ + @if (! $check['ok']) + {{ __('secrets.check_'.$check['reason']) }} + @else +
+
{{ __('secrets.check_account') }}:
+
{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}
+
{{ __('secrets.check_mode') }}:
+
+ {{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }} +
+
+ + {{-- The part nobody can see from outside: a key can be + perfectly valid while the endpoint listens for the + wrong events, and nothing fails until a payment goes + unrecorded. --}} +

{{ __('secrets.check_webhooks') }}

+ @if ($check['webhooks'] === null) +

{{ __('secrets.check_webhooks_unknown') }}

+ @elseif ($check['webhooks'] === []) + {{ __('secrets.check_webhooks_none') }} + @else +
    + @foreach ($check['webhooks'] as $hook) +
  • +
    + {{ $hook['url'] }} + {{ $hook['status'] }} +
    +
    + @foreach ($hook['events'] as $event) + {{ $event }} + @endforeach +
    +
  • + @endforeach +
+ @endif + @endif +
+ @endif + + {{ __('secrets.webhook_secret_note') }} + @endif +
diff --git a/routes/admin.php b/routes/admin.php index 65a0bb2..f7ee72d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -36,4 +36,5 @@ Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); +Route::get('/secrets', Admin\Secrets::class)->name('secrets'); Route::get('/settings', Admin\Settings::class)->name('settings'); diff --git a/tests/Feature/Admin/SecretVaultTest.php b/tests/Feature/Admin/SecretVaultTest.php new file mode 100644 index 0000000..52432b4 --- /dev/null +++ b/tests/Feature/Admin/SecretVaultTest.php @@ -0,0 +1,124 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + config()->set('services.stripe.secret', 'sk_env_fallback'); +}); + +it('prefers a stored key over the environment, and says which is in force', function () { + $vault = app(SecretVault::class); + + expect($vault->get('stripe.secret'))->toBe('sk_env_fallback') + ->and($vault->source('stripe.secret'))->toBe('environment'); + + $vault->put('stripe.secret', 'sk_live_stored_value', User::factory()->create()); + + expect($vault->get('stripe.secret'))->toBe('sk_live_stored_value') + ->and($vault->source('stripe.secret'))->toBe('stored'); + + // Removing it falls back rather than leaving nothing. + $vault->forget('stripe.secret'); + expect($vault->get('stripe.secret'))->toBe('sk_env_fallback'); +}); + +it('stores the value encrypted, and never in the clear', function () { + app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', User::factory()->create()); + + $stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value'); + + expect($stored)->not->toContain('sk_live_verysecret'); +}); + +it('shows only an outline, never the value', function () { + app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH1234', User::factory()->create()); + + $outline = app(SecretVault::class)->outline('stripe.secret'); + + expect($outline)->toContain('1234')->and($outline)->not->toContain('ABCDEFGH'); +}); + +it('refuses a key it does not know', function () { + // A form that can set any environment variable is a privilege-escalation + // primitive. The registry is the whole point. + expect(fn () => app(SecretVault::class)->put('app.key', 'whatever', User::factory()->create())) + ->toThrow(RuntimeException::class); +}); + +it('refuses to store or read anything without its own key', function () { + // Falling back to APP_KEY would put payment credentials under a key that + // gets rotated as routine maintenance. + config()->set('admin_access.secrets_key', ''); + + expect(app(SecretVault::class)->isUsable())->toBeFalse() + ->and(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create())) + ->toThrow(RuntimeException::class); +}); + +it('fails loudly when a stored value cannot be decrypted, instead of using the old one', function () { + // "The key cannot be read" and "there is no key" call for different + // actions, and only one of them is fixed by typing it in again. + app(SecretVault::class)->put('stripe.secret', 'sk_live_stored', User::factory()->create()); + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + + expect(fn () => app(SecretVault::class)->get('stripe.secret'))->toThrow(RuntimeException::class); +}); + +it('hands the Stripe client the stored key rather than the environment one', function () { + app(SecretVault::class)->put('stripe.secret', 'sk_live_from_console', User::factory()->create()); + + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['id' => 'prod_1'], 200)]); + + app(App\Services\Stripe\HttpStripeClient::class)->createProduct('Test'); + + Illuminate\Support\Facades\Http::assertSent( + fn ($request) => $request->hasHeader('Authorization', 'Bearer sk_live_from_console'), + ); +}); + +it('accepts the key in the format the documentation tells you to generate', function () { + // `head -c 32 /dev/urandom | base64` produces raw base64 with no prefix. + // Requiring the prefix made every read and write fail on exactly the + // documented setup — a 44-byte key where AES-256 wants 32. + $raw = random_bytes(32); + + foreach ([base64_encode($raw), 'base64:'.base64_encode($raw)] as $spelling) { + config()->set('admin_access.secrets_key', $spelling); + + $vault = app(SecretVault::class); + $vault->put('stripe.secret', 'sk_live_roundtrip', User::factory()->create()); + + expect($vault->get('stripe.secret'))->toBe('sk_live_roundtrip', $spelling); + } +}); + +it('refuses a key that is not 32 bytes rather than failing later', function () { + config()->set('admin_access.secrets_key', 'viel-zu-kurz'); + + expect(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create())) + ->toThrow(RuntimeException::class); +}); + +it('keeps the original creation date when a key is rotated', function () { + // The one piece of audit metadata that answers "how long has this been + // here?" must survive a rotation. + $user = User::factory()->create(); + app(SecretVault::class)->put('stripe.secret', 'sk_first', $user); + + $created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'); + + $this->travel(2)->days(); + app(SecretVault::class)->put('stripe.secret', 'sk_second', $user); + + expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created); +}); diff --git a/tests/Feature/Admin/SecretsPageTest.php b/tests/Feature/Admin/SecretsPageTest.php new file mode 100644 index 0000000..85fa0ba --- /dev/null +++ b/tests/Feature/Admin/SecretsPageTest.php @@ -0,0 +1,102 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); +}); + +it('is not reachable without the capability', function () { + // Every operator has console.view. That must not mean "can read the + // payment key". + Livewire::actingAs(operator('Admin')) + ->test(SecretsPage::class) + ->assertForbidden(); +}); + +it('shows nothing until the password is confirmed', function () { + $owner = operator('Owner'); + app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner); + + $page = Livewire::actingAs($owner)->test(SecretsPage::class); + + $page->assertSee(__('secrets.locked_title')) + // Not even the outline before unlocking. + ->assertDontSee('9999') + ->assertViewHas('unlocked', false); +}); + +it('refuses every action while locked, not merely hiding the buttons', function () { + // A Livewire action is reachable by anyone who can post to /livewire/update. + $owner = operator('Owner'); + + foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) { + Livewire::actingAs($owner) + ->test(SecretsPage::class) + ->call($action, $arg) + ->assertForbidden(); + } +}); + +it('unlocks with the password, then stores and clears the entered value', function () { + $owner = operator('Owner'); + + $page = Livewire::actingAs($owner) + ->test(SecretsPage::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->assertHasNoErrors() + ->assertViewHas('unlocked', true); + + $page->set('entered.stripe_secret', 'sk_live_NEWKEY1234') + ->call('save', 'stripe.secret') + ->assertHasNoErrors(); + + expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234') + // Out of the component state as soon as it is stored: a Livewire + // property travels to the browser and back in the snapshot. + ->and($page->get('entered')['stripe_secret'])->toBe(''); +}); + +it('never puts a stored secret into the component snapshot', function () { + $owner = operator('Owner'); + app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner); + + $page = Livewire::actingAs($owner) + ->test(SecretsPage::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword'); + + expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42'); +}); + +it('can be locked again without signing out', function () { + $owner = operator('Owner'); + + Livewire::actingAs($owner) + ->test(SecretsPage::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->assertViewHas('unlocked', true) + ->call('forgetPasswordConfirmation') + ->assertViewHas('unlocked', false); +}); + +it('binds the form to a key Livewire can actually write to', function () { + // A dot in a Livewire property path means nesting, so a registry key with a + // dot in it would be written to entered['stripe']['secret'] and the value + // would never reach the save. + expect(App\Livewire\Admin\Secrets::field('stripe.secret'))->toBe('stripe_secret') + ->and(str_contains(App\Livewire\Admin\Secrets::field('stripe.secret'), '.'))->toBeFalse(); +});