From 8098237ce10d65ab5ea2496c7d1757409beb05fe Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 26 Jun 2026 15:35:24 +0200 Subject: [PATCH] hub v0.14.0: passphrase-authed host enrollment (Day-0 option C) New POST /api/v1/host-enroll (handleHostEnroll): X-Retrieval-Password authed, body {customer_id} -> {host_id, api_key}. Mint-once-reuse (201 first, 200 reuse) so re-running the host-bootstrap never orphans a running agent's key; auth checked before any mint. Backed by new Store.GetHostByCustomer (ORDER BY updated_at DESC LIMIT 1, idx_hosts_customer). GET /config/{id} and global-key POST /admin/hosts left untouched. Exact-match route (path == "/host-enroll") to avoid the /hosts/ prefix collision. Tests: host_enroll_test.go (mint/reuse/401-no-mint/404/400) + GetHostByCustomer store test; companion red-proof verified always-mint fails the reuse assertion. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7 --- ...IKE-day0-firstboot-handshake-2026-06-26.md | 9 +- hub/CHANGELOG.md | 25 +++ hub/README.md | 10 ++ hub/internal/api/handler.go | 84 ++++++++++ hub/internal/api/host_enroll_test.go | 149 ++++++++++++++++++ hub/internal/store/host_test.go | 40 +++++ hub/internal/store/store.go | 15 ++ 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 hub/internal/api/host_enroll_test.go diff --git a/documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md b/documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md index 774147d..cfe94be 100644 --- a/documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md +++ b/documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md @@ -176,8 +176,15 @@ scope — the `customerID` (path param) and the `h.store` handle (which exposes headers alongside the YAML body. The in-guest controller ignores them; the script reads them. Fewer endpoints but couples two tiers' secrets into one response. +> **UPDATE 2026-06-26 — c-pref SHIPPED in hub v0.14.0.** The dedicated passphrase-authed endpoint +> landed as `POST /api/v1/host-enroll` (mint-once-reuse via the new `Store.GetHostByCustomer`); the +> proven `GET /config/{id}` and the global-key `POST /admin/hosts` escape hatch are unchanged. See the +> hub `CHANGELOG.md` v0.14.0 + `README.md` "Host Enrollment (Day-0, option C)". The route is the exact +> `path == "/host-enroll"` form (not `/hosts/enroll`, which would collide with the `/hosts/` per-host +> prefix routes). + **Recommendation:** ship **Option A now** (it is proven and needs nothing). Adopt **Option C, variant -(c-pref) — a dedicated passphrase-authed `hosts/enroll` endpoint, mint-once-reuse — at the production +(c-pref) — a dedicated passphrase-authed `host-enroll` endpoint, mint-once-reuse — at the production cutover**, on the secret-hygiene argument: it removes the operator-tier global key from the field deployment entirely and reduces the operator-carried secret set to one (the passphrase the customer already has). It is a small, isolated change that does not touch the proven controller config-pull. diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index e9d311b..975fd32 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,30 @@ # Felhom Hub — Changelog +## v0.14.0 — Passphrase-authed host enrollment (Day-0 option C) (2026-06-26) + +Adds the single-secret Day-0 host-enrollment path proven in +`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md` (option C). The operator / +host-bootstrap script now carries **only the customer's retrieval passphrase** — the global operator +key never enters the field deploy path. + +- **New endpoint `POST /api/v1/host-enroll`** (`internal/api/handler.go`, `handleHostEnroll`): + passphrase-authed (`X-Retrieval-Password` header, body `{customer_id}`), returns + `{host_id, api_key}`. **Mint-once-reuse** — mints on first call (`201`), returns the existing + credential byte-for-byte on every subsequent call (`200`), so re-running the bootstrap never orphans + a running agent's key. Auth is checked **before** any mint (a wrong passphrase never writes a row): + wrong/missing passphrase → `401`, unknown customer → `404`, missing `customer_id` → `400`. Mirrors + `handleConfigRetrieve`'s auth pattern + `handleAdminCreateHost`'s mint block. +- **New store method `Store.GetHostByCustomer`** (`internal/store/store.go`): `SELECT … FROM hosts + WHERE customer_id = ? ORDER BY updated_at DESC LIMIT 1` (uses `idx_hosts_customer`), nil-on-not-found. + Backs the reuse lookup. >1 host for a customer (not expected in Day-0) → most-recent wins, never a + duplicate mint. +- **Unchanged & deliberately untouched:** `GET /api/v1/config/{id}` (controller pull — same raw-YAML + body) and `POST /api/v1/admin/hosts` (global-key operator escape hatch, still PROVISIONAL pending the + cutover lock-down). +- Tests: `internal/api/host_enroll_test.go` (mint/reuse/401-no-mint/404/400, +5) and a + `GetHostByCustomer` store test (+1); companion red-proof confirmed an always-mint variant fails the + reuse assertion. + ## v0.13.1 — DR recipe v1 drive-shape sync: test-data + regression guard only (2026-06-16) **No behavior change — redeploy optional.** Tracks the agent's v0.39.0 v1 host-half drive shape (which diff --git a/hub/README.md b/hub/README.md index 39e4d52..85d7c6d 100644 --- a/hub/README.md +++ b/hub/README.md @@ -131,6 +131,16 @@ Notifications are dispatched automatically when events are processed: Config retrieval uses a separate per-customer retrieval password (not the API key). Retrieval passwords are auto-generated as **Hungarian word passphrases** (e.g., `alma-kerék-madár-felhő`) for easy phone-based entry during disaster recovery. The Hub generates a complete `controller.yaml` by deep-merging `controller.yaml.example` (periodically fetched from the Gitea repo) with customer-specific overrides (identity, infrastructure tokens, hub API key, session secret). +### Host Enrollment (Day-0, option C) + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/host-enroll` | Mint-or-reuse a host credential, body `{customer_id}` (auth: `X-Retrieval-Password` header) → `{host_id, api_key}` | + +The Day-0 first-boot handshake (`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`) enrolls a Proxmox host's agent **using the customer's retrieval passphrase only** — the operator-tier global key never enters the field deploy path. `host-enroll` mints the per-host credential on the first call and **reuses it byte-for-byte** on every later call (idempotent — re-running the host-bootstrap never orphans a running agent's key), backed by `Store.GetHostByCustomer`. Auth is checked **before** any mint, so a wrong passphrase never creates a host row. Behaviour: first → `201`, reuse → `200`, wrong/missing passphrase → `401`, unknown customer → `404`, missing `customer_id` → `400`. + +The global-key `POST /api/v1/admin/hosts` (operator/HQ pre-mint) remains as the escape hatch pending the enrollment-cutover lock-down; it and `GET /config/{id}` are unchanged by this endpoint. + ### Assets | Method | Path | Description | diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 352ece0..b636933 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -124,6 +124,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleReport(w, r) case r.Method == http.MethodPost && path == "/host-report": h.handleHostReport(w, r) + case r.Method == http.MethodPost && path == "/host-enroll": + h.handleHostEnroll(w, r) case r.Method == http.MethodPost && path == "/admin/hosts": h.handleAdminCreateHost(w, r) case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"): @@ -651,6 +653,88 @@ func (h *Handler) handleAdminCreateHost(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey}) } +// handleHostEnroll is the passphrase-authed, mint-once-reuse host enrollment for Day-0 +// (option C, SPIKE-day0-firstboot-handshake-2026-06-26). It is the sibling of the +// global-key handleAdminCreateHost: the operator/host-bootstrap script carries ONLY the +// customer's retrieval passphrase (no global key in the field deploy path), POSTs the +// customer_id, and gets back the host credential — minted on first call, REUSED byte-for- +// byte on every subsequent call (so re-running the bootstrap never orphans a live agent's +// key). Auth (passphrase) is checked BEFORE any mint — a bad-auth call never writes a row. +// The proven GET /config/{id} controller pull and POST /admin/hosts escape hatch are +// untouched. +func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + var req struct { + CustomerID string `json:"customer_id"` + } + if err := json.Unmarshal(body, &req); err != nil || req.CustomerID == "" { + http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) + return + } + + // Passphrase auth — mirrors handleConfigRetrieve exactly (header, 404-then-401 order, + // constant-time compare). Happens BEFORE any mint. + password := r.Header.Get("X-Retrieval-Password") + if password == "" { + http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized) + return + } + cc, err := h.store.GetCustomerConfig(req.CustomerID) + if err != nil { + h.logger.Printf("[ERROR] host-enroll: customer lookup failed for %s: %v", req.CustomerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if cc == nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + if subtle.ConstantTimeCompare([]byte(password), []byte(cc.RetrievalPassword)) != 1 { + http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized) + return + } + + // Mint-once-reuse: an existing host for this customer is returned as-is (idempotent). + existing, err := h.store.GetHostByCustomer(req.CustomerID) + if err != nil { + h.logger.Printf("[ERROR] host-enroll: host lookup failed for %s: %v", req.CustomerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if existing != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey}) + return + } + + // First enroll: mint (mirrors handleAdminCreateHost's mint block). + sfx, err := configgen.RandomHex(3) // 6 hex chars — host_id suffix + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + hostID := req.CustomerID + "-" + sfx + apiKey, err := configgen.RandomHex(32) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if err := h.store.UpsertHost(&store.Host{HostID: hostID, CustomerID: req.CustomerID, APIKey: apiKey}); err != nil { + h.logger.Printf("[ERROR] host-enroll: failed to mint host for %s: %v", req.CustomerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + h.logger.Printf("[INFO] host enrolled: %s (customer %s)", hostID, req.CustomerID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey}) +} + // escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob // (slice 7, doc 03 §8a). It MUST stay in lockstep with the agent's emit struct // (felhom-agent cmd/felhom-agent escrowUploadRequest). The hub stores the bytes and NEVER decrypts diff --git a/hub/internal/api/host_enroll_test.go b/hub/internal/api/host_enroll_test.go new file mode 100644 index 0000000..5661b19 --- /dev/null +++ b/hub/internal/api/host_enroll_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// doEnroll POSTs /host-enroll with the passphrase header (the do() helper only sets Bearer). +func doEnroll(h *Handler, customerID, pw string) *httptest.ResponseRecorder { + body := `{"customer_id":"` + customerID + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/host-enroll", strings.NewReader(body)) + if pw != "" { + req.Header.Set("X-Retrieval-Password", pw) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} + +type enrollResp struct { + HostID string `json:"host_id"` + APIKey string `json:"api_key"` +} + +func countHostsForCustomer(t *testing.T, st *store.Store, customerID string) int { + t.Helper() + hosts, err := st.ListHosts() + if err != nil { + t.Fatalf("ListHosts: %v", err) + } + n := 0 + for _, h := range hosts { + if h.CustomerID == customerID { + n++ + } + } + return n +} + +var hostIDSuffix = regexp.MustCompile(`^c1-[0-9a-f]{6}$`) +var hex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// Scenario A — first enroll mints. +func TestHostEnroll_FirstMints(t *testing.T) { + h, st, _ := newTestHandler(t) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"}) + + rr := doEnroll(h, "c1", "pass-phrase") + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) + } + var got enrollResp + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if !hostIDSuffix.MatchString(got.HostID) { + t.Errorf("host_id = %q, want c1-<6hex>", got.HostID) + } + if !hex64.MatchString(got.APIKey) { + t.Errorf("api_key = %q, want 64 hex", got.APIKey) + } + if n := countHostsForCustomer(t, st, "c1"); n != 1 { + t.Errorf("host rows for c1 = %d, want 1", n) + } + byKey, err := st.GetHostByAPIKey(got.APIKey) + if err != nil || byKey == nil || byKey.HostID != got.HostID { + t.Errorf("GetHostByAPIKey(minted) = %+v / %v", byKey, err) + } +} + +// Scenario B — second enroll REUSES (idempotent). The load-bearing case. +func TestHostEnroll_SecondReuses(t *testing.T) { + h, st, _ := newTestHandler(t) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"}) + + rr1 := doEnroll(h, "c1", "pass-phrase") + if rr1.Code != http.StatusCreated { + t.Fatalf("first status = %d", rr1.Code) + } + var first enrollResp + json.Unmarshal(rr1.Body.Bytes(), &first) + + rr2 := doEnroll(h, "c1", "pass-phrase") + if rr2.Code != http.StatusOK { + t.Fatalf("second status = %d, want 200 (reuse), body=%s", rr2.Code, rr2.Body.String()) + } + var second enrollResp + json.Unmarshal(rr2.Body.Bytes(), &second) + + if second.HostID != first.HostID || second.APIKey != first.APIKey { + t.Errorf("reuse returned different creds: first=%+v second=%+v", first, second) + } + if n := countHostsForCustomer(t, st, "c1"); n != 1 { + t.Errorf("after 2nd enroll host rows for c1 = %d, want 1 (no orphan/dup)", n) + } +} + +// Scenario C — wrong passphrase refused, NO mint (auth before mint). +func TestHostEnroll_WrongPassphrase_NoMint(t *testing.T) { + h, st, _ := newTestHandler(t) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"}) + + rr := doEnroll(h, "c1", "wrong") + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } + if n := countHostsForCustomer(t, st, "c1"); n != 0 { + t.Errorf("host rows for c1 = %d after bad auth, want 0 (no mint on bad auth)", n) + } +} + +// Scenario D — unknown customer → 404, no mint. +func TestHostEnroll_UnknownCustomer(t *testing.T) { + h, st, _ := newTestHandler(t) + rr := doEnroll(h, "ghost", "anything") + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } + if n := countHostsForCustomer(t, st, "ghost"); n != 0 { + t.Errorf("host rows for ghost = %d, want 0", n) + } +} + +// Scenario E — missing header → 401 ; missing customer_id → 400. +func TestHostEnroll_MissingHeaderAndCustomerID(t *testing.T) { + h, st, _ := newTestHandler(t) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"}) + + // missing X-Retrieval-Password → 401 + rr := doEnroll(h, "c1", "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("missing header status = %d, want 401", rr.Code) + } + + // empty/absent customer_id → 400 (checked before auth: payload validation first) + req := httptest.NewRequest(http.MethodPost, "/api/v1/host-enroll", strings.NewReader(`{}`)) + req.Header.Set("X-Retrieval-Password", "pass-phrase") + rr2 := httptest.NewRecorder() + h.ServeHTTP(rr2, req) + if rr2.Code != http.StatusBadRequest { + t.Errorf("missing customer_id status = %d, want 400", rr2.Code) + } +} diff --git a/hub/internal/store/host_test.go b/hub/internal/store/host_test.go index 1eac1eb..c03d8f8 100644 --- a/hub/internal/store/host_test.go +++ b/hub/internal/store/host_test.go @@ -45,6 +45,46 @@ func TestUpsertHost_AndLookup(t *testing.T) { } } +func TestGetHostByCustomer(t *testing.T) { + s := newTestStore(t) + + // none → nil, nil + got, err := s.GetHostByCustomer("c1") + if err != nil || got != nil { + t.Fatalf("no host: got %+v / %v (want nil,nil)", got, err) + } + + // one → that host + if err := s.UpsertHost(&Host{HostID: "c1-aaa111", CustomerID: "c1", APIKey: "k1"}); err != nil { + t.Fatal(err) + } + got, err = s.GetHostByCustomer("c1") + if err != nil || got == nil || got.HostID != "c1-aaa111" || got.APIKey != "k1" { + t.Fatalf("one host: got %+v / %v", got, err) + } + + // two for the same customer → most-recently-updated wins (never a duplicate on reuse). + // updated_at is second-resolution, so set it explicitly to make the ordering deterministic. + if err := s.UpsertHost(&Host{HostID: "c1-bbb222", CustomerID: "c1", APIKey: "k2"}); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE hosts SET updated_at='2026-01-01 00:00:00' WHERE host_id='c1-aaa111'`); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE hosts SET updated_at='2026-06-26 00:00:00' WHERE host_id='c1-bbb222'`); err != nil { + t.Fatal(err) + } + got, err = s.GetHostByCustomer("c1") + if err != nil || got == nil || got.HostID != "c1-bbb222" { + t.Fatalf("two hosts: want most-recent c1-bbb222, got %+v / %v", got, err) + } + + // a different customer is unaffected + if other, err := s.GetHostByCustomer("c2"); err != nil || other != nil { + t.Fatalf("other customer: got %+v / %v (want nil,nil)", other, err) + } +} + func TestSaveHostReport_BumpsRealityPreservesIntent(t *testing.T) { s := newTestStore(t) if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil { diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index dd519cc..95c88a7 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -1147,6 +1147,21 @@ func (s *Store) GetHost(hostID string) (*Host, error) { return h, err } +// GetHostByCustomer returns the customer's host, or nil (no error) if none exists. +// Backs the passphrase-authed host-enroll mint-once-reuse path (Day-0 option C): on +// the second enroll the existing credential is reused, not re-minted. A customer is +// expected to have at most one host in the Day-0 model; if more than one ever exists, +// the most-recently-updated wins (we never mint a duplicate on a reuse). Uses the +// idx_hosts_customer index. Mirrors GetHostByAPIKey's nil-on-not-found contract. +func (s *Store) GetHostByCustomer(customerID string) (*Host, error) { + h, err := scanHost(s.db.QueryRow(`SELECT `+hostSelectCols+ + ` FROM hosts WHERE customer_id = ? ORDER BY updated_at DESC LIMIT 1`, customerID).Scan) + if err == sql.ErrNoRows { + return nil, nil + } + return h, err +} + // ListHosts returns all hosts (debug / host-domain views). func (s *Store) ListHosts() ([]Host, error) { rows, err := s.db.Query(`SELECT ` + hostSelectCols + ` FROM hosts ORDER BY host_id`)