hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1)

Let a customer bind their own freshly-installed appliance without the
operator: operator "Send self-bind link" mints a 7-day tokenized
capability link, emailed (Hungarian, sibling sender) to the customer, who
opens a public /bind/<token> page and proves two factors — the console
pairing code shown on the box screen + their retrieval passphrase — and
the hub stages the bind via the same BindAppliance (provenance
customer_selfbind). The box's ~30s appliance poll delivers.

Viktor's three rulings verbatim: console pairing code (no appliance list
ever rendered), operator-sent tokenized link, 5-attempt lockout ->
"call support". Wrong code == wrong passphrase (one generic failure, no
oracle, both factors compared unconditionally); expiry falls back to
operator-bind unchanged.

THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login
gate sites via a single isPublicBindPath predicate (tight trailing-slash
match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests
(Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout,
oracle, widened-prefix, single-active). GC verdict: no appliance GC ->
the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred.

Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
This commit is contained in:
2026-07-17 23:56:53 +02:00
parent c6d7a69e6d
commit 592818492c
24 changed files with 1304 additions and 138 deletions
+53
View File
@@ -1,5 +1,58 @@
# Felhom Hub — Changelog
## v0.66.0 — Customer self-bind (R-27 slice 1): tokenized capability link + public two-factor `/bind/` page (2026-07-17)
Lets a customer bind their OWN freshly-installed appliance without the operator. Until now every
box booted from the universal secret-free ISO (R-21 slice C) had to be bound by the operator on the
Hosts page; this adds the self-service path. **Viktor's three rulings, each honoured verbatim:**
(a) *"only their own visible"* → the customer proves possession with the **console pairing code**
shown on the box screen — **no appliance list is ever rendered** on any public surface; (b) *first-box
entry* → an **operator-sent 7-day tokenized capability link** over Hungarian email (the claim-engine
delivery pattern, a sibling sender — NOT routed through the claim engine); (c) **lockout after 5
failed attempts** → the token locks and the page says *"call support"*. Wrong code and wrong
passphrase produce **one identical generic failure** (no oracle); an expired link falls back to
operator-bind, unchanged. Does **not** touch the controller or agent. Green: `go build/vet/test`
(full hub suite + 9 new self-bind tests), all 4 red-proofs verified red-then-green, hub confirm gate.
- **Pairing code (Part 1).** `POST /api/v1/appliance/register` now returns an additive `pairing_code`
(6 chars, ambiguity-free alphabet, `ABC-234` display) minted once at first registration and stable
across the idempotent re-register/upsert (backfilled if a pre-existing row had none). Persisted on
`appliance_registrations.pairing_code`; shown in the operator Hosts "Unclaimed appliances" table.
The bootstrap (`felhom-bootstrap.sh`, ISO v1.20.0) parses it and prints a Hungarian **console
banner** to `/dev/console` so the customer can read it off the screen. Old ISOs ignore the field;
an old hub omits it and the banner prints nothing — additive both ways.
- **Capability token (Part 2).** New `selfbind_tokens` table: `sha256(token)` at rest (never the
token), **single-active per customer** (a re-mint deletes the prior row in one tx — an old link dies
the instant a new one is sent), `attempts` locking at 5, one-shot `consumed_at`, `emailed_at`
honesty. Operator **"Send self-bind link"** button on the customer Setup tab
(`POST /customers/{id}/selfbind-link`) mints a 256-bit token and emails
`https://hub.felhom.eu/bind/<token>` (Hungarian, adult tone, no emoji, names both factors + the
7-day + 5-attempt limits). **F1** (no registered email → nothing minted, LOUD flash) and **F2**
(email send fails → the just-minted token is deleted, not left silently live) are both honest.
- **Public bind page (Part 3) — THE TRAP (§9.2).** One new public prefix `/bind/`, exempted from
operator auth AND CSRF at the two gate sites the `/login` exemption occupies, via a **single**
predicate `isPublicBindPath` (matched tightly: trailing slash → no sibling like `/bindsecret`; the
ServeMux `..`-cleans before we see the path → no traversal reach; the handler also rejects a token
containing `/`). `GET` renders form/consumed/locked/expired; `POST` normalizes both inputs, compares
**both factors unconditionally** (constant-time passphrase vs the customer's retrieval passphrase;
the ONE bindable appliance carrying the console code), then decides — identical generic failure
either way. On success: the **same `BindAppliance`** the operator uses, a provenance event with
source `customer_selfbind`, one-shot consume, and *"A doboz kb. egy percen belül folytatja a
telepítést."* The box's ~30 s appliance poll picks up the delivery. Own per-IP rate limiter; the
page is self-contained (it cannot link `/style.css`, which is itself operator-gated). No hub
customer-login/session was built — the URL capability token IS the auth model; a cross-site POST
without both secrets only burns attempts (accepted + documented).
- **Tests + red-proofs (Part 4).** Scenarios AF + F1/F2 (9 tests). The **4 red-proofs** were each
applied and confirmed to turn exactly their scenario red, then reverted green: lockout removed → C1;
oracle introduced → B; `/bind/` prefix widened (drop the slash) → E (and D); single-active DELETE
dropped → C4. The passphrase is never logged/echoed/persisted; only attempt COUNTS are logged
(`self-bind attempt N/5 … token <8hex>…`); the raw link token never enters logs or events.
- **GC verdict (spec §3):** there is **no appliance-staleness GC** in the hub (`applianceStaleAfter`
is a DISPLAY badge only; `pruneAll`/`PurgeExpiredLogBundles` touch reports/log-bundles, not
appliances or selfbind tokens). The 7-day token TTL therefore stands alone and needs no reaper —
single-active-per-customer means at most one row per customer, superseded rows are deleted on
re-mint, and an expired row simply reads as expired (no security or storage pressure).
## v0.65.0 — PBS DR storage visibility (ep0 `usage` op) + Offsite tab split (Restic / PBS DR) + dual dashboard gauges (R-5) (2026-07-17)
Makes the **PBS DR** storage visible like the restic pool box already is (v0.64.0), the two clearly