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
5.4 KiB
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; the scripts history lives in 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:
- "Only their own visible" → possession is proven by the console pairing code; no appliance list is ever rendered on any public surface.
- 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). - 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/loginoccupies, through a singleisPublicBindPathdefinition. Matched tightly — trailing slash (no/bindsecretsibling), thehttp.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,/offsitestill 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 -nclean 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 (/bindsecretleaks past auth).- single-active DELETE dropped → C4 red (the prior link still resolves).
- lockout removed (
- D2 regression evidence: with an operator password configured,
/,/hosts,/customers/acme,/configuration,/offsiteall302 → /loginthroughRequireAuth, while/bind/<token>renders200— 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).