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:
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