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
+325
View File
@@ -0,0 +1,325 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// selfbind_test.go — customer self-bind (v0.66.0, R-27 slice 1), Scenarios AF.
//
// Each red-proof below names the ONE line to break to turn a scenario red — the guard that the test
// actually pins. If a red-proof does NOT turn its scenario red, the test is hollow.
const (
testPass = "alpha beta gamma delta epsilon" // the customer retrieval passphrase (5 words)
testCode = "ABC234" // an appliance console pairing code (raw stored form)
testCodeFmt = "abc-234" // as a human might type it (lowercased, separated)
)
// selfBindSetup seeds a customer (with passphrase + email) and one registered appliance carrying the
// given console pairing code, and returns the appliance id. Distinct customers must use distinct
// codes (a code shared by two registered appliances is ambiguous → binds nothing).
func selfBindSetup(t *testing.T, st *store.Store, customerID, code string) int64 {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: customerID, CustomerName: customerID, APIKey: "k",
RetrievalPassword: testPass, Email: customerID + "@example.test",
}); err != nil {
t.Fatal(err)
}
sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host"
if _, _, err := st.RegisterAppliance("uuid-"+customerID, "bc:24:11:98:10:0e", sshKey, `{"product":"N100"}`, "aphash-"+customerID, code); err != nil {
t.Fatal(err)
}
list, _ := st.ListUnclaimedAppliances()
for _, a := range list {
if a.UUID == "uuid-"+customerID {
return a.ID
}
}
t.Fatal("seeded appliance not found")
return 0
}
// mintLink mints a self-bind token for the customer and returns the plaintext token (URL segment).
// Each call uses a fresh nonce so distinct calls yield distinct tokens (single-active still applies —
// the store deletes the prior row for the customer on each mint).
var mintNonce int
func mintLink(t *testing.T, st *store.Store, customerID string, ttl time.Duration) string {
t.Helper()
mintNonce++
token := "tok-" + customerID + "-" + string(rune('a'+mintNonce%26)) + strconv.Itoa(mintNonce)
if err := st.MintSelfBindToken(customerID, selfBindHash(token), ttl); err != nil {
t.Fatal(err)
}
return token
}
func bindGET(t *testing.T, s *Server, token string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
s.handleBind(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
return rr
}
func bindPOST(t *testing.T, s *Server, token, code, pass string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"pairing_code": {code}, "passphrase": {pass}}
req := httptest.NewRequest("POST", "/bind/"+token, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleBind(rr, req)
return rr
}
// --- Scenario A: happy path (GET renders form; POST with both correct factors stages the bind) ---
func TestSelfBind_A_HappyPath(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "Párosító kód") || !strings.Contains(body, "action=\"/bind/"+token+"\"") {
t.Fatalf("GET did not render the entry form")
}
// A human types the code lowercased + separated and the passphrase with odd spacing — normalization
// must accept both.
rr := bindPOST(t, s, token, testCodeFmt, " Alpha Beta gamma-delta epsilon ")
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") {
t.Fatalf("POST success page not rendered: code=%d body=%q", rr.Code, rr.Body.String())
}
// The appliance is bound to the customer (same effect as an operator bind).
if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound || a.CustomerID != "acme" {
t.Fatalf("appliance not bound: %+v", a)
}
// Provenance event is customer self-bind, not operator/hub.
ev, _ := st.GetLatestEventByType("acme", "appliance_bound")
if ev == nil || ev.Source != "customer_selfbind" {
t.Fatalf("expected customer_selfbind provenance event, got %+v", ev)
}
// One-shot: the token is now consumed; a second open shows the consumed state.
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "már fel lett használva") {
t.Fatalf("token not consumed after success")
}
// Red-proof: drop the ConsumeSelfBindToken call (or make BindAppliance the only effect) → the
// token stays live and this consumed-state assertion goes red.
}
// --- Scenario B: NO ORACLE — wrong code and wrong passphrase yield the SAME generic failure ---
func TestSelfBind_B_NoOracle(t *testing.T) {
s, st := newTestServer(t)
// Two customers with DISTINCT pairing codes so the single-active mint does not cross-invalidate,
// and each has one fresh (attempt-count 1) token — the only difference between the two failure
// pages is then the token in the form action, which we normalize out.
selfBindSetup(t, st, "acme", testCode)
selfBindSetup(t, st, "acme2", "XYZ789")
t1 := mintLink(t, st, "acme", selfBindTTL) // wrong-code attempt (right passphrase)
t2 := mintLink(t, st, "acme2", selfBindTTL) // wrong-passphrase attempt (right code)
wrongCode := strings.ReplaceAll(bindPOST(t, s, t1, "ZZZ999", testPass).Body.String(), t1, "TOKEN")
wrongPass := strings.ReplaceAll(bindPOST(t, s, t2, "xyz-789", "wrong words here now").Body.String(), t2, "TOKEN")
if wrongCode != wrongPass {
t.Fatalf("failure pages differ between wrong-code and wrong-passphrase — that is an oracle")
}
// The generic failure must not leak any appliance data.
if !strings.Contains(wrongCode, "nem megfelelőek") {
t.Fatalf("failure page missing the generic banner: %q", wrongCode)
}
if strings.Contains(wrongCode, "uuid-") || strings.Contains(wrongCode, "N100") {
t.Fatalf("failure page leaked appliance data")
}
// Red-proof: give wrong-code and wrong-passphrase distinct messages/states (an oracle) → the
// wrongCode == wrongPass assertion goes red.
}
// --- Scenario C1: LOCKOUT after 5 failed attempts; a subsequent CORRECT attempt cannot bind ---
func TestSelfBind_C1_Lockout(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
for i := 1; i <= store.SelfBindMaxAttempts; i++ {
rr := bindPOST(t, s, token, "ZZZ999", "definitely wrong words indeed")
if i < store.SelfBindMaxAttempts && !strings.Contains(rr.Body.String(), "nem megfelelőek") {
t.Fatalf("attempt %d should re-render the form with a failure, got %q", i, rr.Body.String())
}
}
// The 5th failure locked it: even the CORRECT secrets now bind nothing.
rr := bindPOST(t, s, token, testCodeFmt, testPass)
if !strings.Contains(rr.Body.String(), "zárolva") {
t.Fatalf("token not locked after %d failures: %q", store.SelfBindMaxAttempts, rr.Body.String())
}
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Fatalf("a locked link still bound the appliance: %+v", a)
}
// Red-proof: remove the `locked = attempts >= SelfBindMaxAttempts` lock (never lock) → the correct
// post-lockout POST binds and both the "zárolva" and still-registered assertions go red.
}
// --- Scenario C4: SINGLE-ACTIVE per customer — re-minting kills the prior link ---
func TestSelfBind_C4_SingleActive(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
first := mintLink(t, st, "acme", selfBindTTL)
second := mintLink(t, st, "acme", selfBindTTL) // re-mint for the SAME customer
if body := bindGET(t, s, first).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
t.Fatalf("the first link still resolves after a re-mint (not single-active): %q", body)
}
if body := bindGET(t, s, second).Body.String(); !strings.Contains(body, "Párosító kód") {
t.Fatalf("the freshly-minted link does not render the form: %q", body)
}
// Red-proof: drop the `DELETE FROM selfbind_tokens WHERE customer_id` in MintSelfBindToken → the
// first link still resolves and the érvénytelen assertion goes red.
}
// --- Scenario D: the operator auth gate is intact — self-bind's exemption did NOT open other routes ---
func TestSelfBind_D_AuthGateIntact(t *testing.T) {
s, st := newAuthServer(t)
selfBindSetup(t, st, "acme", testCode)
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
for _, path := range []string{"/", "/hosts", "/customers/acme", "/configuration", "/offsite"} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/login" {
t.Fatalf("gated route %s not redirected to /login: code=%d loc=%q", path, rr.Code, rr.Header().Get("Location"))
}
}
// Red-proof: this is the companion to Scenario E — widening isPublicBindPath turns THIS red too.
}
// --- Scenario E: THE TRAP — /bind/ is exempt from operator auth, matched TIGHTLY (no leak/traversal) ---
func TestSelfBind_E_TheTrap(t *testing.T) {
s, st := newAuthServer(t)
selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
// The public bind link renders WITHOUT a login (the exemption works).
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Párosító kód") {
t.Fatalf("public /bind/ link was gated or not rendered: code=%d", rr.Code)
}
// A sibling prefix must NOT be exempt: /bindsecret is still gated (tight trailing-slash match).
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bindsecret", nil))
if rr.Code != http.StatusFound {
t.Fatalf("/bindsecret leaked through the exemption (prefix not tight): code=%d", rr.Code)
}
// Traversal through the exempt prefix must not reach a gated handler: it stays inside handleBind,
// which rejects a token containing '/', never touching /hosts.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/../hosts", nil))
if strings.Contains(rr.Body.String(), "Unclaimed appliances") || strings.Contains(rr.Body.String(), "No hosts enrolled") {
t.Fatalf("path traversal through /bind/ reached the hosts page")
}
// Red-proof: widen isPublicBindPath to strings.HasPrefix(path, "/bind") (drop the slash) → the
// /bindsecret gated assertion goes red; broaden it further and Scenario D goes red too.
}
// --- Scenario F: EXPIRY falls back — an expired link binds nothing, even with correct factors ---
func TestSelfBind_F_ExpiryFallsBack(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", -1*time.Hour) // already expired
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
t.Fatalf("expired link did not render the expired state")
}
// Even the CORRECT secrets on an expired link bind nothing (operator-bind fallback is unchanged).
bindPOST(t, s, token, testCodeFmt, testPass)
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Fatalf("an expired link still bound the appliance: %+v", a)
}
// Red-proof: drop the `expires_at > datetime('now')` guard in ConsumeSelfBindToken AND the
// tok.Expired() gate → the expired POST binds and the still-registered assertion goes red.
}
// --- Scenario (F1/F2): the operator MINT honesty paths ---
// F1: a customer with no registered email → nothing minted, LOUD flash.
func TestSelfBind_MintNoEmail(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "noemail", APIKey: "k", RetrievalPassword: testPass}); err != nil {
t.Fatal(err)
}
s.SetSelfBindMailer(&stubMailer{})
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/customers/noemail/selfbind-link", nil)
s.handleSelfBindLinkSend(rr, req, "noemail")
if rr.Code != http.StatusSeeOther || !strings.Contains(rr.Header().Get("Location"), "selfbind-no-email") {
t.Fatalf("no-email mint should redirect with selfbind-no-email: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
}
if tok, _ := st.SelfBindTokenByHash(selfBindHash("x")); tok != nil {
t.Fatal("a token was minted despite no email")
}
}
// F2: the email send fails → the just-minted token is deleted (not left silently live).
func TestSelfBind_MintSendFailsCleansUp(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
stub := &stubMailer{fail: true}
s.SetSelfBindMailer(stub)
rr := httptest.NewRecorder()
s.handleSelfBindLinkSend(rr, httptest.NewRequest("POST", "/customers/acme/selfbind-link", nil), "acme")
if !strings.Contains(rr.Header().Get("Location"), "selfbind-send-failed") {
t.Fatalf("send failure should redirect with selfbind-send-failed: %q", rr.Header().Get("Location"))
}
// The token that was minted for the (failed) send is gone — not left silently live (F2 cleanup).
// Recover the token from the link the mailer was handed, and confirm it no longer resolves.
tokenFromLink := stub.link[strings.LastIndexByte(stub.link, '/')+1:]
if tokenFromLink == "" {
t.Fatal("mailer was never handed a link")
}
if tok, _ := st.SelfBindTokenByHash(selfBindHash(tokenFromLink)); tok != nil {
t.Fatal("a failed send left a live token behind")
}
}
// stubMailer records the last send and can be told to fail.
type stubMailer struct {
fail bool
link string
}
func (m *stubMailer) SendSelfBindEmail(customerID, email, link string) error {
m.link = link
if m.fail {
return errStubSend
}
return nil
}
var errStubSend = &stubErr{}
type stubErr struct{}
func (*stubErr) Error() string { return "stub send failure" }
// newAuthServer is newTestServer with an operator password configured, so RequireAuth is live.
func newAuthServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
s, st := newTestServer(t)
h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
s.configPasswordHash = string(h)
return s, st
}