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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user