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
parent
103453877b
commit
3629f18844
|
|
@ -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).
|
||||
Loading…
Reference in New Issue