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