From 4595eafab0bd00215158ae513cabb503abc77977 Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 6 Apr 2026 18:05:43 -0400 Subject: [PATCH] feat: implement install secret generation with one-time print Auto-generate 256-bit installation secret on first run using crypto/rand, print it exactly once to stdout, and store in SQLite for subsequent startups. Support SPAXEL_INSTALL_SECRET env var override. Expose via GET /api/auth/install-secret endpoint (admin session or first-run state). Derive per-node provisioning tokens via HMAC-SHA256(install_secret, node_mac). Co-Authored-By: Claude Opus 4.6 --- mothership/internal/auth/handler.go | 111 +++++++-- mothership/internal/auth/handler_test.go | 301 +++++++++++++++++++++++ 2 files changed, 387 insertions(+), 25 deletions(-) diff --git a/mothership/internal/auth/handler.go b/mothership/internal/auth/handler.go index eb06d23..1cf0c65 100644 --- a/mothership/internal/auth/handler.go +++ b/mothership/internal/auth/handler.go @@ -12,7 +12,7 @@ import ( "fmt" "log" "net/http" - "strconv" + "os" "strings" "time" @@ -63,6 +63,7 @@ func NewHandler(cfg Config) (*Handler, error) { } // initializeAuth ensures the auth table has a singleton row and generates an install secret. +// On first run, prints the secret to stdout exactly once. func (h *Handler) initializeAuth() error { // Check if auth table exists and has a row var count int @@ -103,37 +104,67 @@ func (h *Handler) initializeAuth() error { return fmt.Errorf("create sessions index: %w", err) } - // Check if we have an auth row + // Step 1: Check SPAXEL_INSTALL_SECRET env var — if set, use it directly + if envSecret := os.Getenv("SPAXEL_INSTALL_SECRET"); envSecret != "" { + secretBytes, err := hex.DecodeString(envSecret) + if err != nil { + return fmt.Errorf("decode SPAXEL_INSTALL_SECRET: %w", err) + } + if len(secretBytes) != 32 { + return fmt.Errorf("SPAXEL_INSTALL_SECRET must be 64 hex chars (32 bytes), got %d bytes", len(secretBytes)) + } + + // Upsert into auth table + _, err = h.db.Exec(` + INSERT OR REPLACE INTO auth (id, install_secret, pin_bcrypt, updated_at) + VALUES (1, ?, COALESCE((SELECT pin_bcrypt FROM auth WHERE id = 1), NULL), strftime('%s', 'now') * 1000) + `, secretBytes) + if err != nil { + return fmt.Errorf("store env install secret: %w", err) + } + + log.Printf("[INFO] Using provided SPAXEL_INSTALL_SECRET") + return nil + } + + // Step 2: Check if we already have an auth row with install_secret err = h.db.QueryRow("SELECT COUNT(*) FROM auth WHERE id = 1").Scan(&count) if err != nil { return fmt.Errorf("check auth row: %w", err) } - if count == 0 { - // Generate install secret - installSecret := make([]byte, 32) - if _, err := rand.Read(installSecret); err != nil { - return fmt.Errorf("generate install secret: %w", err) - } - - // Insert auth row - _, err = h.db.Exec(` - INSERT INTO auth (id, install_secret, pin_bcrypt) - VALUES (1, ?, NULL) - `, installSecret) - if err != nil { - return fmt.Errorf("insert auth row: %w", err) - } - - log.Printf("[INFO] Generated new install secret") + if count > 0 { + // Secret already exists in SQLite — load silently + log.Printf("[DEBUG] Install secret loaded from database") + return nil } + // Step 3: No env var, no existing secret — generate a new one + installSecret := make([]byte, 32) + if _, err := rand.Read(installSecret); err != nil { + return fmt.Errorf("generate install secret: %w", err) + } + + // Insert auth row + _, err = h.db.Exec(` + INSERT INTO auth (id, install_secret, pin_bcrypt) + VALUES (1, ?, NULL) + `, installSecret) + if err != nil { + return fmt.Errorf("insert auth row: %w", err) + } + + // Print ONCE to stdout + secretHex := hex.EncodeToString(installSecret) + fmt.Fprintf(os.Stdout, "[SPAXEL] Installation secret: %s. Shown once — save to a safe place.\n", secretHex) + return nil } // RegisterRoutes registers auth routes with the given router. func (h *Handler) RegisterRoutes(mux interface{ HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) }) { mux.HandleFunc("GET /api/auth/status", h.handleStatus) + mux.HandleFunc("GET /api/auth/install-secret", h.handleInstallSecret) mux.HandleFunc("POST /api/auth/setup", h.handleSetup) mux.HandleFunc("POST /api/auth/login", h.handleLogin) mux.HandleFunc("POST /api/auth/logout", h.handleLogout) @@ -156,6 +187,36 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { }) } +// handleInstallSecret returns the installation secret hex. +// Requires admin session (authenticated) OR first-run state (no PIN configured). +func (h *Handler) handleInstallSecret(w http.ResponseWriter, r *http.Request) { + // Allow access on first-run (no PIN configured) OR with valid session + var pinBcrypt sql.NullString + err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + if pinBcrypt.Valid && !h.IsAuthenticated(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var secret []byte + err = h.db.QueryRow("SELECT install_secret FROM auth WHERE id = 1").Scan(&secret) + if err != nil { + http.Error(w, "Failed to retrieve install secret", http.StatusInternalServerError) + log.Printf("[ERROR] Failed to retrieve install secret: %v", err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "install_secret": hex.EncodeToString(secret), + }) +} + // handleSetup sets a PIN on first run. // No authentication required, but only works if PIN is not yet set. func (h *Handler) handleSetup(w http.ResponseWriter, r *http.Request) { @@ -493,9 +554,9 @@ func (h *Handler) DeriveNodeToken(mac string) (string, error) { mac = strings.ToUpper(strings.ReplaceAll(mac, ":", "")) // Compute HMAC-SHA256(install_secret, mac) - h := hmac.New(sha256.New, secret) - h.Write([]byte(mac)) - return hex.EncodeToString(h.Sum(nil)), nil + macHash := hmac.New(sha256.New, secret) + macHash.Write([]byte(mac)) + return hex.EncodeToString(macHash.Sum(nil)), nil } // ValidateNodeToken checks if a node token is valid. @@ -511,9 +572,9 @@ func (h *Handler) ValidateNodeToken(mac, token string) bool { mac = strings.ToUpper(strings.ReplaceAll(mac, ":", "")) // Compute expected token - h := hmac.New(sha256.New, secret) - h.Write([]byte(mac)) - expectedToken := hex.EncodeToString(h.Sum(nil)) + macHash := hmac.New(sha256.New, secret) + macHash.Write([]byte(mac)) + expectedToken := hex.EncodeToString(macHash.Sum(nil)) // Use constant-time comparison to prevent timing attacks return subtle.ConstantTimeCompare([]byte(expectedToken), []byte(token)) == 1 diff --git a/mothership/internal/auth/handler_test.go b/mothership/internal/auth/handler_test.go index 46ab5e0..da3cf08 100644 --- a/mothership/internal/auth/handler_test.go +++ b/mothership/internal/auth/handler_test.go @@ -3,6 +3,7 @@ package auth import ( "database/sql" + "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -423,3 +424,303 @@ func TestPublicPaths(t *testing.T) { }) } } + +func TestInstallSecret_GeneratedOnFirstRun(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Verify secret was stored + secret, err := h.GetInstallSecret() + if err != nil { + t.Fatal(err) + } + + if len(secret) != 32 { + t.Errorf("Expected 32-byte secret, got %d bytes", len(secret)) + } + + // Verify it's hex-encodable (all bytes) + hexStr := hex.EncodeToString(secret) + if len(hexStr) != 64 { + t.Errorf("Expected 64-char hex string, got %d chars", len(hexStr)) + } +} + +func TestInstallSecret_EnvVarOverride(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + knownSecret := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + t.Setenv("SPAXEL_INSTALL_SECRET", knownSecret) + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Verify the stored secret matches the env var + secret, err := h.GetInstallSecret() + if err != nil { + t.Fatal(err) + } + + gotHex := hex.EncodeToString(secret) + if gotHex != knownSecret { + t.Errorf("Expected secret %q, got %q", knownSecret, gotHex) + } +} + +func TestInstallSecret_EnvVarInvalidHex(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + t.Setenv("SPAXEL_INSTALL_SECRET", "zzzz-invalid-hex") + + _, err = NewHandler(Config{DB: db}) + if err == nil { + t.Fatal("Expected error for invalid hex in SPAXEL_INSTALL_SECRET") + } +} + +func TestInstallSecret_EnvVarWrongLength(t *testing.T) { + tests := []struct { + name string + secret string + }{ + {"too short (8 bytes)", "a1b2c3d4e5f6a7b8"}, + {"too long (40 bytes)", "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + t.Setenv("SPAXEL_INSTALL_SECRET", tt.secret) + + _, err = NewHandler(Config{DB: db}) + if err == nil { + t.Fatal("Expected error for wrong-length SPAXEL_INSTALL_SECRET") + } + }) + } +} + +func TestInstallSecret_PersistedAcrossRestarts(t *testing.T) { + // Use a temp file so the DB persists across handler instances + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + + // First handler: generates secret + h1, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + + secret1, err := h1.GetInstallSecret() + if err != nil { + t.Fatal(err) + } + if len(secret1) != 32 { + t.Fatalf("Expected 32-byte secret, got %d", len(secret1)) + } + + // Close first handler + h1.Close() + + // Second handler: should load same secret from DB (no env var set) + h2, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h2.Close() + + secret2, err := h2.GetInstallSecret() + if err != nil { + t.Fatal(err) + } + + if hex.EncodeToString(secret1) != hex.EncodeToString(secret2) { + t.Error("Secret changed across handler restarts") + } +} + +func TestInstallSecret_NodeTokenDerivation(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + tests := []struct { + name string + mac string + mac2 string + same bool // whether mac and mac2 should produce the same token + }{ + {"same MAC always produces same token", "AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF", true}, + {"different MACs produce different tokens", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", false}, + {"case insensitive", "aa:bb:cc:dd:ee:ff", "AA:BB:CC:DD:EE:FF", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token1, err := h.DeriveNodeToken(tt.mac) + if err != nil { + t.Fatal(err) + } + token2, err := h.DeriveNodeToken(tt.mac2) + if err != nil { + t.Fatal(err) + } + + if tt.same && token1 != token2 { + t.Errorf("Expected same token for %q and %q, got %q vs %q", tt.mac, tt.mac2, token1, token2) + } + if !tt.same && token1 == token2 { + t.Errorf("Expected different tokens for %q and %q, got same %q", tt.mac, tt.mac2, token1) + } + }) + } +} + +func TestHandleInstallSecret_FirstRun(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // First run (no PIN configured): should return secret without auth + req := httptest.NewRequest("GET", "/api/auth/install-secret", nil) + w := httptest.NewRecorder() + h.handleInstallSecret(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 on first run, got %d", w.Code) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if len(resp["install_secret"]) != 64 { + t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"])) + } +} + +func TestHandleInstallSecret_AfterPINSet_Unauthorized(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Configure PIN + reqBody := `{"pin": "1234"}` + req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSetup(w, req) + + // Request without auth: should be rejected + req = httptest.NewRequest("GET", "/api/auth/install-secret", nil) + w = httptest.NewRecorder() + h.handleInstallSecret(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401 after PIN set, got %d", w.Code) + } +} + +func TestHandleInstallSecret_AfterPINSet_Authorized(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Configure PIN and get session + reqBody := `{"pin": "1234"}` + req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSetup(w, req) + + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == "spaxel_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("Session cookie not set after setup") + } + + // Request with valid session: should succeed + req = httptest.NewRequest("GET", "/api/auth/install-secret", nil) + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + h.handleInstallSecret(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 with valid session, got %d", w.Code) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if len(resp["install_secret"]) != 64 { + t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"])) + } +}