Live through the real ingress: public /bind/ renders logged-out with the no-oracle expired state (200-not-500 proves selfbind_tokens migrated); gate intact (/ and /hosts -> /login); CSRF exemption is /bind/-only (POST /bind/ no-CSRF 200 vs POST /customers/x/block no-CSRF 302). Operator-minted full walk + new-ISO console banner remain operator/R-1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
6.7 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 (hub 0.66.0 live on k3s, through the real hub.felhom.eu ingress, 2026-07-18)
- Public page renders logged-out, no oracle:
GET /bind/<garbage>→200rendering the Hungarian "Doboz összekötése … érvénytelen vagy lejárt" page (an unknown token folds into the generic expired state — no was-this-real signal). The200(not500) also proves theselfbind_tokenstable migrated live —SelfBindTokenByHashqueried it successfully (a missing table would 500). - Auth gate intact (THE TRAP is tight live):
GET /andGET /hosts→302 → /login. - CSRF exemption is
/bind/-only, live:POST /bind/<garbage>with no CSRF/session →200(exempt at gate site 1), renders the generic failure; the controlPOST /customers/acme/blockwith no CSRF →302 → /login(auth fires first — the exemption did not widen). - Clean startup: migrate() ran without error;
Listening on :8080, all checkers seeded.
Pending (NOT-live, as anticipated): the operator-minted full customer walk (the "Send self-bind link" button and the customer's real two-factor success are behind the operator password gate → CC cannot log in) and the new-ISO console pairing-code banner on a physically re-imaged box — both fold into the supervised rehearsal (R-1).
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).