@elhamdev/tracejs
Version:
A modern, privacy-conscious alternative to browser fingerprinting for unique user identification.
401 lines (400 loc) • 16 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BehaviorFingerprint = void 0;
const cache_1 = require("../utils/cache");
const BaseFingerprint_1 = require("./BaseFingerprint");
class BehaviorFingerprint extends BaseFingerprint_1.BaseFingerprint {
constructor(options = {}) {
super();
this.behaviorProfile = {};
this.initialized = false;
this.profileReady = false;
this.mouseData = [];
this.keyData = [];
this.touchData = [];
this.dataCollectionStartTime = 0;
this.mouseMoveHandler = null;
this.mouseClickHandler = null;
this.keyDownHandler = null;
this.keyUpHandler = null;
this.touchHandler = null;
this.keysDown = {}; // key: timestamp
this.cachedProfile = null;
this.profileTimestamp = 0;
this.PROFILE_VALIDITY_PERIOD = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
// Define default options as a separate object for better readability
const defaultOptions = {
trackMouse: true,
trackKeyboard: true,
trackTouch: true,
privacyMode: "balanced",
sampleRate: 100, // 100ms between samples by default
trainingDuration: 10000, // 10 seconds by default
};
// Merge with provided options, giving preference to user-provided values
this.options = {
...defaultOptions,
...options,
};
// Try to load cached profile from storage
this.loadCachedProfile();
}
/**
* Loads previously cached behavior profile from localStorage if available
* and if it's still within the validity period
*/
loadCachedProfile() {
try {
const cacheKey = (0, cache_1.generateCacheKey)("behavior_profile");
const cachedProfile = (0, cache_1.getFromCache)(cacheKey);
if (cachedProfile) {
this.behaviorProfile = cachedProfile;
this.profileReady = true;
this.cachedProfile = JSON.stringify(cachedProfile);
}
}
catch (error) {
console.error("Error loading cached behavior profile:", error);
// If there's an error, we'll just collect new data
}
}
/**
* Saves the current behavior profile to localStorage for future consistency
*/
saveCachedProfile() {
try {
if (this.profileReady && this.behaviorProfile) {
const cacheKey = (0, cache_1.generateCacheKey)("behavior_profile");
(0, cache_1.saveToCache)(cacheKey, this.behaviorProfile);
this.cachedProfile = JSON.stringify(this.behaviorProfile);
this.profileTimestamp = Date.now();
}
}
catch (error) {
console.error("Error saving behavior profile to cache:", error);
}
}
initialize() {
if (this.initialized)
return;
this.initialized = true;
this.dataCollectionStartTime = Date.now();
// Set up event listeners
if (this.options.trackMouse) {
this.setupMouseTracking();
}
if (this.options.trackKeyboard) {
this.setupKeyboardTracking();
}
if (this.options.trackTouch) {
this.setupTouchTracking();
}
// Set up processing interval
setTimeout(() => this.processCollectedData(), this.options.trainingDuration);
}
cleanup() {
if (!this.initialized)
return;
// Remove event listeners
if (this.mouseMoveHandler) {
window.removeEventListener("mousemove", this.mouseMoveHandler);
this.mouseMoveHandler = null;
}
if (this.mouseClickHandler) {
window.removeEventListener("mousedown", this.mouseClickHandler);
this.mouseClickHandler = null;
}
if (this.keyDownHandler) {
window.removeEventListener("keydown", this.keyDownHandler);
this.keyDownHandler = null;
}
if (this.keyUpHandler) {
window.removeEventListener("keyup", this.keyUpHandler);
this.keyUpHandler = null;
}
if (this.touchHandler) {
window.removeEventListener("touchstart", this.touchHandler);
window.removeEventListener("touchmove", this.touchHandler);
window.removeEventListener("touchend", this.touchHandler);
this.touchHandler = null;
}
this.initialized = false;
}
setupMouseTracking() {
let lastSample = 0;
this.mouseMoveHandler = (e) => {
const now = Date.now();
const sampleRate = this.options.sampleRate ?? 100; // Use nullish coalescing
if (now - lastSample < sampleRate)
return;
lastSample = now;
this.mouseData.push({
x: e.clientX,
y: e.clientY,
timestamp: now,
});
};
this.mouseClickHandler = (e) => {
// Track clicks for pressure estimation
this.mouseData.push({
x: e.clientX,
y: e.clientY,
timestamp: Date.now(),
});
};
window.addEventListener("mousemove", this.mouseMoveHandler);
window.addEventListener("mousedown", this.mouseClickHandler);
}
setupKeyboardTracking() {
this.keyDownHandler = (e) => {
// Don't track actual key values in balanced or minimal privacy modes
if (this.options.privacyMode === "balanced" ||
this.options.privacyMode === "minimal") {
this.keysDown[e.code] = Date.now();
}
else {
this.keysDown[e.key] = Date.now();
}
};
this.keyUpHandler = (e) => {
const startTime = this.keysDown[this.options.privacyMode === "full" ? e.key : e.code];
if (startTime) {
const duration = Date.now() - startTime;
// In minimal privacy mode, only record timing data, not key identity
const keyIdentifier = this.options.privacyMode === "minimal"
? "key"
: this.options.privacyMode === "balanced"
? e.code
: e.key;
this.keyData.push({
key: keyIdentifier,
duration,
timestamp: Date.now(),
});
delete this.keysDown[this.options.privacyMode === "full" ? e.key : e.code];
}
};
window.addEventListener("keydown", this.keyDownHandler);
window.addEventListener("keyup", this.keyUpHandler);
}
setupTouchTracking() {
let lastSample = 0;
this.touchHandler = (e) => {
const now = Date.now();
const sampleRate = this.options.sampleRate ?? 100; // Use nullish coalescing
if (now - lastSample < sampleRate && e.type === "touchmove")
return;
if (e.type === "touchmove")
lastSample = now;
// Only track the first touch point for privacy reasons
if (e.touches.length > 0) {
const touch = e.touches[0];
this.touchData.push({
x: touch.clientX,
y: touch.clientY,
size: touch.radiusX * touch.radiusY || 1, // Some browsers may not support radius
timestamp: now,
});
}
};
window.addEventListener("touchstart", this.touchHandler);
window.addEventListener("touchmove", this.touchHandler);
window.addEventListener("touchend", this.touchHandler);
}
calculateMouseMetrics() {
if (this.mouseData.length < 10)
return {}; // Not enough data
const metrics = {};
// Calculate average speed
let totalSpeed = 0;
let speedSamples = 0;
for (let i = 1; i < this.mouseData.length; i++) {
const prev = this.mouseData[i - 1];
const curr = this.mouseData[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const timeDiff = curr.timestamp - prev.timestamp;
if (timeDiff > 0) {
const speed = distance / timeDiff;
totalSpeed += speed;
speedSamples++;
}
}
metrics.averageSpeed = speedSamples > 0 ? totalSpeed / speedSamples : 0;
// Calculate direction changes
let directionChanges = 0;
let prevDirection = 0; // 0 = undefined, 1 = up, 2 = down, 3 = left, 4 = right
for (let i = 1; i < this.mouseData.length; i++) {
const prev = this.mouseData[i - 1];
const curr = this.mouseData[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
let currDirection = 0;
if (Math.abs(dx) > Math.abs(dy)) {
currDirection = dx > 0 ? 4 : 3;
}
else {
currDirection = dy > 0 ? 2 : 1;
}
if (prevDirection !== 0 && currDirection !== prevDirection) {
directionChanges++;
}
prevDirection = currDirection;
}
metrics.directionChanges = directionChanges;
// Calculate hesitations (pauses in movement)
let hesitations = 0;
for (let i = 1; i < this.mouseData.length; i++) {
const prev = this.mouseData[i - 1];
const curr = this.mouseData[i];
const timeDiff = curr.timestamp - prev.timestamp;
if (timeDiff > 300) {
// Pause threshold: 300ms
hesitations++;
}
}
metrics.hesitations = hesitations;
return metrics;
}
calculateKeyboardMetrics() {
if (this.keyData.length < 5)
return {}; // Not enough data
const metrics = {};
// Calculate average key press time
const totalPressTimes = this.keyData.reduce((sum, data) => sum + data.duration, 0);
metrics.keyPressTime = totalPressTimes / this.keyData.length;
// Calculate typing speed
if (this.keyData.length >= 2) {
const timeSpan = this.keyData[this.keyData.length - 1].timestamp -
this.keyData[0].timestamp;
metrics.typingSpeed = this.keyData.length / (timeSpan / 1000); // characters per second
}
// In full privacy mode, analyze common errors
if (this.options.privacyMode === "full" && this.keyData.length > 20) {
const backspaceCount = this.keyData.filter((k) => k.key === "Backspace").length;
metrics.deletionRate = backspaceCount / this.keyData.length;
}
return metrics;
}
calculateTouchMetrics() {
if (this.touchData.length < 10)
return {}; // Not enough data
const metrics = {};
// Calculate average touch size
const totalSize = this.touchData.reduce((sum, data) => sum + data.size, 0);
metrics.touchSize = totalSize / this.touchData.length;
// Calculate swipe speed
let totalSwipeSpeed = 0;
let swipeSamples = 0;
for (let i = 1; i < this.touchData.length; i++) {
const prev = this.touchData[i - 1];
const curr = this.touchData[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const timeDiff = curr.timestamp - prev.timestamp;
if (timeDiff > 0 && timeDiff < 300) {
// Likely part of the same swipe
const speed = distance / timeDiff;
totalSwipeSpeed += speed;
swipeSamples++;
}
}
if (swipeSamples > 0) {
metrics.swipeCharacteristics = {
speed: totalSwipeSpeed / swipeSamples,
};
}
return metrics;
}
processCollectedData() {
if (this.profileReady)
return;
this.behaviorProfile = {
mouse: this.options.trackMouse ? this.calculateMouseMetrics() : undefined,
keyboard: this.options.trackKeyboard
? this.calculateKeyboardMetrics()
: undefined,
touch: this.options.trackTouch ? this.calculateTouchMetrics() : undefined,
};
this.profileReady = true;
// Save profile for future consistency
this.saveCachedProfile();
// Notify if a callback is provided
if (this.options.onProfileUpdate) {
this.options.onProfileUpdate(this.behaviorProfile);
}
}
async getCharacteristics() {
// If we already have a cached profile, use it for consistency
if (this.cachedProfile) {
return {
behaviorProfile: this.cachedProfile,
};
}
// Otherwise, proceed with normal initialization and collection
if (!this.initialized) {
this.initialize();
}
// If profile isn't ready yet, wait for it
if (!this.profileReady) {
// Calculate how much time is left
const elapsedTime = Date.now() - this.dataCollectionStartTime;
const trainingDuration = this.options.trainingDuration ?? 10000; // Use nullish coalescing for cleaner fallback
const remainingTime = Math.max(0, trainingDuration - elapsedTime);
if (remainingTime > 0) {
await new Promise((resolve) => setTimeout(resolve, remainingTime));
this.processCollectedData();
}
}
// If we have a behavior profile, return it
if (this.profileReady && this.behaviorProfile) {
const profileStr = JSON.stringify(this.behaviorProfile);
// Update cache if needed
if (this.cachedProfile !== profileStr) {
this.saveCachedProfile();
}
return {
behaviorProfile: profileStr,
};
}
return {};
}
getStrengthScore() {
if (!this.profileReady) {
return {
score: 0,
details: ["Behavior profile not yet available"],
};
}
let score = 0;
const details = [];
// Mouse metrics contribute up to 20 points
if (this.behaviorProfile.mouse) {
const mouseMetrics = Object.keys(this.behaviorProfile.mouse).length;
const mouseScore = Math.min(20, mouseMetrics * 5);
score += mouseScore;
details.push(`Mouse behavior patterns (${mouseScore} points)`);
}
// Keyboard metrics contribute up to 20 points
if (this.behaviorProfile.keyboard) {
const keyboardMetrics = Object.keys(this.behaviorProfile.keyboard).length;
const keyboardScore = Math.min(20, keyboardMetrics * 6);
score += keyboardScore;
details.push(`Keyboard behavior patterns (${keyboardScore} points)`);
}
// Touch metrics contribute up to 20 points
if (this.behaviorProfile.touch) {
const touchMetrics = Object.keys(this.behaviorProfile.touch).length;
const touchScore = Math.min(20, touchMetrics * 7);
score += touchScore;
details.push(`Touch behavior patterns (${touchScore} points)`);
}
return {
score,
details: details.length > 0 ? details : ["Behavior fingerprinting enabled"],
};
}
}
exports.BehaviorFingerprint = BehaviorFingerprint;