592818492c
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
81 lines
5.4 KiB
Markdown
81 lines
5.4 KiB
Markdown
# felhom.eu — task reports
|
||
|
||
> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md).
|
||
|
||
## Hub v0.66.0 — Customer self-bind (R-27 slice 1) — 2026-07-17
|
||
|
||
A customer can now bind their **own** freshly-installed appliance without the operator. The operator
|
||
sends a **7-day tokenized capability link** by email; the customer opens a **public, Hungarian**
|
||
`/bind/<token>` page (no login — the URL token IS the capability), enters the **console pairing code**
|
||
shown on the box screen plus their **retrieval passphrase**, and the hub stages the bind through the
|
||
same `BindAppliance` the operator uses. The box's ~30 s appliance poll then delivers credentials and
|
||
day-0 install proceeds. The controller and agent are **untouched**.
|
||
|
||
Viktor's three rulings, honoured verbatim:
|
||
1. **"Only their own visible"** → possession is proven by the console pairing code; **no appliance
|
||
list is ever rendered** on any public surface.
|
||
2. **First-box entry** → an operator-sent tokenized capability link over Hungarian email (claim-engine
|
||
delivery pattern, a **sibling** sender in `web/`+`notify/` — deliberately NOT routed through the
|
||
claim engine).
|
||
3. **Lockout after 5 failed attempts** → the token locks; the page says *call support*.
|
||
|
||
### Components
|
||
|
||
| Part | Area | What |
|
||
|---|---|---|
|
||
| 1 | store + api + bootstrap | `pairing_code` on `appliance_registrations`; additive `pairing_code` in the register response; stable across idempotent re-register; console banner (`felhom-bootstrap.sh`, ISO **v1.20.0**) |
|
||
| 2 | store + notify + web | `selfbind_tokens` table (sha256-at-rest, single-active/customer, 5-attempt lock, one-shot); operator **"Send self-bind link"** button; Hungarian email (sibling sender); F1/F2 honesty |
|
||
| 3 | web | public `/bind/<token>` page; **THE TRAP** auth+CSRF exemption via one `isPublicBindPath` predicate; two-factor unconditional compare; provenance event `customer_selfbind`; own rate limiter |
|
||
| 4 | tests | Scenarios A–F + F1/F2 (9 tests) + **4 red-proofs** |
|
||
| 5 | docs + deploy | this REPORT, CHANGELOGs, CONTEXT, capability-map, ROADMAP; build 0.66.0, manifest bump, sync; live validation |
|
||
|
||
### Security properties (spec §9)
|
||
|
||
- **THE TRAP (§9.2):** exactly one new public prefix `/bind/`, exempted from operator auth and CSRF at
|
||
the two gate sites `/login` occupies, through a **single** `isPublicBindPath` definition. Matched
|
||
tightly — trailing slash (no `/bindsecret` sibling), the `http.ServeMux` `..`-cleans before the
|
||
handler sees the path, and the handler rejects any token containing `/`. Scenario E asserts a public
|
||
link renders logged-out while `/`, `/hosts`, `/customers/…`, `/configuration`, `/offsite` still
|
||
redirect to `/login`; Scenario D is its companion.
|
||
- **No oracle:** an unknown token folds into "expired" (no *was-this-real* signal); wrong code and
|
||
wrong passphrase yield one byte-identical generic failure (both factors compared unconditionally
|
||
before deciding); the page renders no appliance data in any state.
|
||
- **Custody:** `sha256(token)` at rest, prefix-only in logs, raw token never in logs/events; the
|
||
passphrase is never logged/echoed/persisted; only attempt COUNTS are logged (`attempt N/5 … <8hex>…`).
|
||
- **No customer-login system** was built; the capability link is the whole auth model. A cross-site
|
||
POST without both secrets only burns attempts (accepted + documented).
|
||
|
||
### GC verdict (spec §3)
|
||
|
||
There is **no appliance-staleness garbage collection** in the hub — `applianceStaleAfter` (7 d) is a
|
||
DISPLAY badge only; `pruneAll` and `PurgeExpiredLogBundles` touch host reports and log bundles, not
|
||
appliances or self-bind tokens. The **7-day token TTL stands alone** and needs no reaper: single-active
|
||
per customer means at most one row per customer, a re-mint deletes the prior row, and an expired row
|
||
simply reads as expired (no security or storage pressure).
|
||
|
||
### Verification
|
||
|
||
- **Green gate:** `go build ./... && go vet ./... && go test ./...` — all hub packages pass (17 ok),
|
||
including 9 new self-bind tests. `bash -n` clean on both ISO scripts. Hub confirm gate OK.
|
||
- **Red-proofs (all confirmed red-then-green):**
|
||
- lockout removed (`locked = false`) → **C1** red.
|
||
- oracle introduced (distinct state on wrong code) → **B** red.
|
||
- `/bind/` prefix widened (drop the slash) → **E** red (`/bindsecret` leaks past auth).
|
||
- single-active DELETE dropped → **C4** red (the prior link still resolves).
|
||
- **D2 regression evidence:** with an operator password configured, `/`, `/hosts`, `/customers/acme`,
|
||
`/configuration`, `/offsite` all `302 → /login` through `RequireAuth`, while `/bind/<token>` renders
|
||
`200` — proving the exemption is tight (`TestSelfBind_D_AuthGateIntact`, `TestSelfBind_E_TheTrap`).
|
||
|
||
### Live validation
|
||
|
||
Filled in after deploy — see the CONTEXT.md live-state note.
|
||
|
||
### Not in this slice (deferred)
|
||
|
||
- **R-27b** — the controller's second-box flow (a dismissable "bind another box" prompt + bind-later
|
||
under settings). Mechanism sketched; not built. The controller/agent were not touched.
|
||
- **Multi-box per link** — one link binds one box (single-active, one-shot). Binding several boxes for
|
||
one customer = repeated operator sends. Noted on the ROADMAP.
|
||
- Attaching the appliance's stored SSH host key to the host record on bind (no clean hand-off surface
|
||
today — same open item as slice C).
|