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.
- Preconditions: server reachable, a credential exists. If the credential is already
keyandPasswordAuthenticationis alreadyno, return a no-op "already key-only" result. - 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. - Install the public key into the SSH user's
~/.ssh/authorized_keysviaFleetService::addAuthorizedKey()(idempotent, additive — harmless while password auth is on). - Switch + verify (atomic, rollback-safe): snapshot the current credential
(
auth_type,secret,passphrase), update it toauth_type='key',secret=<private PEM>,passphrase=null, then runFleetService::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).
- On failure: restore the snapshot credential, do NOT disable password auth, return an error
(
- Disable password auth: call
HardeningService::apply($server, 'ssh_password', enable: false). Its lock-out guard now PASSES (credential iskey, an authorized key exists), writesPasswordAuthentication noto the/etc/ssh/sshd_config.d/00-clusev.confdrop-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. - 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).
- Audit every materialized step:
ssh_key.autoprovision(key installed + credential switched) and the existingssh_passwordhardening audit fromHardeningService.
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_failedetc.), 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 onFleetService+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, liketwo-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.phpif 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.xstyle 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,
addAuthorizedKeycalled, credential switched tokey,testConnectionok →apply('ssh_password', false)called → result carries the private key. - verify-fail path:
testConnectionreturns 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.
- happy path: keypair generated,
- 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).