Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
285
pca-platform/heuristic-service/internal/api/handlers.go
Normal file
285
pca-platform/heuristic-service/internal/api/handlers.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/heuristics"
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/stepup"
|
||||
)
|
||||
|
||||
// Handler holds all API handlers
|
||||
type Handler struct {
|
||||
config *config.Config
|
||||
scorer *heuristics.Scorer
|
||||
webauthn *stepup.WebAuthnService
|
||||
pow *stepup.PoWService
|
||||
}
|
||||
|
||||
// NewHandler creates a new API handler
|
||||
func NewHandler(cfg *config.Config) *Handler {
|
||||
return &Handler{
|
||||
config: cfg,
|
||||
scorer: heuristics.NewScorer(cfg),
|
||||
webauthn: stepup.NewWebAuthnService(&cfg.StepUp.WebAuthn),
|
||||
pow: stepup.NewPoWService(&cfg.StepUp.PoW),
|
||||
}
|
||||
}
|
||||
|
||||
// TickRequest represents metrics sent from client SDK
|
||||
type TickRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
DwellRatio float64 `json:"dwell_ratio"`
|
||||
ScrollDepth float64 `json:"scroll_depth"`
|
||||
Clicks int `json:"clicks"`
|
||||
MouseMoves int `json:"mouse_moves"`
|
||||
KeyStrokes int `json:"key_strokes,omitempty"`
|
||||
TouchEvents int `json:"touch_events,omitempty"`
|
||||
MouseVelocities []float64 `json:"mouse_velocities,omitempty"`
|
||||
ScrollVelocities []float64 `json:"scroll_velocities,omitempty"`
|
||||
ClickIntervals []float64 `json:"click_intervals,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// TickResponse returns the computed score and action
|
||||
type TickResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Score float64 `json:"score"`
|
||||
Action string `json:"action"`
|
||||
StepUpMethod string `json:"step_up_method,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// HandleTick receives tick data from client SDK
|
||||
func (h *Handler) HandleTick(c *gin.Context) {
|
||||
var req TickRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate session ID if not provided
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
session := h.scorer.GetOrCreateSession(req.SessionID)
|
||||
|
||||
// Update metrics
|
||||
totalTime := time.Since(session.StartTime).Seconds()
|
||||
session.VisibleTime = req.DwellRatio * totalTime
|
||||
session.MaxScrollPercent = req.ScrollDepth / 100.0 // Convert from percentage
|
||||
session.ClickCount = req.Clicks
|
||||
session.MouseMoves = req.MouseMoves
|
||||
session.KeyStrokes = req.KeyStrokes
|
||||
session.TouchEvents = req.TouchEvents
|
||||
|
||||
if len(req.MouseVelocities) > 0 {
|
||||
session.MouseVelocities = append(session.MouseVelocities, req.MouseVelocities...)
|
||||
}
|
||||
if len(req.ScrollVelocities) > 0 {
|
||||
session.ScrollVelocities = append(session.ScrollVelocities, req.ScrollVelocities...)
|
||||
}
|
||||
if len(req.ClickIntervals) > 0 {
|
||||
session.ClickIntervals = append(session.ClickIntervals, req.ClickIntervals...)
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
score := h.scorer.CalculateScore(req.SessionID)
|
||||
|
||||
// Determine action
|
||||
var action, stepUpMethod, message string
|
||||
if score >= h.config.Thresholds.ScorePass {
|
||||
action = "allow"
|
||||
message = "Human behavior detected"
|
||||
} else if score >= h.config.Thresholds.ScoreChallenge {
|
||||
action = "allow"
|
||||
message = "Acceptable behavior"
|
||||
} else {
|
||||
action = "challenge"
|
||||
stepUpMethod = h.config.StepUp.Primary
|
||||
message = "Additional verification required"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TickResponse{
|
||||
SessionID: req.SessionID,
|
||||
Score: score,
|
||||
Action: action,
|
||||
StepUpMethod: stepUpMethod,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleEvaluate evaluates a session for a specific path
|
||||
func (h *Handler) HandleEvaluate(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
path := c.Query("path")
|
||||
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
path = "default"
|
||||
}
|
||||
|
||||
// TODO: Load path configs from ai-access.json
|
||||
pathConfigs := map[string]config.PathConfig{}
|
||||
|
||||
result := h.scorer.EvaluateRequest(sessionID, path, pathConfigs)
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// HandleWebAuthnChallenge creates a WebAuthn challenge
|
||||
func (h *Handler) HandleWebAuthnChallenge(c *gin.Context) {
|
||||
if !h.webauthn.IsEnabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := c.Query("session_id")
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
challenge, err := h.webauthn.CreateChallenge(sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
// HandleWebAuthnVerify verifies a WebAuthn assertion
|
||||
func (h *Handler) HandleWebAuthnVerify(c *gin.Context) {
|
||||
if !h.webauthn.IsEnabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var req stepup.VerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
verified, err := h.webauthn.VerifyChallenge(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"verified": verified,
|
||||
"session_id": req.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
// HandlePoWChallenge creates a Proof-of-Work challenge
|
||||
func (h *Handler) HandlePoWChallenge(c *gin.Context) {
|
||||
if !h.pow.IsEnabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := c.Query("session_id")
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
challenge, err := h.pow.CreateChallenge(sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
// HandlePoWVerify verifies a Proof-of-Work solution
|
||||
func (h *Handler) HandlePoWVerify(c *gin.Context) {
|
||||
if !h.pow.IsEnabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var req stepup.PoWVerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
verified, err := h.pow.VerifyChallenge(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"verified": verified,
|
||||
"session_id": req.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetConfig returns client-safe configuration
|
||||
func (h *Handler) HandleGetConfig(c *gin.Context) {
|
||||
// Return only non-sensitive config for client SDK
|
||||
clientConfig := gin.H{
|
||||
"thresholds": h.config.Thresholds,
|
||||
"weights": h.config.Weights,
|
||||
"tick": gin.H{
|
||||
"endpoint": h.config.Tick.Endpoint,
|
||||
"interval_ms": h.config.Tick.IntervalMs,
|
||||
},
|
||||
"step_up": gin.H{
|
||||
"methods": h.config.StepUp.Methods,
|
||||
"primary": h.config.StepUp.Primary,
|
||||
"webauthn": gin.H{
|
||||
"enabled": h.config.StepUp.WebAuthn.Enabled,
|
||||
"userVerification": h.config.StepUp.WebAuthn.UserVerification,
|
||||
"timeout_ms": h.config.StepUp.WebAuthn.TimeoutMs,
|
||||
"challenge_endpoint": h.config.StepUp.WebAuthn.ChallengeEndpoint,
|
||||
},
|
||||
"pow": gin.H{
|
||||
"enabled": h.config.StepUp.PoW.Enabled,
|
||||
"difficulty": h.config.StepUp.PoW.Difficulty,
|
||||
"max_duration_ms": h.config.StepUp.PoW.MaxDurationMs,
|
||||
},
|
||||
},
|
||||
"compliance": h.config.Compliance,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientConfig)
|
||||
}
|
||||
|
||||
// HandleHealth returns service health
|
||||
func (h *Handler) HandleHealth(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"service": "pca-heuristic-service",
|
||||
"version": "0.1.0",
|
||||
})
|
||||
}
|
||||
|
||||
// StartCleanupRoutine starts background cleanup
|
||||
func (h *Handler) StartCleanupRoutine() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
h.scorer.CleanupOldSessions(30 * time.Minute)
|
||||
h.webauthn.CleanupExpiredChallenges()
|
||||
h.pow.CleanupExpiredChallenges()
|
||||
}
|
||||
}()
|
||||
}
|
||||
151
pca-platform/heuristic-service/internal/config/config.go
Normal file
151
pca-platform/heuristic-service/internal/config/config.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds the heuristic service configuration
|
||||
type Config struct {
|
||||
Port string `json:"port"`
|
||||
RedisURL string `json:"redis_url"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
|
||||
// Heuristic thresholds
|
||||
Thresholds ThresholdConfig `json:"thresholds"`
|
||||
|
||||
// Heuristic weights
|
||||
Weights WeightConfig `json:"weights"`
|
||||
|
||||
// Step-up configuration
|
||||
StepUp StepUpConfig `json:"step_up"`
|
||||
|
||||
// Tick configuration
|
||||
Tick TickConfig `json:"tick"`
|
||||
|
||||
// Compliance settings
|
||||
Compliance ComplianceConfig `json:"compliance"`
|
||||
}
|
||||
|
||||
// ThresholdConfig defines score thresholds
|
||||
type ThresholdConfig struct {
|
||||
ScorePass float64 `json:"score_pass"` // Score to pass without step-up (e.g., 0.7)
|
||||
ScoreChallenge float64 `json:"score_challenge"` // Score below which step-up is required (e.g., 0.4)
|
||||
}
|
||||
|
||||
// WeightConfig defines weights for each heuristic
|
||||
type WeightConfig struct {
|
||||
DwellRatio float64 `json:"dwell_ratio"` // Weight for dwell time ratio
|
||||
ScrollScore float64 `json:"scroll_score"` // Weight for scroll depth
|
||||
PointerVariance float64 `json:"pointer_variance"` // Weight for mouse movement patterns
|
||||
ClickRate float64 `json:"click_rate"` // Weight for click interactions
|
||||
}
|
||||
|
||||
// StepUpConfig defines step-up verification methods
|
||||
type StepUpConfig struct {
|
||||
Methods []string `json:"methods"` // ["webauthn", "pow"]
|
||||
Primary string `json:"primary"` // Preferred method
|
||||
WebAuthn WebAuthnConfig `json:"webauthn"`
|
||||
PoW PoWConfig `json:"pow"`
|
||||
}
|
||||
|
||||
// WebAuthnConfig for WebAuthn step-up
|
||||
type WebAuthnConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UserVerification string `json:"userVerification"` // "preferred", "required", "discouraged"
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
ChallengeEndpoint string `json:"challenge_endpoint"`
|
||||
}
|
||||
|
||||
// PoWConfig for Proof-of-Work step-up
|
||||
type PoWConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Difficulty int `json:"difficulty"` // Number of leading zero bits required
|
||||
MaxDurationMs int `json:"max_duration_ms"` // Max time for PoW computation
|
||||
}
|
||||
|
||||
// TickConfig for periodic tick submissions
|
||||
type TickConfig struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
IntervalMs int `json:"interval_ms"`
|
||||
}
|
||||
|
||||
// ComplianceConfig for privacy compliance
|
||||
type ComplianceConfig struct {
|
||||
GDPR bool `json:"gdpr"`
|
||||
AnonymizeIP bool `json:"anonymize_ip"`
|
||||
NoCookies bool `json:"no_cookies"`
|
||||
NoPII bool `json:"no_pii"`
|
||||
}
|
||||
|
||||
// PathConfig for path-specific rules
|
||||
type PathConfig struct {
|
||||
MinScore float64 `json:"min_score"`
|
||||
StepUpMethod *string `json:"step_up_method"` // nil means no step-up
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default configuration
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8085"),
|
||||
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "pca-secret-change-me"),
|
||||
Thresholds: ThresholdConfig{
|
||||
ScorePass: 0.7,
|
||||
ScoreChallenge: 0.4,
|
||||
},
|
||||
Weights: WeightConfig{
|
||||
DwellRatio: 0.30,
|
||||
ScrollScore: 0.25,
|
||||
PointerVariance: 0.20,
|
||||
ClickRate: 0.25,
|
||||
},
|
||||
StepUp: StepUpConfig{
|
||||
Methods: []string{"webauthn", "pow"},
|
||||
Primary: "webauthn",
|
||||
WebAuthn: WebAuthnConfig{
|
||||
Enabled: true,
|
||||
UserVerification: "preferred",
|
||||
TimeoutMs: 60000,
|
||||
ChallengeEndpoint: "/pca/v1/webauthn-challenge",
|
||||
},
|
||||
PoW: PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
},
|
||||
},
|
||||
Tick: TickConfig{
|
||||
Endpoint: "/pca/v1/tick",
|
||||
IntervalMs: 5000,
|
||||
},
|
||||
Compliance: ComplianceConfig{
|
||||
GDPR: true,
|
||||
AnonymizeIP: true,
|
||||
NoCookies: true,
|
||||
NoPII: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromFile loads configuration from a JSON file
|
||||
func LoadFromFile(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return DefaultConfig(), nil // Return default if file not found
|
||||
}
|
||||
|
||||
config := DefaultConfig()
|
||||
if err := json.Unmarshal(data, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
340
pca-platform/heuristic-service/internal/heuristics/scorer.go
Normal file
340
pca-platform/heuristic-service/internal/heuristics/scorer.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package heuristics
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
// SessionMetrics holds behavioral metrics for a session
|
||||
type SessionMetrics struct {
|
||||
SessionID string `json:"session_id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
VisibleTime float64 `json:"visible_time"` // Seconds visible
|
||||
LastVisibleTS time.Time `json:"last_visible_ts"` // Last visibility timestamp
|
||||
MaxScrollPercent float64 `json:"max_scroll_percent"` // 0-1 scroll depth
|
||||
ClickCount int `json:"click_count"`
|
||||
MouseMoves int `json:"mouse_moves"`
|
||||
KeyStrokes int `json:"key_strokes"`
|
||||
TouchEvents int `json:"touch_events"`
|
||||
|
||||
// Advanced metrics
|
||||
MouseVelocities []float64 `json:"mouse_velocities,omitempty"` // For variance calculation
|
||||
ScrollVelocities []float64 `json:"scroll_velocities,omitempty"` // Scroll speed patterns
|
||||
ClickIntervals []float64 `json:"click_intervals,omitempty"` // Time between clicks
|
||||
|
||||
// Computed score
|
||||
LastScore float64 `json:"last_score"`
|
||||
LastScoreTime time.Time `json:"last_score_time"`
|
||||
}
|
||||
|
||||
// Scorer calculates human-likelihood scores based on behavioral heuristics
|
||||
type Scorer struct {
|
||||
config *config.Config
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*SessionMetrics
|
||||
}
|
||||
|
||||
// NewScorer creates a new heuristic scorer
|
||||
func NewScorer(cfg *config.Config) *Scorer {
|
||||
return &Scorer{
|
||||
config: cfg,
|
||||
sessions: make(map[string]*SessionMetrics),
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCreateSession retrieves or creates a session
|
||||
func (s *Scorer) GetOrCreateSession(sessionID string) *SessionMetrics {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if session, exists := s.sessions[sessionID]; exists {
|
||||
return session
|
||||
}
|
||||
|
||||
session := &SessionMetrics{
|
||||
SessionID: sessionID,
|
||||
StartTime: time.Now(),
|
||||
LastVisibleTS: time.Now(),
|
||||
}
|
||||
s.sessions[sessionID] = session
|
||||
return session
|
||||
}
|
||||
|
||||
// UpdateMetrics updates session metrics from a tick
|
||||
func (s *Scorer) UpdateMetrics(sessionID string, metrics *SessionMetrics) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if existing, exists := s.sessions[sessionID]; exists {
|
||||
// Merge metrics
|
||||
existing.VisibleTime = metrics.VisibleTime
|
||||
existing.MaxScrollPercent = metrics.MaxScrollPercent
|
||||
existing.ClickCount = metrics.ClickCount
|
||||
existing.MouseMoves = metrics.MouseMoves
|
||||
existing.KeyStrokes = metrics.KeyStrokes
|
||||
existing.TouchEvents = metrics.TouchEvents
|
||||
|
||||
if len(metrics.MouseVelocities) > 0 {
|
||||
existing.MouseVelocities = append(existing.MouseVelocities, metrics.MouseVelocities...)
|
||||
}
|
||||
if len(metrics.ScrollVelocities) > 0 {
|
||||
existing.ScrollVelocities = append(existing.ScrollVelocities, metrics.ScrollVelocities...)
|
||||
}
|
||||
if len(metrics.ClickIntervals) > 0 {
|
||||
existing.ClickIntervals = append(existing.ClickIntervals, metrics.ClickIntervals...)
|
||||
}
|
||||
} else {
|
||||
s.sessions[sessionID] = metrics
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateScore computes the human-likelihood score for a session
|
||||
func (s *Scorer) CalculateScore(sessionID string) float64 {
|
||||
s.mu.RLock()
|
||||
session, exists := s.sessions[sessionID]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return 0.0
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
weights := s.config.Weights
|
||||
|
||||
// Calculate individual heuristic scores (0-1)
|
||||
dwellScore := s.calculateDwellScore(session)
|
||||
scrollScore := s.calculateScrollScore(session)
|
||||
pointerScore := s.calculatePointerScore(session)
|
||||
clickScore := s.calculateClickScore(session)
|
||||
|
||||
// Weighted sum
|
||||
totalScore := dwellScore*weights.DwellRatio +
|
||||
scrollScore*weights.ScrollScore +
|
||||
pointerScore*weights.PointerVariance +
|
||||
clickScore*weights.ClickRate
|
||||
|
||||
// Clamp to [0, 1]
|
||||
if totalScore > 1.0 {
|
||||
totalScore = 1.0
|
||||
}
|
||||
if totalScore < 0.0 {
|
||||
totalScore = 0.0
|
||||
}
|
||||
|
||||
// Update session with score
|
||||
s.mu.Lock()
|
||||
session.LastScore = totalScore
|
||||
session.LastScoreTime = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
return totalScore
|
||||
}
|
||||
|
||||
// calculateDwellScore: visible time / total time ratio
|
||||
func (s *Scorer) calculateDwellScore(session *SessionMetrics) float64 {
|
||||
totalTime := time.Since(session.StartTime).Seconds()
|
||||
if totalTime <= 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Calculate visible time including current period if visible
|
||||
visibleTime := session.VisibleTime
|
||||
|
||||
ratio := visibleTime / totalTime
|
||||
if ratio > 1.0 {
|
||||
ratio = 1.0
|
||||
}
|
||||
|
||||
// Apply sigmoid to reward longer dwell times
|
||||
// A 30+ second dwell with high visibility is very human-like
|
||||
return sigmoid(ratio, 0.5, 10)
|
||||
}
|
||||
|
||||
// calculateScrollScore: scroll depth and natural patterns
|
||||
func (s *Scorer) calculateScrollScore(session *SessionMetrics) float64 {
|
||||
// Base score from scroll depth
|
||||
baseScore := session.MaxScrollPercent
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
// Bonus for natural scroll velocity patterns (humans have variable scroll speeds)
|
||||
if len(session.ScrollVelocities) > 2 {
|
||||
variance := calculateVariance(session.ScrollVelocities)
|
||||
// Too uniform = bot, some variance = human
|
||||
if variance > 0.01 && variance < 10.0 {
|
||||
baseScore *= 1.2 // Boost for natural variance
|
||||
}
|
||||
}
|
||||
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
return baseScore
|
||||
}
|
||||
|
||||
// calculatePointerScore: mouse movement patterns
|
||||
func (s *Scorer) calculatePointerScore(session *SessionMetrics) float64 {
|
||||
// Binary: has mouse activity at all
|
||||
if session.MouseMoves == 0 && session.TouchEvents == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
baseScore := 0.5 // Some activity
|
||||
|
||||
// Humans have variable mouse velocities
|
||||
if len(session.MouseVelocities) > 5 {
|
||||
variance := calculateVariance(session.MouseVelocities)
|
||||
// Bots often have either very uniform or very erratic movement
|
||||
if variance > 0.1 && variance < 100.0 {
|
||||
baseScore = 0.9 // Natural variance pattern
|
||||
} else if variance <= 0.1 {
|
||||
baseScore = 0.3 // Too uniform - suspicious
|
||||
} else {
|
||||
baseScore = 0.4 // Too erratic - also suspicious
|
||||
}
|
||||
}
|
||||
|
||||
// Boost for touch events (mobile users)
|
||||
if session.TouchEvents > 0 {
|
||||
baseScore += 0.2
|
||||
}
|
||||
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
return baseScore
|
||||
}
|
||||
|
||||
// calculateClickScore: click patterns
|
||||
func (s *Scorer) calculateClickScore(session *SessionMetrics) float64 {
|
||||
if session.ClickCount == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
totalTime := time.Since(session.StartTime).Seconds()
|
||||
if totalTime <= 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Clicks per second
|
||||
clickRate := float64(session.ClickCount) / totalTime
|
||||
|
||||
// Natural click rate is 0.1-2 clicks per second
|
||||
// Too fast = bot, none = no interaction
|
||||
var baseScore float64
|
||||
if clickRate > 0.05 && clickRate < 3.0 {
|
||||
baseScore = 0.8
|
||||
} else if clickRate >= 3.0 {
|
||||
baseScore = 0.2 // Suspiciously fast clicking
|
||||
} else {
|
||||
baseScore = 0.4
|
||||
}
|
||||
|
||||
// Check for natural intervals between clicks
|
||||
if len(session.ClickIntervals) > 2 {
|
||||
variance := calculateVariance(session.ClickIntervals)
|
||||
// Natural human timing has variance
|
||||
if variance > 0.01 {
|
||||
baseScore += 0.2
|
||||
}
|
||||
}
|
||||
|
||||
if baseScore > 1.0 {
|
||||
baseScore = 1.0
|
||||
}
|
||||
|
||||
return baseScore
|
||||
}
|
||||
|
||||
// EvaluateRequest determines action based on score
|
||||
func (s *Scorer) EvaluateRequest(sessionID string, path string, pathConfigs map[string]config.PathConfig) *EvaluationResult {
|
||||
score := s.CalculateScore(sessionID)
|
||||
|
||||
// Get path-specific config or use defaults
|
||||
minScore := s.config.Thresholds.ScoreChallenge
|
||||
var stepUpMethod *string
|
||||
|
||||
if cfg, exists := pathConfigs[path]; exists {
|
||||
minScore = cfg.MinScore
|
||||
stepUpMethod = cfg.StepUpMethod
|
||||
}
|
||||
|
||||
result := &EvaluationResult{
|
||||
SessionID: sessionID,
|
||||
Score: score,
|
||||
MinScore: minScore,
|
||||
Action: "allow",
|
||||
}
|
||||
|
||||
if score >= s.config.Thresholds.ScorePass {
|
||||
result.Action = "allow"
|
||||
} else if score >= minScore {
|
||||
result.Action = "allow" // In gray zone but above minimum
|
||||
} else {
|
||||
result.Action = "challenge"
|
||||
if stepUpMethod != nil {
|
||||
result.StepUpMethod = *stepUpMethod
|
||||
} else {
|
||||
result.StepUpMethod = s.config.StepUp.Primary
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// EvaluationResult contains the score evaluation outcome
|
||||
type EvaluationResult struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Score float64 `json:"score"`
|
||||
MinScore float64 `json:"min_score"`
|
||||
Action string `json:"action"` // "allow", "challenge", "block"
|
||||
StepUpMethod string `json:"step_up_method,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupOldSessions removes sessions older than maxAge
|
||||
func (s *Scorer) CleanupOldSessions(maxAge time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, session := range s.sessions {
|
||||
if now.Sub(session.StartTime) > maxAge {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func calculateVariance(values []float64) float64 {
|
||||
if len(values) < 2 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Calculate mean
|
||||
var sum float64
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
mean := sum / float64(len(values))
|
||||
|
||||
// Calculate variance
|
||||
var variance float64
|
||||
for _, v := range values {
|
||||
diff := v - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(values) - 1)
|
||||
|
||||
return variance
|
||||
}
|
||||
|
||||
// sigmoid applies a sigmoid transformation for smoother score curves
|
||||
func sigmoid(x, midpoint, steepness float64) float64 {
|
||||
return 1.0 / (1.0 + math.Exp(-steepness*(x-midpoint)))
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package heuristics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
func TestNewScorer(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
if scorer == nil {
|
||||
t.Fatal("Expected non-nil scorer")
|
||||
}
|
||||
if scorer.config == nil {
|
||||
t.Error("Expected config to be set")
|
||||
}
|
||||
if scorer.sessions == nil {
|
||||
t.Error("Expected sessions map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateSession(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// First call should create session
|
||||
session1 := scorer.GetOrCreateSession("test-session-1")
|
||||
if session1 == nil {
|
||||
t.Fatal("Expected non-nil session")
|
||||
}
|
||||
if session1.SessionID != "test-session-1" {
|
||||
t.Errorf("Expected session ID 'test-session-1', got '%s'", session1.SessionID)
|
||||
}
|
||||
|
||||
// Second call should return same session
|
||||
session2 := scorer.GetOrCreateSession("test-session-1")
|
||||
if session1 != session2 {
|
||||
t.Error("Expected same session instance on second call")
|
||||
}
|
||||
|
||||
// Different ID should create new session
|
||||
session3 := scorer.GetOrCreateSession("test-session-2")
|
||||
if session1 == session3 {
|
||||
t.Error("Expected different session for different ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_NewSession(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// New session with no activity should have low score
|
||||
scorer.GetOrCreateSession("test-new")
|
||||
score := scorer.CalculateScore("test-new")
|
||||
|
||||
if score < 0 || score > 1 {
|
||||
t.Errorf("Expected score between 0 and 1, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_HighActivity(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
session := scorer.GetOrCreateSession("test-active")
|
||||
session.StartTime = time.Now().Add(-30 * time.Second)
|
||||
session.VisibleTime = 28.0 // High visibility
|
||||
session.MaxScrollPercent = 0.8
|
||||
session.ClickCount = 10
|
||||
session.MouseMoves = 100
|
||||
session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90}
|
||||
session.ClickIntervals = []float64{1.5, 2.0, 1.2, 0.8}
|
||||
|
||||
score := scorer.CalculateScore("test-active")
|
||||
|
||||
// Active session should have higher score
|
||||
if score < 0.5 {
|
||||
t.Errorf("Expected score > 0.5 for active session, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_BotLikeActivity(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
session := scorer.GetOrCreateSession("test-bot")
|
||||
session.StartTime = time.Now().Add(-5 * time.Second)
|
||||
session.VisibleTime = 1.0 // Very short
|
||||
session.MaxScrollPercent = 0.0
|
||||
session.ClickCount = 0
|
||||
session.MouseMoves = 0
|
||||
|
||||
score := scorer.CalculateScore("test-bot")
|
||||
|
||||
// Bot-like session should have very low score
|
||||
if score > 0.3 {
|
||||
t.Errorf("Expected score < 0.3 for bot-like session, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore_UniformMouseMovement(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
session := scorer.GetOrCreateSession("test-uniform")
|
||||
session.StartTime = time.Now().Add(-20 * time.Second)
|
||||
session.VisibleTime = 18.0
|
||||
session.MouseMoves = 50
|
||||
// Very uniform velocities (suspicious)
|
||||
session.MouseVelocities = []float64{100, 100, 100, 100, 100, 100, 100, 100}
|
||||
|
||||
score := scorer.CalculateScore("test-uniform")
|
||||
|
||||
// Uniform movement should result in lower pointer score
|
||||
if score > 0.7 {
|
||||
t.Errorf("Expected score < 0.7 for uniform mouse movement, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRequest(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// High score session
|
||||
session := scorer.GetOrCreateSession("test-evaluate")
|
||||
session.StartTime = time.Now().Add(-60 * time.Second)
|
||||
session.VisibleTime = 55.0
|
||||
session.MaxScrollPercent = 0.9
|
||||
session.ClickCount = 15
|
||||
session.MouseMoves = 200
|
||||
session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90, 110}
|
||||
|
||||
result := scorer.EvaluateRequest("test-evaluate", "/default", nil)
|
||||
|
||||
if result.SessionID != "test-evaluate" {
|
||||
t.Errorf("Expected session ID 'test-evaluate', got '%s'", result.SessionID)
|
||||
}
|
||||
if result.Action != "allow" && result.Score >= cfg.Thresholds.ScorePass {
|
||||
t.Errorf("Expected 'allow' action for high score, got '%s'", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRequest_Challenge(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// Low score session
|
||||
scorer.GetOrCreateSession("test-challenge")
|
||||
|
||||
result := scorer.EvaluateRequest("test-challenge", "/api", nil)
|
||||
|
||||
if result.Action != "challenge" {
|
||||
t.Errorf("Expected 'challenge' action for new session, got '%s'", result.Action)
|
||||
}
|
||||
if result.StepUpMethod == "" {
|
||||
t.Error("Expected step-up method to be set for challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldSessions(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
scorer := NewScorer(cfg)
|
||||
|
||||
// Create some sessions
|
||||
scorer.GetOrCreateSession("session-new")
|
||||
|
||||
oldSession := scorer.GetOrCreateSession("session-old")
|
||||
oldSession.StartTime = time.Now().Add(-2 * time.Hour)
|
||||
|
||||
// Verify both exist
|
||||
if len(scorer.sessions) != 2 {
|
||||
t.Errorf("Expected 2 sessions, got %d", len(scorer.sessions))
|
||||
}
|
||||
|
||||
// Cleanup with 1 hour max age
|
||||
scorer.CleanupOldSessions(1 * time.Hour)
|
||||
|
||||
// Old session should be removed
|
||||
if len(scorer.sessions) != 1 {
|
||||
t.Errorf("Expected 1 session after cleanup, got %d", len(scorer.sessions))
|
||||
}
|
||||
|
||||
if _, exists := scorer.sessions["session-old"]; exists {
|
||||
t.Error("Expected old session to be cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateVariance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
values: []float64{},
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
values: []float64{5.0},
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "uniform values",
|
||||
values: []float64{5.0, 5.0, 5.0, 5.0},
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "varied values",
|
||||
values: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
|
||||
expected: 2.5, // Variance of [1,2,3,4,5]
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := calculateVariance(tt.values)
|
||||
if tt.expected == 0.0 && result != 0.0 {
|
||||
t.Errorf("Expected 0 variance, got %f", result)
|
||||
}
|
||||
if tt.expected != 0.0 && (result < tt.expected-0.1 || result > tt.expected+0.1) {
|
||||
t.Errorf("Expected variance ~%f, got %f", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigmoid(t *testing.T) {
|
||||
// Test sigmoid at midpoint
|
||||
result := sigmoid(0.5, 0.5, 10)
|
||||
if result < 0.49 || result > 0.51 {
|
||||
t.Errorf("Expected sigmoid(0.5, 0.5, 10) ~ 0.5, got %f", result)
|
||||
}
|
||||
|
||||
// Test sigmoid well above midpoint
|
||||
result = sigmoid(1.0, 0.5, 10)
|
||||
if result < 0.9 {
|
||||
t.Errorf("Expected sigmoid(1.0, 0.5, 10) > 0.9, got %f", result)
|
||||
}
|
||||
|
||||
// Test sigmoid well below midpoint
|
||||
result = sigmoid(0.0, 0.5, 10)
|
||||
if result > 0.1 {
|
||||
t.Errorf("Expected sigmoid(0.0, 0.5, 10) < 0.1, got %f", result)
|
||||
}
|
||||
}
|
||||
180
pca-platform/heuristic-service/internal/stepup/pow.go
Normal file
180
pca-platform/heuristic-service/internal/stepup/pow.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package stepup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
// PoWService handles Proof-of-Work challenges
|
||||
type PoWService struct {
|
||||
config *config.PoWConfig
|
||||
challenges map[string]*PoWChallenge
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// PoWChallenge represents a Proof-of-Work challenge
|
||||
type PoWChallenge struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Solved bool `json:"solved"`
|
||||
}
|
||||
|
||||
// PoWChallengeResponse is sent to the client
|
||||
type PoWChallengeResponse struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
MaxTimeMs int `json:"max_time_ms"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// PoWVerifyRequest for verifying a solved challenge
|
||||
type PoWVerifyRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
}
|
||||
|
||||
// NewPoWService creates a new Proof-of-Work service
|
||||
func NewPoWService(cfg *config.PoWConfig) *PoWService {
|
||||
return &PoWService{
|
||||
config: cfg,
|
||||
challenges: make(map[string]*PoWChallenge),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChallenge generates a new PoW challenge
|
||||
func (s *PoWService) CreateChallenge(sessionID string) (*PoWChallengeResponse, error) {
|
||||
// Generate random challenge
|
||||
challengeBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(challengeBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challengeStr := hex.EncodeToString(challengeBytes)
|
||||
|
||||
// Generate challenge ID
|
||||
idBytes := make([]byte, 8)
|
||||
rand.Read(idBytes)
|
||||
challengeID := hex.EncodeToString(idBytes)
|
||||
|
||||
// Create challenge
|
||||
challenge := &PoWChallenge{
|
||||
ID: challengeID,
|
||||
SessionID: sessionID,
|
||||
Challenge: challengeStr,
|
||||
Difficulty: s.config.Difficulty,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Duration(s.config.MaxDurationMs*2) * time.Millisecond),
|
||||
Solved: false,
|
||||
}
|
||||
|
||||
// Store challenge
|
||||
s.mu.Lock()
|
||||
s.challenges[challengeID] = challenge
|
||||
s.mu.Unlock()
|
||||
|
||||
// Build response
|
||||
prefix := strings.Repeat("0", s.config.Difficulty)
|
||||
response := &PoWChallengeResponse{
|
||||
ChallengeID: challengeID,
|
||||
Challenge: challengeStr,
|
||||
Difficulty: s.config.Difficulty,
|
||||
MaxTimeMs: s.config.MaxDurationMs,
|
||||
Hint: fmt.Sprintf("Find nonce where SHA256(challenge + nonce) starts with '%s'", prefix),
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// VerifyChallenge verifies a PoW solution
|
||||
func (s *PoWService) VerifyChallenge(req *PoWVerifyRequest) (bool, error) {
|
||||
s.mu.RLock()
|
||||
challenge, exists := s.challenges[req.ChallengeID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(challenge.ExpiresAt) {
|
||||
s.mu.Lock()
|
||||
delete(s.challenges, req.ChallengeID)
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check session match
|
||||
if challenge.SessionID != req.SessionID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check challenge string match
|
||||
if challenge.Challenge != req.Challenge {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify the proof of work
|
||||
input := fmt.Sprintf("%s%d", req.Challenge, req.Nonce)
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Check if hash has required number of leading zeros
|
||||
prefix := strings.Repeat("0", challenge.Difficulty)
|
||||
if !strings.HasPrefix(hashHex, prefix) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Mark as solved
|
||||
s.mu.Lock()
|
||||
challenge.Solved = true
|
||||
s.mu.Unlock()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyProof is a standalone verification without stored challenge
|
||||
// Useful for quick verification
|
||||
func (s *PoWService) VerifyProof(challenge string, nonce int64, difficulty int) bool {
|
||||
input := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
prefix := strings.Repeat("0", difficulty)
|
||||
return strings.HasPrefix(hashHex, prefix)
|
||||
}
|
||||
|
||||
// CleanupExpiredChallenges removes expired challenges
|
||||
func (s *PoWService) CleanupExpiredChallenges() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, challenge := range s.challenges {
|
||||
if now.After(challenge.ExpiresAt) {
|
||||
delete(s.challenges, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether PoW is enabled
|
||||
func (s *PoWService) IsEnabled() bool {
|
||||
return s.config.Enabled
|
||||
}
|
||||
|
||||
// GetDifficulty returns configured difficulty
|
||||
func (s *PoWService) GetDifficulty() int {
|
||||
return s.config.Difficulty
|
||||
}
|
||||
235
pca-platform/heuristic-service/internal/stepup/pow_test.go
Normal file
235
pca-platform/heuristic-service/internal/stepup/pow_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package stepup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
func TestNewPoWService(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Expected non-nil service")
|
||||
}
|
||||
if !service.IsEnabled() {
|
||||
t.Error("Expected service to be enabled")
|
||||
}
|
||||
if service.GetDifficulty() != 4 {
|
||||
t.Errorf("Expected difficulty 4, got %d", service.GetDifficulty())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChallenge(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
response, err := service.CreateChallenge("test-session")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if response == nil {
|
||||
t.Fatal("Expected non-nil response")
|
||||
}
|
||||
if response.Challenge == "" {
|
||||
t.Error("Expected non-empty challenge")
|
||||
}
|
||||
if response.ChallengeID == "" {
|
||||
t.Error("Expected non-empty challenge ID")
|
||||
}
|
||||
if response.Difficulty != 4 {
|
||||
t.Errorf("Expected difficulty 4, got %d", response.Difficulty)
|
||||
}
|
||||
if response.MaxTimeMs != 5000 {
|
||||
t.Errorf("Expected max time 5000, got %d", response.MaxTimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProof_Valid(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2, // Low difficulty for fast testing
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Find a valid nonce for a known challenge
|
||||
challenge := "test-challenge-123"
|
||||
var validNonce int64 = -1
|
||||
|
||||
// Brute force to find valid nonce (with low difficulty)
|
||||
for nonce := int64(0); nonce < 10000; nonce++ {
|
||||
if service.VerifyProof(challenge, nonce, 2) {
|
||||
validNonce = nonce
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if validNonce == -1 {
|
||||
t.Skip("Could not find valid nonce in reasonable time")
|
||||
}
|
||||
|
||||
// Verify the found nonce
|
||||
if !service.VerifyProof(challenge, validNonce, 2) {
|
||||
t.Errorf("Expected valid proof for nonce %d", validNonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProof_Invalid(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Nonce 0 is very unlikely to be valid for difficulty 4
|
||||
valid := service.VerifyProof("random-challenge", 0, 4)
|
||||
|
||||
if valid {
|
||||
t.Error("Expected invalid proof for nonce 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChallenge_ValidFlow(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 10000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Create challenge
|
||||
response, err := service.CreateChallenge("test-session")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create challenge: %v", err)
|
||||
}
|
||||
|
||||
// Find valid nonce
|
||||
var validNonce int64 = -1
|
||||
for nonce := int64(0); nonce < 100000; nonce++ {
|
||||
if service.VerifyProof(response.Challenge, nonce, 2) {
|
||||
validNonce = nonce
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if validNonce == -1 {
|
||||
t.Skip("Could not find valid nonce")
|
||||
}
|
||||
|
||||
// Verify challenge
|
||||
req := &PoWVerifyRequest{
|
||||
SessionID: "test-session",
|
||||
ChallengeID: response.ChallengeID,
|
||||
Challenge: response.Challenge,
|
||||
Nonce: validNonce,
|
||||
}
|
||||
|
||||
verified, err := service.VerifyChallenge(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Verification error: %v", err)
|
||||
}
|
||||
if !verified {
|
||||
t.Error("Expected verification to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChallenge_WrongSession(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Create challenge for session A
|
||||
response, _ := service.CreateChallenge("session-a")
|
||||
|
||||
// Try to verify with session B
|
||||
req := &PoWVerifyRequest{
|
||||
SessionID: "session-b",
|
||||
ChallengeID: response.ChallengeID,
|
||||
Challenge: response.Challenge,
|
||||
Nonce: 0,
|
||||
}
|
||||
|
||||
verified, _ := service.VerifyChallenge(req)
|
||||
if verified {
|
||||
t.Error("Expected verification to fail for wrong session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChallenge_NonexistentChallenge(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
req := &PoWVerifyRequest{
|
||||
SessionID: "test-session",
|
||||
ChallengeID: "nonexistent-challenge",
|
||||
Challenge: "test",
|
||||
Nonce: 0,
|
||||
}
|
||||
|
||||
verified, _ := service.VerifyChallenge(req)
|
||||
if verified {
|
||||
t.Error("Expected verification to fail for nonexistent challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupExpiredChallenges(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 1, // Very short for testing
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Create challenge
|
||||
service.CreateChallenge("test-session")
|
||||
|
||||
if len(service.challenges) != 1 {
|
||||
t.Errorf("Expected 1 challenge, got %d", len(service.challenges))
|
||||
}
|
||||
|
||||
// Wait for expiration
|
||||
// Note: In real test, we'd mock time or set ExpiresAt in the past
|
||||
|
||||
// For now, just verify cleanup doesn't crash
|
||||
service.CleanupExpiredChallenges()
|
||||
}
|
||||
|
||||
func TestIsEnabled(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: false,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
if service.IsEnabled() {
|
||||
t.Error("Expected service to be disabled")
|
||||
}
|
||||
}
|
||||
172
pca-platform/heuristic-service/internal/stepup/webauthn.go
Normal file
172
pca-platform/heuristic-service/internal/stepup/webauthn.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package stepup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
// WebAuthnService handles WebAuthn challenges and verification
|
||||
type WebAuthnService struct {
|
||||
config *config.WebAuthnConfig
|
||||
challenges map[string]*Challenge
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Challenge represents a WebAuthn challenge
|
||||
type Challenge struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// ChallengeRequest is the client-side challenge request format
|
||||
type ChallengeRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// ChallengeResponse is the WebAuthn public key request options
|
||||
type ChallengeResponse struct {
|
||||
PublicKey PublicKeyCredentialRequestOptions `json:"publicKey"`
|
||||
}
|
||||
|
||||
// PublicKeyCredentialRequestOptions mirrors the WebAuthn API structure
|
||||
type PublicKeyCredentialRequestOptions struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Timeout int `json:"timeout"`
|
||||
RpID string `json:"rpId,omitempty"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
AllowCredentials []PublicKeyCredentialDescriptor `json:"allowCredentials,omitempty"`
|
||||
}
|
||||
|
||||
// PublicKeyCredentialDescriptor for allowed credentials
|
||||
type PublicKeyCredentialDescriptor struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Transports []string `json:"transports,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyRequest for client verification response
|
||||
type VerifyRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Credential map[string]interface{} `json:"credential"`
|
||||
}
|
||||
|
||||
// NewWebAuthnService creates a new WebAuthn service
|
||||
func NewWebAuthnService(cfg *config.WebAuthnConfig) *WebAuthnService {
|
||||
return &WebAuthnService{
|
||||
config: cfg,
|
||||
challenges: make(map[string]*Challenge),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChallenge generates a new WebAuthn challenge for a session
|
||||
func (s *WebAuthnService) CreateChallenge(sessionID string) (*ChallengeResponse, error) {
|
||||
// Generate random challenge bytes
|
||||
challengeBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(challengeBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challengeStr := base64.RawURLEncoding.EncodeToString(challengeBytes)
|
||||
|
||||
// Generate challenge ID
|
||||
idBytes := make([]byte, 16)
|
||||
rand.Read(idBytes)
|
||||
challengeID := base64.RawURLEncoding.EncodeToString(idBytes)
|
||||
|
||||
// Create challenge
|
||||
challenge := &Challenge{
|
||||
ID: challengeID,
|
||||
SessionID: sessionID,
|
||||
Challenge: challengeStr,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Duration(s.config.TimeoutMs) * time.Millisecond),
|
||||
Verified: false,
|
||||
}
|
||||
|
||||
// Store challenge
|
||||
s.mu.Lock()
|
||||
s.challenges[challengeID] = challenge
|
||||
s.mu.Unlock()
|
||||
|
||||
// Build response
|
||||
response := &ChallengeResponse{
|
||||
PublicKey: PublicKeyCredentialRequestOptions{
|
||||
Challenge: challengeStr,
|
||||
Timeout: s.config.TimeoutMs,
|
||||
UserVerification: s.config.UserVerification,
|
||||
// In production, you'd include allowed credentials from user registration
|
||||
AllowCredentials: []PublicKeyCredentialDescriptor{},
|
||||
},
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// VerifyChallenge verifies a WebAuthn assertion response
|
||||
func (s *WebAuthnService) VerifyChallenge(req *VerifyRequest) (bool, error) {
|
||||
s.mu.RLock()
|
||||
challenge, exists := s.challenges[req.ChallengeID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(challenge.ExpiresAt) {
|
||||
s.mu.Lock()
|
||||
delete(s.challenges, req.ChallengeID)
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check session match
|
||||
if challenge.SessionID != req.SessionID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// In production, you would:
|
||||
// 1. Parse the credential response
|
||||
// 2. Verify the signature against stored public key
|
||||
// 3. Verify the challenge matches
|
||||
// 4. Check the origin
|
||||
// For MVP, we accept any valid-looking response
|
||||
|
||||
// Verify credential structure exists
|
||||
if req.Credential == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Mark as verified
|
||||
s.mu.Lock()
|
||||
challenge.Verified = true
|
||||
s.mu.Unlock()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CleanupExpiredChallenges removes expired challenges
|
||||
func (s *WebAuthnService) CleanupExpiredChallenges() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, challenge := range s.challenges {
|
||||
if now.After(challenge.ExpiresAt) {
|
||||
delete(s.challenges, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether WebAuthn is enabled
|
||||
func (s *WebAuthnService) IsEnabled() bool {
|
||||
return s.config.Enabled
|
||||
}
|
||||
Reference in New Issue
Block a user