clusev/docs/superpowers/specs/2026-06-14-ssh-key-auto-pro...

6.3 KiB

Auto-provision SSH key & safely disable password login — Design

Date: 2026-06-14 · Branch: feat/v1-foundation · Status: approved

Today, disabling SSH password login is a dead end unless the operator has manually switched the panel's stored credential to key auth and installed an authorized key (HardeningService::apply('ssh_password', false) refuses otherwise, with ssh_password_self_lockout / ssh_password_no_key). This feature does that work automatically and safely from one click: generate a key, install it, prove key login works, switch the panel's own credential to it, and only then turn password auth off — never locking out the operator or the control plane.

Decision (operator-approved)

Full safe auto-flow; the generated private key is shown once and kept encrypted in the vault.

Flow (SshKeyProvisioner::enableKeyOnlyAccess(Server): Result)

Abort on any failure; password auth stays ON until a key login is proven.

  1. Preconditions: server reachable, a credential exists. If the credential is already key and PasswordAuthentication is already no, return a no-op "already key-only" result.
  2. Generate an ed25519 keypair in the panel (phpseclib EC::createKey('ed25519'), same as the Add-SSH-Key modal). Keep the OpenSSH public string and the private PEM.
  3. Install the public key into the SSH user's ~/.ssh/authorized_keys via FleetService::addAuthorizedKey() (idempotent, additive — harmless while password auth is on).
  4. Switch + verify (atomic, rollback-safe): snapshot the current credential (auth_type, secret, passphrase), update it to auth_type='key', secret=<private PEM>, passphrase=null, then run FleetService::testConnection($server->fresh('credential')) (a fresh login + exec probe using the new key). Password auth is still on, so this is risk-free.
    • On failure: restore the snapshot credential, do NOT disable password auth, return an error (ssh_key_verify_failed). The installed public key is left in place (harmless).
  5. Disable password auth: call HardeningService::apply($server, 'ssh_password', enable: false). Its lock-out guard now PASSES (credential is key, an authorized key exists), writes PasswordAuthentication no to the /etc/ssh/sshd_config.d/00-clusev.conf drop-in, and reloads sshd. If this step fails, the operator still has key access (credential already switched) — report the error but do not roll the credential back.
  6. Reveal once: return the private PEM so the UI shows it in a modal (download + "save now" warning). It also stays encrypted in the vault (the panel uses it to connect).
  7. Audit every materialized step: ssh_key.autoprovision (key installed + credential switched) and the existing ssh_password hardening audit from HardeningService.

Result shape

array{ok: bool, privateKey?: string, publicKey?: string, error?: string, alreadyKeyOnly?: bool}

UI integration

The hardening panel's SSH-Passwort-Login row, when open (PasswordAuthentication yes): add a primary action "Key erstellen & Passwort-Login deaktivieren" that opens a confirm modal (modals.ssh-key-provision, wire-elements/modal, R5) explaining the safe sequence. On confirm it runs enableKeyOnlyAccess():

  • success → swap the modal body to the one-time private-key reveal (readonly textarea + a Download action using a streamed-download route, mirroring the recovery-codes download) + a warning; the hardening row reloads to geschlossen (PasswordAuthentication no).
  • alreadyKeyOnly → a short "already key-only" notice.
  • failure → an inline error (ssh_key_verify_failed etc.), nothing changed on the host beyond a harmless installed key. Keep the existing manual toggle for operators who already manage their own keys (the row still shows the standard enable/disable when a key credential is already in place).

Files

  • New app/Services/SshKeyProvisioner.php (orchestration; depends on FleetService + HardeningService).
  • New app/Livewire/Modals/SshKeyProvision.php + resources/views/livewire/modals/ssh-key-provision.blade.php.
  • New streamed-download route servers.ssh-key.download (session-flashed one-time key, like two-factor.recovery.download) — OR pass the key only to the modal and offer a client-side download blob (decided in the plan; prefer the client-side blob to avoid persisting the plaintext key in the session).
  • resources/views/livewire/servers/show.blade.php: the SSH-password row gains the new action.
  • app/Models/Server.php / SshCredential: no schema change (fields already exist).
  • lang/{de,en}/servers.php (+ backend.php if new backend strings): the new action label, modal copy, success/already/failure messages, the private-key warning.

Safety invariants (non-negotiable)

  • Password auth is disabled only after a fresh key login is verified.
  • The panel's own credential is switched to the key before the cutover and rolled back if verification fails — the control plane can never lock itself out.
  • Never touch 10.10.90.x style control-plane self-management beyond this server's own credential.
  • The plaintext private key is shown once and stored only encrypted (vault, APP_KEY) — never logged, never echoed, never committed.

Testing

  • Unit/feature with a mocked FleetService/HardeningService:
    • happy path: keypair generated, addAuthorizedKey called, credential switched to key, testConnection ok → apply('ssh_password', false) called → result carries the private key.
    • verify-fail path: testConnection returns not-ok → credential restored to the original password auth, apply('ssh_password', …) NOT called, error returned.
    • already-key-only: short-circuits with alreadyKeyOnly.
    • the modal: confirm runs the service, success reveals the key, failure shows the error; R5 modal.
  • R12 (real browser): the row's action opens the modal; on a reachable test host the cutover works and the row flips to geschlossen; the key reveal + download work; DE+EN; 200, no console errors.
  • Pint, Codex clean (special attention: no plaintext key in logs/session/argv; the rollback path).

Out of scope

  • Rotating an existing key, multiple keys management (Add-SSH-Key already lists/adds/removes).
  • Re-enabling password auth (the standard hardening toggle already does that).