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