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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-06 18:05:43 -04:00
parent 835301ce58
commit 4595eafab0
2 changed files with 387 additions and 25 deletions

View file

@ -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

View file

@ -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"]))
}
}