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:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View 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()
}
}()
}

View 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
}

View 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)))
}

View File

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

View 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
}

View 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")
}
}

View 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
}