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(); +});