diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5c15f4e13..78cc4eb3b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -112,15 +112,12 @@ docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest - Automatic hashing on security setup - **CRITICAL**: Bcrypt hashes MUST be exactly 60 characters - **API Token Security**: - - 48-character hex tokens (24 bytes of entropy) - - Stored in plain text with file permissions (600) - - Live reloading when .env file changes (v4.3.9+) - - API-only mode supported (no password auth required) - - **Docker Users**: Always wrap hash in single quotes to prevent shell expansion -- **API Token Security**: - - SHA3-256 hashing for all tokens - - 64-character hex format when hashed + - 64-character hex tokens (32 bytes of entropy) + - SHA3-256 hashed before storage (64-char hash) + - Raw token shown only once during generation - Tokens NEVER stored in plain text + - Live reloading when .env file changes + - API-only mode supported (no password auth required) - **CSRF Protection**: All state-changing operations require CSRF tokens - **Rate Limiting**: - Authentication endpoints: 10 attempts/minute per IP @@ -159,13 +156,14 @@ Pulse supports multiple authentication methods that can be used independently or The easiest way to enable authentication is through the web UI: 1. Go to Settings → Security 2. Click "Enable Security Now" -3. Save the generated credentials -4. Click "Restart Pulse" (or restart Docker container) +3. Enter username and password +4. Save the generated API token (shown only once!) +5. Security is enabled immediately (no restart needed) This automatically: - Generates a secure random password - Hashes it with bcrypt (cost factor 12) -- Creates secure API token (SHA3-256 hashed) +- Creates secure API token (SHA3-256 hashed, raw token shown once) - For systemd: Configures systemd with hashed credentials - For Docker: Saves to `/data/.env` with hashed credentials (properly quoted to prevent shell expansion) - Restarts service/container with authentication enabled diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 5abee7878..88e51a273 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -1,8 +1,6 @@ package api import ( - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -12,7 +10,7 @@ import ( "strings" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" + internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rs/zerolog/log" @@ -106,7 +104,7 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } // Validate password complexity - if err := auth.ValidatePasswordComplexity(setupRequest.Password); err != nil { + if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -117,7 +115,7 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } // Hash the password - hashedPassword, err := auth.HashPassword(setupRequest.Password) + hashedPassword, err := internalauth.HashPassword(setupRequest.Password) if err != nil { log.Error().Err(err).Msg("Failed to hash password") http.Error(w, "Failed to process password", http.StatusInternalServerError) @@ -131,20 +129,21 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { return } - // Don't hash the API token - store it as plain text - // (Hashing causes issues with token length detection) - // Always use the new token when setting up full security - // This ensures any API-only tokens are replaced with the new secure token - apiToken := setupRequest.APIToken + // Store the raw API token for displaying to the user + rawAPIToken := setupRequest.APIToken + + // Hash the API token for storage + hashedAPIToken := internalauth.HashAPIToken(rawAPIToken) + if r.config.APIToken != "" && r.config.AuthUser == "" && r.config.AuthPass == "" { // We had API-only access before, now replacing with full security log.Info().Msg("Replacing API-only token with new secure token") } - // Update runtime config immediately - no restart needed! + // Update runtime config immediately with hashed token - no restart needed! r.config.AuthUser = setupRequest.Username r.config.AuthPass = hashedPassword - r.config.APIToken = apiToken + r.config.APIToken = hashedAPIToken r.config.APITokenEnabled = true r.config.PollingInterval = time.Duration(setupRequest.PollingInterval) * time.Second log.Info().Msg("Runtime config updated with new security settings - active immediately") @@ -185,7 +184,7 @@ PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' API_TOKEN=%s ENABLE_AUDIT_LOG=true -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) // Ensure directory exists os.MkdirAll(r.config.ConfigPath, 0755) @@ -221,7 +220,7 @@ PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' API_TOKEN=%s ENABLE_AUDIT_LOG=true -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) // Save to config directory (usually /etc/pulse) os.MkdirAll(r.config.ConfigPath, 0755) @@ -272,7 +271,7 @@ Environment="PULSE_AUTH_USER=%s" Environment="PULSE_AUTH_PASS=%s" Environment="API_TOKEN=%s" Environment="ENABLE_AUDIT_LOG=true" -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil { log.Error().Err(err).Msg("Failed to write systemd override") @@ -310,7 +309,7 @@ PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' API_TOKEN=%s ENABLE_AUDIT_LOG=true -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) // Try to create directory if needed os.MkdirAll(filepath.Dir(envPath), 0755) @@ -360,19 +359,21 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques return } - // Generate new token (24 bytes = 48 hex chars, not 64 to avoid hash detection issue) - tokenBytes := make([]byte, 24) - if _, err := rand.Read(tokenBytes); err != nil { - log.Error().Err(err).Msg("Failed to generate random token") + // Generate new token using the auth package + rawToken, err := internalauth.GenerateAPIToken() + if err != nil { + log.Error().Err(err).Msg("Failed to generate API token") http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } - newToken := hex.EncodeToString(tokenBytes) - // Update runtime config immediately - no restart needed! - r.config.APIToken = newToken + // Hash the token for storage + hashedToken := internalauth.HashAPIToken(rawToken) + + // Update runtime config immediately with hashed token - no restart needed! + r.config.APIToken = hashedToken r.config.APITokenEnabled = true - log.Info().Msg("Runtime config updated with new API token - active immediately") + log.Info().Msg("Runtime config updated with new hashed API token - active immediately") // Determine env file path envPath := filepath.Join(r.config.ConfigPath, ".env") @@ -393,12 +394,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques return } - // Update the API_TOKEN line + // Update the API_TOKEN line with the hashed token lines := strings.Split(string(content), "\n") var updated bool for i, line := range lines { if strings.HasPrefix(line, "API_TOKEN=") { - lines[i] = fmt.Sprintf("API_TOKEN=%s", newToken) + lines[i] = fmt.Sprintf("API_TOKEN=%s", hashedToken) updated = true break } @@ -406,7 +407,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques if !updated { // API_TOKEN line not found, add it - lines = append(lines, fmt.Sprintf("API_TOKEN=%s", newToken)) + lines = append(lines, fmt.Sprintf("API_TOKEN=%s", hashedToken)) } // Write updated content back @@ -424,10 +425,10 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques response := map[string]interface{}{ "success": true, - "token": newToken, + "token": rawToken, // Return the raw token to the user (only shown once!) "deploymentType": deploymentType, "requiresRestart": false, - "message": "New API token generated and active immediately!", + "message": "New API token generated and active immediately! Save this token - it won't be shown again.", } w.Header().Set("Content-Type", "application/json") diff --git a/internal/config/config.go b/internal/config/config.go index f6d86cc81..61696f304 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ import ( "time" "github.com/joho/godotenv" + "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -322,6 +323,13 @@ func Load() (*Config, error) { if apiToken := os.Getenv("API_TOKEN"); apiToken != "" { cfg.APIToken = apiToken log.Debug().Msg("Loaded API token from env var") + + // Check if token needs migration from plain text to hashed + if apiToken != "" && !auth.IsAPITokenHashed(apiToken) { + log.Warn().Msg("Detected plain text API token - please regenerate for better security") + // We don't auto-migrate here because we can't update the .env file from here safely + // The user should regenerate through the UI or API + } } // Check if API token is enabled if apiTokenEnabled := os.Getenv("API_TOKEN_ENABLED"); apiTokenEnabled != "" {