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:
2026-06-26 15:35:24 +02:00
parent 230980f7a8
commit 8098237ce1
7 changed files with 331 additions and 1 deletions
+84
View File
@@ -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