From 15bb942d9b8cf52bc81260b79223a5dab0cc2820 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 14:53:39 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20POST=20/api/admin/agent-keys=20?= =?UTF-8?q?=E2=80=94=20system-keyed=20raw=20key=20minting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New admin endpoint for provisioning per-agent dialectic API keys during recruitment. Auth via separate x-dialectic-admin-key header matching env DIALECTIC_ADMIN_API_KEY (not bearer — admin lifecycle is independent of agent identity). Behavior: - Body {agent_id, force?}; generates 32-byte hex raw key; stores sha256-peppered hash in agent_keys; returns raw key (ONLY time exposed — caller stores in agent secret-mgr) - 409 on existing agent_id unless force:true (rotates the hash, clears last_used_at + revoked_at) - Closed-by-default: if DIALECTIC_ADMIN_API_KEY env is empty, every request 401s Caller pattern: skills/dialectic-hangman-lab/scripts/dialectic-ctrl (to be added) reads admin key from /root/.openclaw/system-secrets/dialectic-admin-key on the openclaw host, POSTs to admin endpoint, stores returned raw key in the proxy- for agent secret-mgr (inherits the proxy-pcexec context from recruitment/onboard). Unblocks Phase 3.5 plan to provision all prod agents and integrate into recruitment skill. --- internal/config/config.go | 8 ++ internal/httpapi/handlers/admin.go | 125 +++++++++++++++++++++++++++++ internal/httpapi/routes.go | 6 ++ internal/orchestrator/ticker.go | 1 - 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 internal/httpapi/handlers/admin.go diff --git a/internal/config/config.go b/internal/config/config.go index 9a03278..cbeb5c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,13 @@ type Config struct { AgentAPIKeyPepper string OIDCDevBypassToken string + // DialecticAdminAPIKey gates POST /api/admin/agent-keys (raw key + // minting). Held on the operator side only — kept on the openclaw + // host at /root/.openclaw/system-secrets/dialectic-admin-key for + // `dialectic-ctrl` script to read. Empty in env → admin endpoint + // fully closed. + DialecticAdminAPIKey string + // OIDC issuer URL (Keycloak realm endpoint). e.g. // https://auth.hangman-lab.top/realms/hangman-lab // Phase 2C ships this as configured-but-not-verified; Phase 4 wires @@ -85,6 +92,7 @@ func LoadFromEnv() (*Config, error) { SystemAPIKey: os.Getenv("SYSTEM_API_KEY"), AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"), OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"), + DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"), diff --git a/internal/httpapi/handlers/admin.go b/internal/httpapi/handlers/admin.go new file mode 100644 index 0000000..be65050 --- /dev/null +++ b/internal/httpapi/handlers/admin.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + + "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth" +) + +type AdminHandler struct { + db *sqlx.DB + pepper string + adminAPIKey string +} + +func NewAdminHandler(db *sqlx.DB, pepper string, adminAPIKey string) *AdminHandler { + return &AdminHandler{db: db, pepper: pepper, adminAPIKey: adminAPIKey} +} + +type provisionAgentKeyBody struct { + AgentID string `json:"agent_id"` + Force bool `json:"force"` +} + +// POST /api/admin/agent-keys +// +// System-auth (header `x-dialectic-admin-key` matching env +// `DIALECTIC_ADMIN_API_KEY`). Generates a fresh 32-byte hex random +// raw key, stores its peppered-sha256 hash in `agent_keys`, and +// returns the raw key in the response. This is the ONLY time the raw +// key is exposed — caller must capture it and place in the target +// agent's secret-mgr. +// +// On duplicate (agent_id already has a key): 409 unless `force: true`, +// in which case the old hash is replaced. +// +// Caller pattern: `dialectic-ctrl create-key` (run via proxy-pcexec +// with proxy-for set to the agent being onboarded — see +// `skills/dialectic-hangman-lab/scripts/dialectic-ctrl`). +func (h *AdminHandler) ProvisionAgentKey(w http.ResponseWriter, r *http.Request) { + if !h.checkAdminAuth(r) { + http.Error(w, "admin auth required (x-dialectic-admin-key)", http.StatusUnauthorized) + return + } + if h.pepper == "" { + http.Error(w, "server misconfigured: AGENT_API_KEY_PEPPER unset", http.StatusInternalServerError) + return + } + + var body provisionAgentKeyBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + if body.AgentID == "" { + http.Error(w, "agent_id required", http.StatusBadRequest) + return + } + + rawKey, err := randomHexKey(32) + if err != nil { + http.Error(w, "rng failed: "+err.Error(), http.StatusInternalServerError) + return + } + hash := auth.HashKey(h.pepper, rawKey) + + _, err = h.db.ExecContext(r.Context(), + `INSERT INTO agent_keys (agent_id, key_hash) VALUES (?, ?)`, + body.AgentID, hash) + if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + // duplicate primary key + if !body.Force { + http.Error(w, fmt.Sprintf( + "agent %q already has a dialectic api key; pass force:true to rotate", + body.AgentID), http.StatusConflict) + return + } + // rotate: replace the hash + if _, err := h.db.ExecContext(r.Context(), + `UPDATE agent_keys SET key_hash = ?, last_used_at = NULL, revoked_at = NULL WHERE agent_id = ?`, + hash, body.AgentID); err != nil { + http.Error(w, "rotate failed: "+err.Error(), http.StatusInternalServerError) + return + } + } else { + http.Error(w, "insert failed: "+err.Error(), http.StatusInternalServerError) + return + } + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "agent_id": body.AgentID, + "api_key": rawKey, // raw — caller must store in agent's secret-mgr; not returned again + "rotated": body.Force, + }) +} + +func (h *AdminHandler) checkAdminAuth(r *http.Request) bool { + if h.adminAPIKey == "" { + return false // unset env = no admin caller is valid + } + got := r.Header.Get("x-dialectic-admin-key") + if got == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(h.adminAPIKey)) == 1 +} + +func randomHexKey(byteLen int) (string, error) { + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index a27f914..98ee034 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -63,6 +63,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler { signupsH := handlers.NewSignupsHandler(topicStore, signupStore) argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore) verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore) + adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey) // Routes. r.Route("/api", func(r chi.Router) { @@ -95,6 +96,11 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler { r.Use(requireAnyAuth) r.Get("/topics/{id}/signups", signupsH.List) }) + + // Admin: provision an agent api key. Auth is its own header + // (x-dialectic-admin-key against env DIALECTIC_ADMIN_API_KEY), + // not bearer — admin lifecycle is separate from agent identity. + r.Post("/admin/agent-keys", adminH.ProvisionAgentKey) }) return r diff --git a/internal/orchestrator/ticker.go b/internal/orchestrator/ticker.go index 43efbe9..c56c998 100644 --- a/internal/orchestrator/ticker.go +++ b/internal/orchestrator/ticker.go @@ -234,4 +234,3 @@ func (t *Ticker) broadcastAnnouncement(topic *models.Topic) { log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err) } } -