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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user