docs(spec): auto-provision SSH key & safely disable password login

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:54:02 +02:00
parent 103453877b
commit 3629f18844
1 changed files with 91 additions and 0 deletions

View File

@ -0,0 +1,91 @@
# 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).