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
+55
View File
@@ -1,3 +1,58 @@
## v0.84.0 — Break-glass console credential on the host page (2026-07-31)
**The credential existed and was not reachable when it was wanted.** Every Felhom-installed box has
had a strong random `root@pam` console password since TASK G1 — set on the box by
`felhom-host-install.sh` step 4b, vaulted in the hub at day 0, live for three hosts today, and used
for real during the sshd incident. The only way to read it back was a hand-written `curl` against
`/api/v1/admin/hosts/<id>/recovery-credential` carrying the **global operator key** — a different
secret from the hub login password, kept out-of-band. In practice the PVE web console on a demo box
felt locked.
**The host page grows a `Console access` card.** By default it states only that a credential is
vaulted, for which user, and when it was last set. A **Reveal** button fetches the plaintext on
demand and shows it for 60 s with a Copy button; masking clears the JS variable, and the mask also
fires on a second click and on `visibilitychange → hidden`. A host with nothing vaulted says so, and
says why (byo host, or step 4b never ran), with no Reveal control at all.
**The secret is never rendered into the page — that constraint shapes the whole change.** The render
path calls a new `store.GetHostRecoveryMeta`, whose struct and whose `SELECT` both omit the `secret`
column, so it is *structurally* incapable of carrying it; `hostDetailData` gains exactly three keys
(`RecoveryVaulted`, `RecoveryUsername`, `RecoverySetAt`). 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 a secret is never retrievable by
URL alone (prefetch, history, referrer). The load-bearing test asserts the canary appears **nowhere**
in the rendered response — attribute, comment, inline script or JSON blob.
**Deliberately NOT the `customer_unified.html` `data-secret` widget**, which embeds the plaintext in
the page HTML on every load: acceptable for one customer's retrieval passphrase, not for console root
on every box in the fleet (it survives in the bfcache, in "save page as", and in any DOM-capturing
screenshot). That widget is untouched and recorded as an observation.
**Transparency, matching the log-pull precedent.** A delivered reveal writes one
`recovery_credential_revealed` event (info, source `hub`, Hungarian) on the host's customer timeline
`SaveEvent` alone, no dispatcher call, so nobody is emailed. Two reveals write two events: the
register records **accesses**, not states. A 404 is not an access and writes nothing. An unbound host
reveals fine and writes no event (no customer to tell); the `[INFO]` hub line is then the only
record, and it carries the username and a length — never the password.
**The global-key API path is untouched, by design.** `handleAdminGetRecoveryCredential` is the
break-glass route for when the hub *UI* is the thing that is broken; coupling it to the session layer
would remove exactly the independence that makes it a fallback.
**Recorded as a real trade, not a free one:** the hub session password alone now unlocks console root
on every managed box, where retrieval previously also required the global API key. Accepted for a
single-operator, HU-geo-fenced hub that already stores these passwords in plaintext at rest — and the
plaintext-at-rest half is now filed as **R-133** (envelope-encrypt `host_recovery.secret` under a KEK
held outside the DB, so a hub DB backup stops being a fleet-wide console-credential dump).
Files: `hub/internal/store/host_recovery.go` (+`GetHostRecoveryMeta`, `HostRecoveryMeta`),
`hub/internal/web/hosts.go` (+`handleHostRevealRecoveryCredential`, `hostDetailData`),
`hub/internal/web/server.go` (route, **above** the bare `/hosts/` catch-all),
`hub/internal/web/templates/host_detail_body.html` (card + fetch-on-demand script).
Tests 550 → 559; four red-proofs (A page-leak, B audit event, D CSRF gate, E route order) each run,
observed failing, and reverted. The E proof is a seam test driving `ServeHTTP`: a handler-level test
cannot see that defect, because the handler is correct and simply never runs.
## v0.83.0 — R-109 + R-122: the recipe assembly stops dropping sections (2026-07-30)
Pairs with **agent v0.118.0** (R-106 + R-109). The agent half is useless without this one.