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>
474 lines
14 KiB
JavaScript
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;
|
|
}
|