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

243
pca-platform/README.md Normal file
View File

@@ -0,0 +1,243 @@
# PCA Platform - Person-Corporate-Agent
Plattform zur Monetarisierung von KI-Crawler-Zugriffen und Human-vs-Bot-Erkennung.
## Übersicht
Die PCA Platform ermöglicht Website-Betreibern:
1. **Bot-Erkennung**: Unterscheidung zwischen Menschen und Bots durch Verhaltensheuristiken
2. **Step-Up-Verification**: WebAuthn oder Proof-of-Work für verdächtige Besucher
3. **Monetarisierung**: KI-Crawler können gegen Micropayment Zugriff erhalten (HTTP 402)
## Architektur
```
┌────────────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ Website │────▶│ PCA Heuristic │────▶│ Redis │
│ + PCA SDK │ │ Service │ │ Session Store │
└────────────────────┘ └────────────────────┘ └──────────────────┘
│ │
│ ▼
│ ┌────────────────────┐
│ │ Payment Gateway │ (Future)
│ │ HTTP 402 │
│ └────────────────────┘
┌────────────────────┐
│ ai-access.json │
│ Policy Config │
└────────────────────┘
```
## Komponenten
### 1. Heuristic Service (Go)
- Port: 8085
- Berechnet Human-Score basierend auf Verhaltensmetriken
- Verwaltet Step-Up-Verifikation (WebAuthn, PoW)
### 2. JavaScript SDK
- Sammelt Verhaltensmetriken (Scroll, Mouse, Clicks)
- Sendet Ticks an Backend
- Führt Step-Up bei Bedarf durch
### 3. ai-access.json
- Policy-Datei für Zugriffsregeln
- Definiert Preise pro Rolle/Bot
- Konfiguriert Schwellenwerte
## Quick Start
```bash
cd pca-platform
docker compose up -d
```
Services:
- Heuristic Service: http://localhost:8085
- Demo Site: http://localhost:8087
- Redis: localhost:6380
## API Endpoints
### Heuristic Service
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/health` | Health Check |
| GET | `/pca/v1/config` | Client Config |
| POST | `/pca/v1/tick` | Metrics empfangen |
| GET | `/pca/v1/evaluate` | Score auswerten |
| GET | `/pca/v1/webauthn-challenge` | WebAuthn Challenge |
| POST | `/pca/v1/webauthn-verify` | WebAuthn verifizieren |
| GET | `/pca/v1/pow-challenge` | PoW Challenge |
| POST | `/pca/v1/pow-verify` | PoW verifizieren |
### Tick Request
```json
{
"session_id": "pca_xxx",
"dwell_ratio": 0.85,
"scroll_depth": 45.0,
"clicks": 5,
"mouse_moves": 120,
"ts": 1702828800000
}
```
### Tick Response
```json
{
"session_id": "pca_xxx",
"score": 0.72,
"action": "allow",
"message": "Human behavior detected"
}
```
## ai-access.json Konfiguration
```json
{
"thresholds": {
"score_pass": 0.7,
"score_challenge": 0.4
},
"weights": {
"dwell_ratio": 0.30,
"scroll_score": 0.25,
"pointer_variance": 0.20,
"click_rate": 0.25
},
"step_up": {
"methods": ["webauthn", "pow"],
"primary": "webauthn"
},
"pca_roles": {
"Person": { "access": "allow", "price": null },
"Agent": { "access": "charge", "price": "0.001 EUR" }
}
}
```
## SDK Integration
### Vanilla JavaScript
```html
<script src="/sdk/pca-sdk.js"></script>
<script>
PCA.init({
tick: { endpoint: '/pca/v1/tick', interval_ms: 5000 }
});
PCA.onScoreUpdate((score, action) => {
if (action === 'challenge') {
PCA.triggerStepUp();
}
});
</script>
```
### React
```jsx
import { useEffect, useState } from 'react';
function ProtectedContent() {
const [verified, setVerified] = useState(false);
useEffect(() => {
PCA.init(config);
PCA.onScoreUpdate(async (score, action) => {
if (score >= 0.7) {
setVerified(true);
} else if (action === 'challenge') {
const success = await PCA.triggerStepUp();
if (success) setVerified(true);
}
});
}, []);
if (!verified) return <p>Verifying...</p>;
return <div>Protected Content</div>;
}
```
## Heuristiken
| Metrik | Gewicht | Beschreibung |
|--------|---------|--------------|
| `dwell_ratio` | 30% | Sichtbare Verweildauer / Gesamtzeit |
| `scroll_score` | 25% | Maximale Scrolltiefe (0-100%) |
| `pointer_variance` | 20% | Mausbewegungsmuster (Varianz) |
| `click_rate` | 25% | Klicks pro Sekunde + Intervall-Varianz |
### Score-Interpretation
| Score | Bedeutung | Aktion |
|-------|-----------|--------|
| ≥0.7 | Wahrscheinlich Mensch | Allow |
| 0.4-0.7 | Unsicher | Optional Challenge |
| <0.4 | Wahrscheinlich Bot | Challenge erforderlich |
## Step-Up Methoden
### WebAuthn
- Biometrische Authentifizierung (FaceID, TouchID)
- Hardware Security Keys
- Höchste Sicherheit
### Proof-of-Work
- Client löst SHA-256 Puzzle
- Kein User-Input nötig
- Bots werden gebremst
## GDPR Compliance
Die Plattform ist GDPR-konform:
- ✅ Keine personenbezogenen Daten
- ✅ Keine Cookies
- ✅ IP-Anonymisierung möglich
- ✅ Nur aggregierte Metriken
## Entwicklung
### Tests ausführen
```bash
cd heuristic-service
go test -v ./...
```
### Service lokal starten
```bash
cd heuristic-service
go run ./cmd/server
```
## Roadmap
- [ ] Payment Gateway (HTTP 402)
- [ ] Stablecoin Integration (USDC, EURC)
- [ ] Lightning Network Support
- [ ] Publisher Dashboard
- [ ] Agent SDK für KI-Crawler
- [ ] WordPress Plugin
- [ ] Nginx Module
## Integration mit BreakPilot
Die PCA Platform kann in BreakPilot integriert werden:
1. **Admin-Bereich schützen**: Bot-Schutz für Consent-Management
2. **API monetarisieren**: EduSearch-Daten gegen Zahlung verfügbar machen
3. **Legal Crawler**: Als zahlender Agent auf andere Seiten zugreifen
## Lizenz
MIT License - Kommerziell nutzbar

View File

@@ -0,0 +1,82 @@
{
"thresholds": {
"score_pass": 0.7,
"score_challenge": 0.4
},
"weights": {
"dwell_ratio": 0.30,
"scroll_score": 0.25,
"pointer_variance": 0.20,
"click_rate": 0.25
},
"step_up": {
"methods": ["webauthn", "pow"],
"primary": "webauthn",
"webauthn": {
"enabled": true,
"userVerification": "preferred",
"timeout_ms": 60000,
"challenge_endpoint": "/pca/v1/webauthn-challenge"
},
"pow": {
"enabled": true,
"difficulty": 4,
"max_duration_ms": 5000
}
},
"tick": {
"endpoint": "/pca/v1/tick",
"interval_ms": 5000
},
"paths": {
"/api/*": {
"min_score": 0.7,
"step_up_method": "webauthn"
},
"/admin/*": {
"min_score": 0.8,
"step_up_method": "webauthn"
},
"/public/*": {
"min_score": 0.0,
"step_up_method": null
},
"default": {
"min_score": 0.4,
"step_up_method": "pow"
}
},
"pca_roles": {
"Person": {
"description": "Verified human visitor",
"access": "allow",
"price": null
},
"Corporate": {
"description": "Verified business entity",
"access": "allow",
"price": null
},
"Agent": {
"description": "AI/Bot agent",
"access": "charge",
"price": {
"amount": "0.001",
"currency": "EUR",
"per": "request"
}
}
},
"payment": {
"enabled": true,
"methods": ["EURC", "USDC", "Lightning"],
"wallet_address": null,
"min_balance": "0.01"
},
"compliance": {
"gdpr": true,
"anonymize_ip": true,
"no_cookies": true,
"no_pii": true
}
}

View File

@@ -0,0 +1,444 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PCA Platform Demo - Human vs Bot Detection</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 200vh; /* Long page for scroll testing */
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
background: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
h1 {
color: #667eea;
margin-bottom: 0.5rem;
}
.subtitle {
color: #666;
font-size: 1.1rem;
}
.score-panel {
background: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
position: sticky;
top: 1rem;
z-index: 100;
}
.score-display {
display: flex;
align-items: center;
gap: 2rem;
}
.score-circle {
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: bold;
transition: all 0.3s ease;
}
.score-circle.low {
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
color: white;
}
.score-circle.medium {
background: linear-gradient(135deg, #feca57, #ff9f43);
color: white;
}
.score-circle.high {
background: linear-gradient(135deg, #1dd1a1, #10ac84);
color: white;
}
.score-value {
font-size: 2rem;
}
.score-label {
font-size: 0.8rem;
opacity: 0.9;
}
.score-details {
flex: 1;
}
.score-details h3 {
margin-bottom: 0.5rem;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.status-badge.allow {
background: #d4edda;
color: #155724;
}
.status-badge.challenge {
background: #fff3cd;
color: #856404;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.metric {
text-align: center;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 8px;
}
.metric-value {
font-size: 1.5rem;
font-weight: bold;
color: #667eea;
}
.metric-label {
font-size: 0.75rem;
color: #666;
}
.content-section {
background: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.content-section h2 {
color: #333;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #667eea;
}
.content-section p {
margin-bottom: 1rem;
}
.demo-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.btn-secondary {
background: #f8f9fa;
color: #333;
border: 1px solid #ddd;
}
.btn-warning {
background: linear-gradient(135deg, #feca57, #ff9f43);
color: white;
}
.protected-content {
background: #f8f9fa;
border: 2px dashed #ddd;
border-radius: 8px;
padding: 2rem;
text-align: center;
margin-top: 1rem;
}
.protected-content.unlocked {
border-color: #1dd1a1;
background: #d4edda;
}
.log-panel {
background: #1a1a2e;
color: #eee;
border-radius: 12px;
padding: 1rem;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
max-height: 300px;
overflow-y: auto;
}
.log-entry {
padding: 0.25rem 0;
border-bottom: 1px solid #333;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #667eea;
}
.log-score {
color: #1dd1a1;
}
footer {
text-align: center;
color: white;
padding: 2rem;
opacity: 0.8;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>PCA Platform Demo</h1>
<p class="subtitle">Person - Corporate - Agent | Human vs Bot Detection</p>
</header>
<div class="score-panel">
<div class="score-display">
<div class="score-circle low" id="scoreCircle">
<span class="score-value" id="scoreValue">0.00</span>
<span class="score-label">Human Score</span>
</div>
<div class="score-details">
<h3>Status: <span class="status-badge challenge" id="statusBadge">Initializing...</span></h3>
<p id="statusMessage">Collecting behavioral data...</p>
<div class="metrics-grid">
<div class="metric">
<div class="metric-value" id="metricDwell">0%</div>
<div class="metric-label">Dwell Time</div>
</div>
<div class="metric">
<div class="metric-value" id="metricScroll">0%</div>
<div class="metric-label">Scroll Depth</div>
</div>
<div class="metric">
<div class="metric-value" id="metricClicks">0</div>
<div class="metric-label">Clicks</div>
</div>
<div class="metric">
<div class="metric-value" id="metricMouse">0</div>
<div class="metric-label">Mouse Moves</div>
</div>
</div>
</div>
</div>
</div>
<section class="content-section">
<h2>How It Works</h2>
<p>
The PCA SDK analyzes your browsing behavior to distinguish humans from bots.
It tracks metrics like scroll depth, mouse movements, click patterns, and dwell time
- all without collecting personal information.
</p>
<p>
<strong>Scroll down</strong>, <strong>move your mouse</strong>, and <strong>click around</strong>
to increase your human score. Once you reach a score of 0.7+, you'll be recognized as human.
</p>
</section>
<section class="content-section">
<h2>Test the SDK</h2>
<div class="demo-buttons">
<button class="btn-primary" onclick="testEvaluate()">Evaluate Now</button>
<button class="btn-secondary" onclick="testTick()">Send Tick</button>
<button class="btn-warning" onclick="testStepUp()">Trigger Step-Up</button>
</div>
</section>
<section class="content-section">
<h2>Protected Content</h2>
<p>This content is protected and requires a human score of 0.7 or higher to access:</p>
<div class="protected-content" id="protectedContent">
<p>Content locked. Increase your score to unlock.</p>
</div>
</section>
<section class="content-section">
<h2>More Content (Scroll Test)</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
<p>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</section>
<section class="content-section">
<h2>Event Log</h2>
<div class="log-panel" id="logPanel">
<div class="log-entry"><span class="log-time">[--:--:--]</span> SDK initializing...</div>
</div>
</section>
<footer>
<p>PCA Platform v0.1.0 | GDPR Compliant | No PII Collected</p>
</footer>
</div>
<!-- PCA SDK -->
<script src="/sdk/pca-sdk.js"></script>
<script>
const logPanel = document.getElementById('logPanel');
const scoreCircle = document.getElementById('scoreCircle');
const scoreValue = document.getElementById('scoreValue');
const statusBadge = document.getElementById('statusBadge');
const statusMessage = document.getElementById('statusMessage');
const protectedContent = document.getElementById('protectedContent');
function log(message) {
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="log-time">[${time}]</span> ${message}`;
logPanel.appendChild(entry);
logPanel.scrollTop = logPanel.scrollHeight;
}
function updateUI(score, action, data) {
// Update score display
scoreValue.textContent = score.toFixed(2);
// Update score circle color
scoreCircle.className = 'score-circle';
if (score >= 0.7) {
scoreCircle.classList.add('high');
} else if (score >= 0.4) {
scoreCircle.classList.add('medium');
} else {
scoreCircle.classList.add('low');
}
// Update status badge
statusBadge.textContent = action === 'allow' ? 'Human Detected' : 'Verification Needed';
statusBadge.className = 'status-badge ' + action;
// Update status message
if (score >= 0.7) {
statusMessage.textContent = 'Congratulations! You are recognized as a human visitor.';
} else if (score >= 0.4) {
statusMessage.textContent = 'Almost there! Keep interacting with the page.';
} else {
statusMessage.textContent = 'Scroll, click, and move your mouse to prove you are human.';
}
// Update metrics
if (data && data.metrics) {
document.getElementById('metricDwell').textContent = Math.round(data.metrics.dwell_ratio * 100) + '%';
document.getElementById('metricScroll').textContent = Math.round(data.metrics.scroll_depth * 100) + '%';
document.getElementById('metricClicks').textContent = data.metrics.clicks;
document.getElementById('metricMouse').textContent = data.metrics.mouse_moves;
}
// Update protected content
if (score >= 0.7) {
protectedContent.className = 'protected-content unlocked';
protectedContent.innerHTML = `
<h3>Content Unlocked!</h3>
<p>This is the secret content that only humans can see.</p>
<p>Your session ID: ${PCA.getSessionId()}</p>
`;
}
log(`Score updated: <span class="log-score">${score.toFixed(3)}</span> | Action: ${action}`);
}
// Initialize SDK with custom config
const customConfig = {
thresholds: {
score_pass: 0.7,
score_challenge: 0.4
},
weights: {
dwell_ratio: 0.30,
scroll_score: 0.25,
pointer_variance: 0.20,
click_rate: 0.25
},
tick: {
endpoint: 'http://localhost:8085/pca/v1/tick',
interval_ms: 3000
},
step_up: {
methods: ['webauthn', 'pow'],
primary: 'pow',
webauthn: {
enabled: true,
challenge_endpoint: 'http://localhost:8085/pca/v1/webauthn-challenge'
},
pow: {
enabled: true,
difficulty: 4,
max_duration_ms: 5000
}
}
};
// Wait for SDK to load
if (typeof PCA !== 'undefined') {
PCA.init(customConfig);
log('SDK initialized with custom config');
// Subscribe to score updates
PCA.onScoreUpdate((score, action, data) => {
updateUI(score, action, PCA.evaluate());
});
// Update metrics every second
setInterval(() => {
const data = PCA.evaluate();
updateUI(data.score, data.score >= 0.7 ? 'allow' : 'challenge', data);
}, 1000);
} else {
log('Error: PCA SDK not loaded');
}
// Test functions
function testEvaluate() {
const result = PCA.evaluate();
log(`Manual evaluation: ${JSON.stringify(result)}`);
updateUI(result.score, result.score >= 0.7 ? 'allow' : 'challenge', result);
}
function testTick() {
PCA.tick();
log('Tick sent to server');
}
async function testStepUp() {
log('Starting step-up verification (PoW)...');
const success = await PCA.triggerStepUp();
if (success) {
log('Step-up verification successful!');
} else {
log('Step-up verification failed or cancelled');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,81 @@
version: '3.8'
services:
# Heuristic Service - Human vs Bot detection
heuristic-service:
build:
context: ./heuristic-service
dockerfile: Dockerfile
container_name: pca-heuristic-service
ports:
- "8085:8085"
environment:
- PORT=8085
- GIN_MODE=release
- CONFIG_PATH=/app/ai-access.json
- REDIS_URL=redis://redis:6379
volumes:
- ./ai-access.json:/app/ai-access.json:ro
depends_on:
- redis
networks:
- pca-network
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8085/health"]
interval: 30s
timeout: 10s
retries: 3
# Payment Gateway - HTTP 402 Handler (future)
# payment-gateway:
# build:
# context: ./payment-gateway
# dockerfile: Dockerfile
# container_name: pca-payment-gateway
# ports:
# - "8086:8086"
# environment:
# - PORT=8086
# - HEURISTIC_SERVICE_URL=http://heuristic-service:8085
# depends_on:
# - heuristic-service
# networks:
# - pca-network
# Redis for session storage
redis:
image: redis:7-alpine
container_name: pca-redis
ports:
- "6380:6379"
volumes:
- pca-redis-data:/data
networks:
- pca-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
# Demo website to test the SDK
demo-site:
image: nginx:alpine
container_name: pca-demo-site
ports:
- "8087:80"
volumes:
- ./demo:/usr/share/nginx/html:ro
- ./sdk/js/src:/usr/share/nginx/html/sdk:ro
- ./ai-access.json:/usr/share/nginx/html/ai-access.json:ro
depends_on:
- heuristic-service
networks:
- pca-network
networks:
pca-network:
driver: bridge
volumes:
pca-redis-data:

View File

@@ -0,0 +1,41 @@
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install dependencies
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod ./
# Initialize module and download dependencies
RUN go mod tidy || true
# Copy source code
COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -o /heuristic-service ./cmd/server
# Runtime stage
FROM alpine:3.19
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk add --no-cache ca-certificates wget
# Copy binary from builder
COPY --from=builder /heuristic-service /app/heuristic-service
# Expose port
EXPOSE 8085
# Set environment variables
ENV PORT=8085
ENV GIN_MODE=release
ENV CONFIG_PATH=/app/ai-access.json
# Run the service
CMD ["/app/heuristic-service"]

View File

@@ -0,0 +1,84 @@
package main
import (
"log"
"os"
"github.com/gin-gonic/gin"
"github.com/breakpilot/pca-platform/heuristic-service/internal/api"
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
)
func main() {
// Load configuration
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "ai-access.json"
}
cfg, err := config.LoadFromFile(configPath)
if err != nil {
log.Printf("Warning: Could not load config from %s, using defaults: %v", configPath, err)
cfg = config.DefaultConfig()
}
// Create handler
handler := api.NewHandler(cfg)
// Start cleanup routine
handler.StartCleanupRoutine()
// Setup Gin router
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
// Enable CORS
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-PCA-Session")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Health endpoint
r.GET("/health", handler.HandleHealth)
// PCA API v1
v1 := r.Group("/pca/v1")
{
// Configuration endpoint (for client SDK)
v1.GET("/config", handler.HandleGetConfig)
// Tick endpoint (receives behavioral metrics)
v1.POST("/tick", handler.HandleTick)
// Evaluation endpoint
v1.GET("/evaluate", handler.HandleEvaluate)
// WebAuthn step-up
v1.GET("/webauthn-challenge", handler.HandleWebAuthnChallenge)
v1.POST("/webauthn-verify", handler.HandleWebAuthnVerify)
// Proof-of-Work step-up
v1.GET("/pow-challenge", handler.HandlePoWChallenge)
v1.POST("/pow-verify", handler.HandlePoWVerify)
}
// Start server
port := cfg.Port
log.Printf("PCA Heuristic Service starting on port %s", port)
log.Printf("Thresholds: pass=%.2f, challenge=%.2f", cfg.Thresholds.ScorePass, cfg.Thresholds.ScoreChallenge)
log.Printf("Step-up methods: %v (primary: %s)", cfg.StepUp.Methods, cfg.StepUp.Primary)
if err := r.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

View File

@@ -0,0 +1,36 @@
module github.com/breakpilot/pca-platform/heuristic-service
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.5.0
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,89 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

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
}

View File

@@ -0,0 +1,473 @@
/**
* PCA SDK - Person-Corporate-Agent Human Detection SDK
*
* Collects behavioral metrics to distinguish humans from bots
* and handles step-up verification (WebAuthn, PoW) when needed.
*
* GDPR/Privacy compliant: No PII collected, only aggregated behavior metrics.
*/
const PCA = (() => {
// Internal state
let config = null;
let sessionId = null;
let metrics = {
startTime: Date.now(),
visibleTime: 0,
lastVisibleTS: Date.now(),
maxScrollPercent: 0,
clickCount: 0,
mouseMoves: 0,
keyStrokes: 0,
touchEvents: 0,
mouseVelocities: [],
scrollVelocities: [],
clickIntervals: [],
lastClickTime: 0,
lastMousePos: null,
lastMouseTime: 0,
lastScrollPos: 0,
lastScrollTime: 0
};
let currentScore = 0;
let tickTimer = null;
let isInitialized = false;
let scoreCallbacks = [];
// Generate unique session ID
function generateSessionId() {
return 'pca_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9);
}
// Calculate score based on current metrics
function evaluateScore() {
const now = Date.now();
const totalTime = (now - metrics.startTime) / 1000;
// Update visible time if page is visible
if (!document.hidden) {
metrics.visibleTime += (now - metrics.lastVisibleTS) / 1000;
metrics.lastVisibleTS = now;
}
// Heuristic 1: Dwell ratio (visible time / total time)
let dwellRatio = totalTime > 0 ? (metrics.visibleTime / totalTime) : 0;
if (dwellRatio > 1) dwellRatio = 1;
// Heuristic 2: Scroll score (max scroll depth 0-1)
let scrollScore = metrics.maxScrollPercent;
if (scrollScore > 1) scrollScore = 1;
// Heuristic 3: Pointer variance (mouse/touch activity)
let pointerScore = 0;
if (metrics.mouseMoves > 0 || metrics.touchEvents > 0) {
pointerScore = 0.5;
// Check for natural mouse velocity variance
if (metrics.mouseVelocities.length > 5) {
const variance = calculateVariance(metrics.mouseVelocities);
if (variance > 0.1 && variance < 100.0) {
pointerScore = 0.9; // Natural variance
} else if (variance <= 0.1) {
pointerScore = 0.3; // Too uniform - suspicious
}
}
if (metrics.touchEvents > 0) pointerScore += 0.2;
if (pointerScore > 1) pointerScore = 1;
}
// Heuristic 4: Click rate
let clickScore = 0;
if (metrics.clickCount > 0 && totalTime > 0) {
const clickRate = metrics.clickCount / totalTime;
if (clickRate > 0.05 && clickRate < 3.0) {
clickScore = 0.8;
} else if (clickRate >= 3.0) {
clickScore = 0.2; // Too fast
} else {
clickScore = 0.4;
}
// Natural click intervals
if (metrics.clickIntervals.length > 2) {
const variance = calculateVariance(metrics.clickIntervals);
if (variance > 0.01) clickScore += 0.2;
if (clickScore > 1) clickScore = 1;
}
}
// Weighted sum
const w = config?.weights || { dwell_ratio: 0.30, scroll_score: 0.25, pointer_variance: 0.20, click_rate: 0.25 };
currentScore =
dwellRatio * (w.dwell_ratio || 0) +
scrollScore * (w.scroll_score || 0) +
pointerScore * (w.pointer_variance || 0) +
clickScore * (w.click_rate || 0);
if (currentScore > 1) currentScore = 1;
if (currentScore < 0) currentScore = 0;
return currentScore;
}
// Calculate variance of an array
function calculateVariance(values) {
if (values.length < 2) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
return values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / (values.length - 1);
}
// Send tick to backend
async function sendTick() {
if (!config?.tick?.endpoint) return;
const now = Date.now();
const totalTime = (now - metrics.startTime) / 1000;
const payload = {
session_id: sessionId,
score: Number(currentScore.toFixed(3)),
dwell_ratio: Number((metrics.visibleTime / totalTime).toFixed(3)),
scroll_depth: Number((metrics.maxScrollPercent * 100).toFixed(1)),
clicks: metrics.clickCount,
mouse_moves: metrics.mouseMoves,
key_strokes: metrics.keyStrokes,
touch_events: metrics.touchEvents,
mouse_velocities: metrics.mouseVelocities.slice(-20), // Last 20 values
scroll_velocities: metrics.scrollVelocities.slice(-20),
click_intervals: metrics.clickIntervals.slice(-10),
ts: now
};
try {
const response = await fetch(config.tick.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
// Notify callbacks
scoreCallbacks.forEach(cb => cb(data.score, data.action, data));
}
} catch (err) {
console.warn('PCA: Tick transmission failed:', err);
}
}
// WebAuthn step-up
async function triggerWebAuthn() {
if (!config?.step_up?.webauthn?.enabled || !window.PublicKeyCredential) {
return false;
}
try {
// Get challenge from server
const challengeUrl = `${config.step_up.webauthn.challenge_endpoint}?session_id=${sessionId}`;
const challengeResp = await fetch(challengeUrl);
const challengeData = await challengeResp.json();
// Convert base64url challenge to ArrayBuffer
const challenge = base64UrlToArrayBuffer(challengeData.publicKey.challenge);
const publicKeyRequestOptions = {
challenge: challenge,
timeout: challengeData.publicKey.timeout,
userVerification: challengeData.publicKey.userVerification,
allowCredentials: challengeData.publicKey.allowCredentials || []
};
// Request credential
const credential = await navigator.credentials.get({ publicKey: publicKeyRequestOptions });
// Send to server for verification
const verifyResp = await fetch('/pca/v1/webauthn-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
credential: credentialToJSON(credential)
})
});
const result = await verifyResp.json();
return result.verified === true;
} catch (e) {
console.log('PCA: WebAuthn step-up failed:', e);
return false;
}
}
// Proof-of-Work step-up
async function triggerPoW() {
if (!config?.step_up?.pow?.enabled) {
return false;
}
try {
// Get challenge from server
const challengeResp = await fetch(`/pca/v1/pow-challenge?session_id=${sessionId}`);
const challengeData = await challengeResp.json();
const { challenge_id, challenge, difficulty, max_time_ms } = challengeData;
const prefix = '0'.repeat(difficulty);
const startTime = Date.now();
let nonce = 0;
// Solve PoW puzzle
while (true) {
const input = challenge + nonce;
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
if (hashHex.startsWith(prefix)) {
// Found solution - verify with server
const verifyResp = await fetch('/pca/v1/pow-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
challenge_id: challenge_id,
challenge: challenge,
nonce: nonce
})
});
const result = await verifyResp.json();
return result.verified === true;
}
nonce++;
// Check timeout
if (Date.now() - startTime > max_time_ms) {
console.warn('PCA: PoW step-up timed out');
return false;
}
// Yield to prevent UI freeze (every 1000 iterations)
if (nonce % 1000 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
} catch (e) {
console.error('PCA: PoW step-up error:', e);
return false;
}
}
// Trigger step-up based on configured primary method
async function triggerStepUp() {
const methods = config?.step_up;
let success = false;
if (methods?.primary === 'webauthn' && methods?.webauthn?.enabled && window.PublicKeyCredential) {
success = await triggerWebAuthn();
}
if (!success && methods?.pow?.enabled) {
success = await triggerPoW();
}
return success;
}
// Helper: Convert base64url to ArrayBuffer
function base64UrlToArrayBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + padding);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// Helper: Convert credential to JSON-serializable object
function credentialToJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url(credential.rawId),
response: {
authenticatorData: arrayBufferToBase64Url(credential.response.authenticatorData),
clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON),
signature: arrayBufferToBase64Url(credential.response.signature)
}
};
}
// Helper: Convert ArrayBuffer to base64url
function arrayBufferToBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Initialize SDK
function init(userConfig) {
if (isInitialized) return;
config = userConfig;
sessionId = generateSessionId();
isInitialized = true;
// Visibility change listener
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
metrics.visibleTime += (Date.now() - metrics.lastVisibleTS) / 1000;
} else {
metrics.lastVisibleTS = Date.now();
}
});
// Scroll listener
window.addEventListener('scroll', () => {
const doc = document.documentElement;
const scrollTop = window.pageYOffset || doc.scrollTop;
const viewportHeight = window.innerHeight;
const totalHeight = doc.scrollHeight;
const scrollPercent = totalHeight > 0 ? (scrollTop + viewportHeight) / totalHeight : 0;
if (scrollPercent > metrics.maxScrollPercent) {
metrics.maxScrollPercent = scrollPercent;
}
// Track scroll velocity
const now = Date.now();
if (metrics.lastScrollTime > 0) {
const dt = (now - metrics.lastScrollTime) / 1000;
if (dt > 0) {
const velocity = Math.abs(scrollTop - metrics.lastScrollPos) / dt;
metrics.scrollVelocities.push(velocity);
if (metrics.scrollVelocities.length > 50) metrics.scrollVelocities.shift();
}
}
metrics.lastScrollPos = scrollTop;
metrics.lastScrollTime = now;
});
// Mouse movement listener
document.addEventListener('mousemove', (e) => {
metrics.mouseMoves++;
// Track mouse velocity
const now = Date.now();
if (metrics.lastMousePos && metrics.lastMouseTime > 0) {
const dt = (now - metrics.lastMouseTime) / 1000;
if (dt > 0) {
const dx = e.clientX - metrics.lastMousePos.x;
const dy = e.clientY - metrics.lastMousePos.y;
const velocity = Math.sqrt(dx * dx + dy * dy) / dt;
metrics.mouseVelocities.push(velocity);
if (metrics.mouseVelocities.length > 50) metrics.mouseVelocities.shift();
}
}
metrics.lastMousePos = { x: e.clientX, y: e.clientY };
metrics.lastMouseTime = now;
});
// Click listener
document.addEventListener('click', () => {
const now = Date.now();
if (metrics.lastClickTime > 0) {
const interval = (now - metrics.lastClickTime) / 1000;
metrics.clickIntervals.push(interval);
if (metrics.clickIntervals.length > 20) metrics.clickIntervals.shift();
}
metrics.lastClickTime = now;
metrics.clickCount++;
});
// Keystroke listener (count only, no content)
document.addEventListener('keydown', () => {
metrics.keyStrokes++;
});
// Touch listener (mobile)
document.addEventListener('touchstart', () => {
metrics.touchEvents++;
});
// Start tick timer
if (config?.tick?.interval_ms) {
tickTimer = setInterval(() => {
evaluateScore();
sendTick();
}, config.tick.interval_ms);
}
}
// Public API
return {
init,
getScore: () => currentScore,
getSessionId: () => sessionId,
triggerStepUp,
triggerWebAuthn,
triggerPoW,
onScoreUpdate: function(callback) {
scoreCallbacks.push(callback);
// Initial score
evaluateScore();
callback(currentScore, currentScore >= (config?.thresholds?.score_pass || 0.7) ? 'allow' : 'challenge', null);
},
// Manual evaluation
evaluate: () => {
return {
score: evaluateScore(),
session_id: sessionId,
metrics: {
dwell_ratio: metrics.visibleTime / ((Date.now() - metrics.startTime) / 1000),
scroll_depth: metrics.maxScrollPercent,
clicks: metrics.clickCount,
mouse_moves: metrics.mouseMoves
}
};
},
// Force send tick
tick: sendTick,
// Cleanup
destroy: () => {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
isInitialized = false;
scoreCallbacks = [];
}
};
})();
// Auto-initialize if config is available
if (typeof window !== 'undefined') {
window.PCA = PCA;
// Try to load config from ai-access.json
fetch('/ai-access.json')
.then(res => res.ok ? res.json() : null)
.catch(() => null)
.then(cfg => {
if (cfg) {
PCA.init(cfg);
}
});
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = PCA;
}