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 }