From 3629f1884410cc83c0d96a9e87924a9dc41ae530 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 21:54:02 +0200 Subject: [PATCH] docs(spec): auto-provision SSH key & safely disable password login Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-14-ssh-key-auto-provision-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-ssh-key-auto-provision-design.md diff --git a/docs/superpowers/specs/2026-06-14-ssh-key-auto-provision-design.md b/docs/superpowers/specs/2026-06-14-ssh-key-auto-provision-design.md new file mode 100644 index 0000000..f36eaff --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-ssh-key-auto-provision-design.md @@ -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=`, + `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).