Files
breakpilot-compliance/pca-platform/sdk/js/src/pca-sdk.js
Benjamin Boenisch 4435e7ea0a 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>
2026-02-11 23:47:28 +01:00

474 lines
14 KiB
JavaScript

/**
* 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;
}