hub v0.86.0 — Copy works without revealing, and every copy branch reports itself

Found by the operator, in the way that matters: it cost a real login.

The v0.84.0 Console access card shipped its Copy button DISABLED until a Reveal.
Clicking it did nothing, silently, so the clipboard kept whatever was already in
it — another host's console password from an earlier reveal. That got pasted into
demo-hp's PVE login, which failed with no explanation: the box logged a plain
`password check failed for user (root)`, the credential was never at fault, and
nothing on screen said the copy had not happened.

A copy button that silently no-ops is worse than no copy button. The operator
cannot tell "copied" from "did nothing", and the stale value left behind is a
VALID secret for a DIFFERENT machine — so the failure looks like a stale
credential and sends you diagnosing the wrong thing.

Copy now works without revealing, and that is the safer default rather than a
concession: the secret goes straight to the clipboard and never renders on
screen, so it cannot be shoulder-surfed or caught in a screenshot. Reveal remains
for when it must be read.

Three silent-failure branches closed, all in the same eight-line function:
  - not yet revealed        -> was a disabled no-op; now fetches and copies
  - navigator.clipboard absent -> was silently skipped; now shows it and says why
  - writeText() REJECTED    -> promise was ignored, so the operator believed it
                               copied; now shows it and reports the refusal

The success path names the host ("Copied demo-hp-bb76ea's root@pam password"),
because the clipboard is fleet-wide and every box has a different console
password — "copied" alone cannot say for WHICH box, which is the confusion that
produced the incident.

One retrieval path, shared: the endpoint is defined once (data-reveal-url) and
read back with getAttribute, so Copy cannot drift onto a different, unaudited URL
than Reveal. Server-side is unchanged — both buttons hit the same CSRF-gated
endpoint and both write the same recovery_credential_revealed event, which is
correct: the register records accesses, and a copy is an access.

Tests 566 -> 568. Red-proof: re-adding `disabled` reproduces the shipped bug.
This commit is contained in:
2026-07-31 09:22:11 +02:00
parent 9e079c7883
commit 670ec35ece
3 changed files with 181 additions and 23 deletions
+41
View File
@@ -1,3 +1,44 @@
## v0.86.0 — Copy works without revealing, and every copy branch reports itself (2026-07-31)
**Found by the operator, in the way that matters: it cost a real login.** The v0.84.0 Console access
card shipped its **Copy button `disabled` until a Reveal**. Clicking it did nothing, silently — so the
clipboard kept whatever was already in it, which was **another host's console password** from an
earlier reveal. That got pasted into demo-hp's PVE login, which failed with no explanation. The box
logged a plain `password check failed for user (root)`; the credential was never at fault, and there
was nothing on screen to say the copy had not happened.
**A copy button that silently no-ops is worse than no copy button**, because the operator has no way
to distinguish "copied" from "did nothing" — and the stale value it leaves behind is a *valid secret
for a different machine*, so the resulting failure looks like a stale-credential problem and sends you
diagnosing the wrong thing.
**Copy now works without revealing — and that is the safer default, not a concession.** The secret
goes straight to the clipboard and never renders on screen, so it cannot be shoulder-surfed or caught
in a screenshot. Reveal is still there for when you need to read it (typing at a console).
**Three silent-failure branches closed, all in the same eight-line function:**
| Branch | Was | Now |
|---|---|---|
| Not yet revealed | button `disabled`, click = no-op | fetches and copies |
| `navigator.clipboard` absent (insecure context) | `if (navigator.clipboard)` → silently skipped | shows the password instead and says why |
| `writeText()` promise REJECTED (permission / no user gesture) | promise ignored — the operator believes it copied | shows the password instead and reports the refusal |
**The success path now names the host:** *"✓ Copied demo-hp-bb76ea's root@pam password to the
clipboard."* The clipboard is fleet-wide and every box has a different console password, so "copied"
alone cannot say copied for *which* box — precisely the confusion that produced the incident.
**One retrieval path, shared.** `fetchConsolePassword` is used by both buttons, and the endpoint is
defined once (the `data-reveal-url` attribute) and read back with `getAttribute`, so Copy cannot drift
onto a different — unaudited — URL than Reveal. A test asserts the URL appears exactly once.
Server-side nothing changed: both buttons hit the same CSRF-gated endpoint and both write the same
`recovery_credential_revealed` event, which is correct — the register records **accesses**, and a copy
is an access.
Tests 566 → 568, both pinning this regression: the Copy button must not ship `disabled`, and every
outcome branch must carry a message. Red-proof: re-adding `disabled` reproduces the shipped bug and
turns the first test red.
## v0.85.0 — Network card: a host's addresses are visible at last (2026-07-31)
**Pairs with agent v0.119.0 and is useless without it** — the agent is what reports the addresses.