diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php
index 2fc30c7..4b85c19 100644
--- a/app/Livewire/Admin/Vpn.php
+++ b/app/Livewire/Admin/Vpn.php
@@ -222,6 +222,50 @@ class Vpn extends Component
$this->dispatch('notify', message: __('vpn.deleted'));
}
+ /**
+ * Replace an access's keypair.
+ *
+ * The way back for an access whose config was never stored: nobody can hand
+ * out a private key that no longer exists, so the honest option is a new
+ * one. The old key stops working the moment the hub is updated, which is
+ * also what makes this the right tool for a lost or leaked device.
+ */
+ public function reissue(string $uuid): void
+ {
+ $peer = VpnPeer::query()->where('uuid', $uuid)->first();
+ if ($peer === null) {
+ return;
+ }
+ $this->authorize('update', $peer);
+
+ if ($peer->kind !== VpnPeer::KIND_STAFF) {
+ $this->dispatch('notify', message: __('vpn.reissue_staff_only'));
+
+ return;
+ }
+
+ $keypair = Keypair::generate();
+ $oldKey = $peer->public_key;
+ $config = $this->clientConfig($keypair, $peer->allowed_ip);
+
+ $peer->forceFill([
+ 'public_key' => $keypair->publicKey,
+ 'present' => false,
+ 'config_secret' => $peer->hasStoredConfig() ? ConfigVault::encrypt($config) : null,
+ ])->save();
+
+ // Old key off the hub first, then the new one on: the other order would
+ // briefly leave two peers claiming the same tunnel address.
+ ApplyVpnPeer::dispatch($oldKey, null, false);
+ ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
+
+ $this->dismissConfig();
+ $this->configToken = ConfigHandoff::put($config);
+ $this->newConfigName = $peer->name;
+
+ $this->dispatch('notify', message: __('vpn.reissued'));
+ }
+
public function dismissConfig(): void
{
ConfigHandoff::forget($this->configToken);
diff --git a/lang/de/vpn.php b/lang/de/vpn.php
index 68789ed..1d49770 100644
--- a/lang/de/vpn.php
+++ b/lang/de/vpn.php
@@ -76,6 +76,12 @@ return [
'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.',
'config_unreadable' => 'Die gespeicherte Konfiguration lässt sich nicht entschlüsseln. Bitte den Zugang neu ausstellen.',
+ 'no_stored_config' => 'Für diesen Zugang wurde die Konfiguration nicht gespeichert — der private Schlüssel existiert nur noch auf Ihrem Gerät. Über „Neu ausstellen" bekommen Sie eine neue.',
+ 'reissue' => 'Neu ausstellen',
+ 'reissue_confirm' => 'Für :name ein neues Schlüsselpaar erzeugen? Die bisherige Konfiguration wird sofort ungültig.',
+ 'reissued' => 'Neuer Schlüssel erzeugt — die alte Konfiguration gilt nicht mehr.',
+ 'reissue_staff_only' => 'Host-Zugänge werden über die Host-Verwaltung erneuert, nicht hier.',
+
'hub' => 'Hub',
'endpoint' => 'Endpunkt',
'hub_key' => 'Public Key',
diff --git a/lang/en/vpn.php b/lang/en/vpn.php
index 8be3b62..8e82ef4 100644
--- a/lang/en/vpn.php
+++ b/lang/en/vpn.php
@@ -76,6 +76,12 @@ return [
'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.',
'config_unreadable' => 'The stored configuration cannot be decrypted. Please re-issue the access.',
+ 'no_stored_config' => 'This access was created without storing its configuration — the private key only exists on your device. Use "Re-issue" to get a new one.',
+ 'reissue' => 'Re-issue',
+ 'reissue_confirm' => 'Generate a new keypair for :name? The current configuration stops working immediately.',
+ 'reissued' => 'New key issued — the old configuration no longer works.',
+ 'reissue_staff_only' => 'Host accesses are renewed through host management, not here.',
+
'hub' => 'Hub',
'endpoint' => 'Endpoint',
'hub_key' => 'Public key',
diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php
index 850ee8e..95d937e 100644
--- a/resources/views/components/ui/icon.blade.php
+++ b/resources/views/components/ui/icon.blade.php
@@ -18,6 +18,7 @@
'download' => '