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:
parent
835301ce58
commit
4595eafab0
2 changed files with 387 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue