hub v0.84.0 — break-glass console credential on the host page

The credential existed and was not reachable when it was wanted. Every box has
had a strong random root@pam password since TASK G1, vaulted in the hub at day 0
and used for real during the sshd incident — but the only way to read it back was
a hand-written curl carrying the global operator key, a secret kept out-of-band.
In practice the PVE web console on a demo box felt locked.

The host page grows a Console access card: presence + username + set_at by
default, Reveal fetches the plaintext on demand for 60 s with a Copy button.
Masking clears the JS variable, and also fires on a second click and on
visibilitychange. A host with nothing vaulted says so, and says why.

The secret is NEVER rendered into the page, and that constraint shapes the
change. The render path uses a new store.GetHostRecoveryMeta whose struct and
SELECT both omit the secret column, so it is structurally incapable of carrying
one. The plaintext crosses the wire only in the response to POST
/hosts/{id}/reveal-recovery-credential (Cache-Control: no-store, CSRF-gated at
the ServeHTTP level; POST precisely so that gate applies and so no secret is
retrievable by URL alone). Deliberately NOT the customer page's data-secret
widget, which embeds the plaintext on every load.

A delivered reveal writes one recovery_credential_revealed event on the host's
customer timeline (info, source hub, Hungarian) via SaveEvent alone — no
dispatcher, nobody emailed, the log_tail_requested shape. Two reveals write two
events: the register records accesses, not states. A 404 is not an access. An
unbound host reveals fine and writes no event; the [INFO] hub line, carrying the
username and a length only, is then the record.

The global-key API path is untouched by design — it is the route for when the
hub UI itself is broken, and coupling it to the session layer would delete the
independence that makes it a fallback.

Recorded as a real trade: the hub session password alone now unlocks console root
fleet-wide, where retrieval previously also needed the global key. Accepted for a
single-operator, HU-geo-fenced hub that already stores these passwords in
plaintext at rest (CONTEXT.md ruling S-4). The plaintext-at-rest half is filed as
R-133 — every hub DB backup is a fleet-wide console-credential dump.

Tests 550 -> 559; four red-proofs (page leak, audit event, CSRF gate, route
order) each run, observed failing, and reverted. The route-order proof is a seam
test driving ServeHTTP: a handler-level test cannot see that defect, because the
handler is correct and simply never runs.
This commit is contained in:
2026-07-31 08:19:36 +02:00
parent 0a9bd3829d
commit 1956e5d390
14 changed files with 801 additions and 157 deletions
+15
View File
@@ -59,6 +59,21 @@ cited as authoritative). They are **decisions, not observations**; the rewrite l
compromised hub yields blobs nobody can open, *provided the operator's key is never stored in the
hub*. That proviso is why escrow custody is an open decision (`07` §11-A).
**S-4 — the hub session password alone now unlocks console root on every managed box (2026-07-31, hub v0.84.0).**
Retrieving a host's vaulted break-glass `root@pam` credential previously required the **global operator
API key**, a secret distinct from the hub login and kept out-of-band. The `Console access` card on the
host page puts that retrieval behind the **hub session** (`POST /hosts/{id}/reveal-recovery-credential`,
CSRF-gated, audited), so the hub login password is now sufficient. **Accepted deliberately**, and the
reasoning is the ruling: the hub is single-operator and geo-fenced to Hungary, and it already stores
these passwords **in plaintext at rest** — so the DB and the login were already jointly sufficient, and
this is a convenience trade rather than a new exposure. It is a real trade, not a free one, and it is
recorded here rather than only in a changelog. Two things follow. **(a)** The global-key path
(`api/handler.go handleAdminGetRecoveryCredential`) stays untouched and must acquire **no** dependency
on the session layer — it is the route for when the hub UI itself is what is broken, and coupling the
two would delete the independence that makes it a fallback. **(b)** The plaintext-at-rest half is now
the named open item **R-133**: every hub DB backup is a fleet-wide console-credential dump, and the fix
shape is envelope encryption under a KEK held outside the DB, the way escrow blobs are already opaque.
Five decisions were deliberately **left open for the operator** and are recorded, unanswered, in
`07` §11: escrow custody · lost-R policy · per-scenario RTO/RPO targets (**none have ever been
stated**) · Hetzner as a single failure domain · and `local` vzdump sharing a physical device with