Files
felhom.eu/REPORT.md
T
admin 1956e5d390 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.
2026-07-31 08:19:36 +02:00

7.8 KiB

REPORT — Hub v0.84.0: break-glass console credential on the host page (2026-07-31)

1. Confirmed baselines actually used

Repo main @ start Version before Version after
felhom.eu 0a9bd3829d9a4465b032ea78813f55e935fbf88e ("D5 SHIPPED…", 2026-07-30) hub v0.83.0 (manifests/hub.yaml:128felhom-hub:0.83.0) hub v0.84.0

Clean-tree gate at start: git status --porcelain empty, HEAD == origin/main. Note: a second Claude Code session was working in this shared clone concurrently — see §10.

2. Files created / modified

Created:

  • hub/internal/web/hosts_recovery_reveal_test.go

Modified:

  • hub/internal/store/host_recovery.goHostRecoveryMeta + GetHostRecoveryMeta
  • hub/internal/store/host_recovery_test.go — Group H
  • hub/internal/web/hosts.gohandleHostRevealRecoveryCredential; hostDetailData +3 keys
  • hub/internal/web/server.go — route, above the bare /hosts/ catch-all
  • hub/internal/web/templates/host_detail_body.html — Console access card + fetch-on-demand script
  • hub/CHANGELOG.md, REUSE.md, CONTEXT.md (ruling S-4), documentation/runbooks/break-glass.md (§3.1 + §5), documentation/architecture/00-capability-map.md (L120), documentation/backlog/ROADMAP.md + documentation/backlog/OPEN-ITEMS.md (R-133)

3. Commits pushed to main

(filled in at push — see git log --oneline)

4. Test results + red-proofs

Every test drives RequireAuth(ServeHTTP) — never a handler function directly.

Test Scenario Result
TestReveal_A_PageNeverCarriesTheSecret A — render, canary absent from the WHOLE body PASS
TestReveal_B_RevealDeliversAndAudits B — 200 + no-store + payload + exactly 1 event + no log leak + 0 notifications PASS
TestReveal_C_NotVaulted C — explanatory card, no control, 404, zero events PASS
TestReveal_D_CSRFRequired D — 403, no leak, zero events; + the with-token discriminator PASS
TestReveal_E_MethodGateAndRouteOrder E — 405 and no fall-through to the host page PASS
TestReveal_F_UnknownHost F — 404, no panic PASS
TestReveal_G_Unauthenticated G — 401, no leak, zero events PASS
TestReveal_UnboundHostRevealsWithoutAnEvent §8 edge — 200, no event, log is the record PASS
TestGetHostRecoveryMeta_MetadataOnly H — store round-trip + absent cases PASS

Red-proofs — each mutation applied, observed failing, reverted:

# Mutation Observed Reverted
A hostDetailData gains RecoverySecret (from GetHostRecoveryCredential) + data-secret="{{.RecoverySecret}}" on the card FAIL … SECRET LEAK: the vaulted console password appears in the rendered host page yes
B delete the SaveEvent block in handleHostRevealRecoveryCredential FAIL … recovery_credential_revealed rows = 0, want exactly 1 yes
D short-circuit the ServeHTTP CSRF check (if false && !s.validateCSRF(r)) FAIL … reveal without CSRF = 200, want 403 yes
E move the new case BELOW case strings.HasPrefix(path, "/hosts/") FAIL … GET on the reveal route = 404, want 405 yes

Red-proof A did not land on the first attempt, and that is worth recording. The first mutation edited only the template; a {{.RecoverySecret}} against a map with no such key renders empty, so the test passed and would have certified nothing. The proof landed only once the data half was mutated too — i.e. the assertion is pinned to the view-model, not to template text.

Red-proof E's symptom differed from the prediction. The spec expected the misordered route to render the host page (200); it actually 404s, because the catch-all takes demo-felhom-8363b5/reveal-recovery-credential as the host id and GetHost misses. The test goes red either way, and its second assertion (no Console access in the body) still pins the fall-through case.

One spec assertion caught a real gap during development. Scenario A's requirement that the page "contains a Reveal control targeting /hosts/{id}/reveal-recovery-credential" failed at first: the URL was assembled in JS ('/hosts/' + encodeURIComponent(hostID) + '/…') and appeared nowhere in the DOM. Fixed by putting the endpoint in data-reveal-url on the button and making the fetch read it from there, so the string the render test asserts is the string the request uses — an attribute nothing reads would have been a hollow assertion.

5. Test count

550 → 559 (8 web + 1 store). Full suite go build ./... && go vet ./... && go test ./... in hub/: rc=0, all 17 packages ok. Run as a separate command from the commit, per standing rule 1.

6. Deployed version

(filled in after the build/manifest/sync steps)

7. NOT yet live-validated — awaiting the operator

That the revealed password actually authenticates at https://<box-ip>:8006 as root@pam on demo-felhom-8363b5. This is the only test that proves the hub's copy still matches the box, and it needs a browser and a real login — CC has neither here (no claude-in-chrome on DooPlex). Every other leg is endpoint-level validated (§6).

8. Teardown

This run provisioned nothing — no guest, no VM, no customer, no drive, no external resource. No teardown obligation.

9. Backlog rows opened / closed / re-ranked

  • Opened: R-133 — the vaulted secret is plaintext at rest, so every hub DB backup is a fleet-wide console-credential dump; envelope-encrypt host_recovery.secret under a KEK held outside the DB. Added to ROADMAP.md and OPEN-ITEMS.md (owner CC, READY (M)). Named the capability-map row it would flip.
  • Closed / re-ranked: none.
  • ID collision, resolved: the spec predicted R-128. The concurrent session's uncommitted WIP in OPEN-ITEMS.md had already taken R-128 through R-132, so this item took R-133. An ID register that lives in a file two sessions edit at once cannot allocate safely by reading committed state — worth noting, not fixed here.

10. Observations — recorded, deliberately NOT acted on

  1. customer_unified.html's data-secret / toggleSecret widget embeds the plaintext in the page HTML on every load. It therefore lives in the back/forward cache, in "save page as", and in any DOM-capturing screenshot. Acceptable for one customer's retrieval passphrase; it is the reason this task built fetch-on-demand instead of reusing it. Not refactored — out of scope.
  2. validateCSRF returns true when no session cookie is present (server.go, the Basic-Auth path). So a Basic-Auth caller reaches the reveal endpoint without any CSRF token. That is the pre-existing hub-wide contract, not something this endpoint introduced, and it is what makes the §6 curl validation possible at all — but it does mean "CSRF-gated" is true only for session callers. Recorded, not changed.
  3. The Console access card also renders on the customer page's Host tab, because host_detail_body is shared and configs.go builds its view models through the same hostDetailData. That is per-host and operator-only (the hub has no customer login), so it does not breach the "never on the hosts LIST" rule — but it is a second surface, and it is stated here rather than left to be discovered.
  4. A concurrent session shares this clone. Its in-flight edits to OPEN-ITEMS.md, RUNBOOK-manual-build.md, RUNBOOK-publish-0.79-0.110-2026-07-10.md and two new untracked files were not swept into this commit: every path was staged explicitly, and OPEN-ITEMS.md was staged as a hand-built blob (HEAD + the R-133 row only) so the foreign WIP stayed unstaged in the working tree. REPORT.md was taken here because that session had already chosen the REPORT-tester-gate-2026-07-31.md sibling.