diff --git a/server/cmd/api/api/computer.go b/server/cmd/api/api/computer.go index f0b2c1c2..a7c4a3c3 100644 --- a/server/cmd/api/api/computer.go +++ b/server/cmd/api/api/computer.go @@ -116,7 +116,7 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo // When duration_ms is specified, compute the number of trajectory points // to achieve that duration at a ~10ms step delay (human-like event frequency). // Otherwise let the library auto-compute from path length. - const defaultStepDelayMs = 10 + const defaultStepDelayMs = 20 var opts *mousetrajectory.Options if body.DurationMs != nil { targetPoints := *body.DurationMs / defaultStepDelayMs @@ -173,7 +173,10 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo }() } - // Move along Bezier path: mousemove_relative for each step with delay + // Move along Bezier path: mousemove_relative for each step with delay. + // Use Gaussian-distributed delays so that inter-event timing has natural + // variance matching real human motor noise, rather than near-constant + // intervals that fingerprinting systems can distinguish from humans. for i := 1; i < len(points); i++ { select { case <-ctx.Done(): @@ -190,14 +193,8 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo return &executionError{msg: "failed during smooth mouse movement"} } } - jitter := stepDelayMs - if stepDelayMs > 3 { - jitter = stepDelayMs + rand.Intn(5) - 2 - if jitter < 3 { - jitter = 3 - } - } - if err := sleepWithContext(ctx, time.Duration(jitter)*time.Millisecond); err != nil { + delay := gaussianDelay(stepDelayMs, 3) + if err := sleepWithContext(ctx, time.Duration(delay)*time.Millisecond); err != nil { return &executionError{msg: "mouse movement cancelled"} } } @@ -1233,12 +1230,9 @@ func (s *ApiService) doDragMouseSmooth(ctx context.Context, log *slog.Logger, bo args = append(args, "mousemove_relative", "--", strconv.Itoa(dx), strconv.Itoa(dy)) if i < numSteps { - delay := smoothStepDelay(i, numSteps, baseDelayMs*2, baseDelayMs/2) - jitter := delay + rand.Intn(5) - 2 - if jitter < 3 { - jitter = 3 - } - args = append(args, "sleep", fmt.Sprintf("%.3f", float64(jitter)/1000.0)) + baseDelay := smoothStepDelay(i, numSteps, baseDelayMs*2, baseDelayMs/2) + delay := gaussianDelay(baseDelay, 3) + args = append(args, "sleep", fmt.Sprintf("%.3f", float64(delay)/1000.0)) } } @@ -1254,6 +1248,28 @@ func (s *ApiService) doDragMouseSmooth(ctx context.Context, log *slog.Logger, bo return nil } +// gaussianDelay returns a Gaussian-distributed delay centered on meanMs with +// stddev of 40% of meanMs, clamped to [minMs, 3*meanMs]. This produces timing +// variance that matches real human motor noise rather than the near-zero +// variance of uniform jitter. +func gaussianDelay(meanMs int, minMs int) int { + stddev := float64(meanMs) * 0.4 + u1 := rand.Float64() + u2 := rand.Float64() + if u1 <= 0 { + u1 = 1e-10 + } + z := math.Sqrt(-2*math.Log(u1)) * math.Cos(2*math.Pi*u2) + delay := int(math.Round(float64(meanMs) + stddev*z)) + if delay < minMs { + delay = minMs + } + if delay > meanMs*3 { + delay = meanMs * 3 + } + return delay +} + // smoothStepDelay maps position i/n through a smoothstep curve to produce // a delay in [fastMs, slowMs]. Slow at start and end, fast in the middle. // smoothstep(t) = 3t² - 2t³ diff --git a/server/cmd/api/api/computer_test.go b/server/cmd/api/api/computer_test.go index ce8ef360..af2cfd51 100644 --- a/server/cmd/api/api/computer_test.go +++ b/server/cmd/api/api/computer_test.go @@ -3,6 +3,8 @@ package api import ( "errors" "fmt" + "math" + "math/rand" "testing" "github.com/stretchr/testify/assert" @@ -168,6 +170,130 @@ func TestIsValidationErr_Nil(t *testing.T) { assert.False(t, isValidationErr(nil)) } +func TestGaussianDelay(t *testing.T) { + const n = 10000 + meanMs := 10 + + var sum, sumSq float64 + minVal, maxVal := math.MaxFloat64, -math.MaxFloat64 + + for i := 0; i < n; i++ { + d := float64(gaussianDelay(meanMs, 3)) + sum += d + sumSq += d * d + if d < minVal { + minVal = d + } + if d > maxVal { + maxVal = d + } + } + + avg := sum / n + variance := sumSq/n - avg*avg + + assert.InDelta(t, float64(meanMs), avg, float64(meanMs)*0.15, + "average delay should be near %dms, got %.1fms", meanMs, avg) + + assert.Greater(t, variance, 5.0, + "variance should be substantial for human-like timing, got %.1f", variance) + + assert.GreaterOrEqual(t, minVal, 3.0, "delay must not go below floor") + + assert.LessOrEqual(t, maxVal, float64(meanMs*3), "delay must not exceed 3x mean") +} + +func TestGaussianDelay_VarianceMuchHigherThanUniform(t *testing.T) { + const n = 5000 + meanMs := 10 + + var gSum, gSumSq float64 + for i := 0; i < n; i++ { + d := float64(gaussianDelay(meanMs, 3)) + gSum += d + gSumSq += d * d + } + gAvg := gSum / n + gVariance := gSumSq/n - gAvg*gAvg + + // Old uniform: meanMs + rand.Intn(5) - 2, variance of {-2,-1,0,1,2} = 2.0 + uniformVariance := 2.0 + + assert.Greater(t, gVariance, uniformVariance*3, + "Gaussian variance (%.1f) should be much larger than old uniform variance (%.1f)", + gVariance, uniformVariance) +} + +// welford implements Welford's online algorithm for computing running variance. +// This is the same algorithm used by browser fingerprinting systems to evaluate +// whether mouse movement timing looks human or automated. +type welford struct { + n int + mean float64 + m2 float64 +} + +func (w *welford) add(v float64) { + w.n++ + delta := v - w.mean + w.mean += delta / float64(w.n) + w.m2 += delta * (v - w.mean) +} + +func (w *welford) variance() float64 { + if w.n < 2 { + return 0 + } + return w.m2 / float64(w.n-1) +} + +func TestGaussianDelay_WelfordVelocityVariance(t *testing.T) { + // Simulate a mouse trajectory: 50 points with varying pixel distances + // (as produced by a Bezier curve), timed with gaussianDelay intervals. + // Compute velocity = distance / delay for each step and measure Welford + // variance. Human-like velocity variance should be well above 5. + const steps = 50 + meanDelayMs := 10 + + // Pixel distances per step typical of a Bezier curve across ~400px. + // Real trajectories vary: small moves near endpoints, larger in the middle. + rng := rand.New(rand.NewSource(42)) + distances := make([]float64, steps) + for i := range distances { + t_norm := float64(i) / float64(steps) + base := 5.0 + 15.0*math.Sin(t_norm*math.Pi) // 5-20px, peaked in middle + distances[i] = base + rng.Float64()*3.0 // small random variation + } + + // Gaussian delays → velocity variance + var gaussianVelVar welford + for i := 0; i < steps; i++ { + delay := float64(gaussianDelay(meanDelayMs, 3)) + velocity := distances[i] / delay + gaussianVelVar.add(velocity) + } + + // Uniform delays (old approach) → velocity variance + var uniformVelVar welford + for i := 0; i < steps; i++ { + delay := float64(meanDelayMs + rand.Intn(5) - 2) + if delay < 3 { + delay = 3 + } + velocity := distances[i] / delay + uniformVelVar.add(velocity) + } + + t.Logf("Gaussian velocity variance: %.4f (n=%d)", gaussianVelVar.variance(), gaussianVelVar.n) + t.Logf("Uniform velocity variance: %.4f (n=%d)", uniformVelVar.variance(), uniformVelVar.n) + + assert.Greater(t, gaussianVelVar.variance(), uniformVelVar.variance(), + "Gaussian timing should produce higher velocity variance than uniform") + + assert.Greater(t, gaussianVelVar.variance(), 0.05, + "Gaussian velocity variance should be well above near-zero") +} + func TestClampPoints(t *testing.T) { tests := []struct { name string diff --git a/server/lib/mousetrajectory/mousetrajectory.go b/server/lib/mousetrajectory/mousetrajectory.go index 75adeb56..d4d7988b 100644 --- a/server/lib/mousetrajectory/mousetrajectory.go +++ b/server/lib/mousetrajectory/mousetrajectory.go @@ -61,7 +61,7 @@ type MultiSegmentResult struct { StepDelayMs int } -const defaultStepDelayMs = 10 +const defaultStepDelayMs = 20 // GenerateMultiSegmentTrajectory creates a human-like Bezier trajectory through // a sequence of waypoints. Each consecutive pair gets its own Bezier curve, with