UNPKG

fingerprint-web

Version:

A blazing-fast, dependency-free browser fingerprinting library to uniquely identify devices using entropy data. Designed for high performance, privacy-respecting analytics, bot detection, and session tracking in modern web apps.

476 lines (414 loc) 16.7 kB
/** * Web-Based Fingerprint - A lightweight, deterministic browser fingerprint generator. * This is an open-source library by Jafran Hasan, Sr. Software Developer at WPPOOL. * Website: https://jafran.online * * Generates consistent browser fingerprints based on stable device and browser characteristics. * Fully customizable with modifiable entropy sources and output formats. */ export default class Fingerprint { constructor(options = {}) { this.data = []; // Holds all the data points that contribute to the fingerprint this.options = { enableFonts: true, // Include font detection excludeVolatile: true, // Exclude highly volatile components hashAlgorithm: 'SHA-256', // Hash algorithm to use (SHA-256, SHA-1, SHA-384, SHA-512) hashLength: 0, // Output hash length (0 = full length, positive number = truncate to length) components: { // Enable/disable specific components userAgent: true, language: true, screen: true, timezone: true, touchPoints: true, hardware: true, doNotTrack: true, canvas: true, webGL: true, colorGamut: true, plugins: true, fonts: true, audio: false // Disabled by default as it's more volatile }, weights: { // Weights to apply to different components (higher = more influence) userAgent: 1, language: 1, screen: 1, timezone: 1, touchPoints: 1, hardware: 1, doNotTrack: 1, canvas: 1, webGL: 1, colorGamut: 1, plugins: 1, fonts: 1, audio: 1 }, customComponents: [], // Array of custom component functions to include separator: '||', // Separator for component values ...options }; } /** * Generates the unique fingerprint by collecting system/browser data * and applying the chosen hash algorithm. * @param {Object} overrideOptions - Optional runtime options to override constructor options * @returns {Promise<string>} The generated fingerprint hash */ async get(overrideOptions = {}) { // Merge runtime options with constructor options const runtimeOptions = { ...this.options, ...overrideOptions }; await this.collect(runtimeOptions); // Collect the necessary data const raw = this.data.join(runtimeOptions.separator); // Join with configured separator const hash = await this.hash(raw, runtimeOptions.hashAlgorithm); // Generate the hash // Truncate hash if length is specified if (runtimeOptions.hashLength > 0 && hash.length > runtimeOptions.hashLength) { return hash.substring(0, runtimeOptions.hashLength); } return hash; // Return the fingerprint hash } /** * Returns the raw fingerprint data before hashing, for advanced customization. * @returns {Promise<Array>} Array of collected data points */ async getRawData() { await this.collect(); return [...this.data]; // Return a copy to prevent modification } /** * Collects various stable browser/device characteristics to generate a fingerprint. * Focuses on characteristics that don't change between sessions. * @param {Object} options - Configuration options */ async collect(options = this.options) { const components = options.components; const weights = options.weights; const stableData = []; // Only include enabled components with their appropriate weighting const addComponent = (name, value) => { if (components[name]) { // Apply component weight by repeating the value const weight = weights[name] || 1; for (let i = 0; i < weight; i++) { stableData.push(value); } } }; // Add core browser/hardware data if (components.userAgent) stableData.push(navigator.userAgent); if (components.language) stableData.push(navigator.language); if (components.screen) stableData.push(screen.width + 'x' + screen.height + 'x' + screen.colorDepth); if (components.timezone) stableData.push(Intl.DateTimeFormat().resolvedOptions().timeZone); if (components.touchPoints) stableData.push(navigator.maxTouchPoints); if (components.hardware) { stableData.push(navigator.hardwareConcurrency || 'unknown'); stableData.push(navigator.deviceMemory || 'unknown'); } if (components.doNotTrack) stableData.push(navigator.doNotTrack); // Add more complex components if (components.canvas) stableData.push(this.getCanvas()); if (components.webGL) stableData.push(this.getWebGL()); if (components.colorGamut) stableData.push(this.getColorGamut()); if (components.plugins) stableData.push(this.getPlugins()); // Add components that need async if (components.fonts && options.enableFonts) { stableData.push(await this.getAvailableFonts()); } // Only include audio if volatility is allowed or specifically enabled if (!options.excludeVolatile && components.audio) { stableData.push(await this.getAudio()); } // Include any custom components if (options.customComponents && options.customComponents.length > 0) { for (const componentFn of options.customComponents) { try { const componentValue = await componentFn.call(this); if (componentValue !== null && componentValue !== undefined) { stableData.push(componentValue); } } catch (e) { console.error('Error in custom component:', e); } } } this.data = stableData.filter(item => item !== null && item !== undefined); } /** * Generates a fingerprint based on the canvas rendering result. * This is unique to the specific device's GPU but consistent. * @returns {string} A hash of the canvas content (not the full base64 which can vary) */ getCanvas() { try { const canvas = document.createElement('canvas'); canvas.width = 250; canvas.height = 60; const ctx = canvas.getContext('2d'); // Draw consistently with specific settings ctx.textBaseline = 'alphabetic'; ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20); // Use specific font size and family for consistency ctx.fillStyle = '#069'; ctx.font = '15px Arial'; ctx.fillText('Consistent-Fingerprint', 2, 15); ctx.fillStyle = 'rgba(102, 204, 0, 0.7)'; ctx.font = '16px Georgia'; ctx.fillText('FingerprintWeb', 4, 45); // Draw shapes for more entropy ctx.strokeStyle = '#FF0000'; ctx.beginPath(); ctx.arc(50, 30, 15, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke(); // Extract the raw image data as numbers, not as base64 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; // Sample specific pixels rather than the entire image for stability const samples = []; for (let i = 0; i < imageData.length; i += 4000) { if (i < imageData.length) { samples.push(imageData[i]); } } return samples.join(','); } catch (e) { return 'canvas_unsupported'; // Fallback if canvas is unsupported } } /** * Generates an audio fingerprint by rendering a fixed audio context and measuring the output. * Takes specific samples from the result for consistency. * @returns {Promise<string>} A deterministic audio fingerprint value */ async getAudio() { try { const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 44100, 44100); // Create a consistent oscillator configuration const oscillator = ctx.createOscillator(); oscillator.type = 'sine'; // Use sine for more stability than triangle oscillator.frequency.setValueAtTime(10000, ctx.currentTime); // Set exact time // Create a consistent gain configuration const gain = ctx.createGain(); gain.gain.setValueAtTime(0.5, ctx.currentTime); // Set exact gain and time // Connect and start at exact times oscillator.connect(gain); gain.connect(ctx.destination); oscillator.start(0); ctx.startRendering(); return new Promise(resolve => { ctx.oncomplete = event => { const buffer = event.renderedBuffer.getChannelData(0); // Instead of summing all values, take specific samples // This is more deterministic across different browsers const samples = []; const samplePoints = [0, 4410, 8820, 13230, 17640, 22050, 26460, 30870, 35280, 39690]; for (const point of samplePoints) { if (point < buffer.length) { // Round to fixed precision to increase stability samples.push(buffer[point].toFixed(6)); } } resolve(samples.join(',')); }; }); } catch (e) { return 'audio_unsupported'; // Fallback if audio context is unsupported } } /** * Collects WebGL information, specifically the vendor and renderer information, * to uniquely identify the GPU. * @returns {string} WebGL renderer information */ getWebGL() { try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); if (!gl) { return 'webgl_unsupported'; } // Try to get the unmasked info let vendor, renderer; try { const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); if (debugInfo) { vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL); renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); } } catch (e) { // Fall back to standard info if debug info isn't available } // Use standard params as fallback vendor = vendor || gl.getParameter(gl.VENDOR); renderer = renderer || gl.getParameter(gl.RENDERER); // Add WebGL capabilities for more stable fingerprinting const capabilities = []; capabilities.push(`max_texture_size:${gl.getParameter(gl.MAX_TEXTURE_SIZE)}`); capabilities.push(`max_viewport_dims:${gl.getParameter(gl.MAX_VIEWPORT_DIMS)}`); capabilities.push(`aliased_line_width_range:${gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE)}`); return `${vendor}~${renderer}~${capabilities.join(',')}`; } catch (e) { return 'webgl_unsupported'; // Fallback if WebGL is unsupported } } /** * Retrieves the list of installed browser plugins in a normalized format. * @returns {string} A normalized list of plugin names */ getPlugins() { try { if (!navigator.plugins || navigator.plugins.length === 0) { return 'no_plugins'; } // Create a sorted, normalized list of plugin names const pluginList = []; for (let i = 0; i < navigator.plugins.length; i++) { const plugin = navigator.plugins[i]; if (plugin && plugin.name) { // Normalize plugin names for consistent capitalization and whitespace let name = plugin.name.replace(/\s+/g, ' ').trim(); pluginList.push(name); } } // Sort for consistency regardless of browser's internal order return pluginList.sort().join(','); } catch (e) { return 'plugins_unsupported'; // Fallback if plugins are unsupported } } /** * Determines the color gamut of the device (P3 or sRGB). * @returns {string} The color gamut of the device */ getColorGamut() { try { // Check for various color gamuts in order of specificity if (window.matchMedia('(color-gamut: rec2020)').matches) { return 'rec2020'; } if (window.matchMedia('(color-gamut: p3)').matches) { return 'p3'; } if (window.matchMedia('(color-gamut: srgb)').matches) { return 'srgb'; } return 'unknown'; } catch (e) { return 'gamut_unsupported'; } } /** * Tests for availability of standard fonts to add entropy. * This is more reliable than checking battery or network which change frequently. * @returns {Promise<string>} A comma-separated list of available fonts */ async getAvailableFonts() { if (!this.options.enableFonts) { return ''; } const baseFonts = ['monospace', 'sans-serif', 'serif']; const fontList = [ 'Arial', 'Courier New', 'Georgia', 'Times New Roman', 'Trebuchet MS', 'Verdana', 'Tahoma', 'Helvetica' ]; const testString = 'mmMMMwWWiii'; const testSize = '72px'; try { const d = document.createElement('div'); d.style.cssText = 'position: absolute; left: -9999px; visibility: hidden;'; document.body.appendChild(d); const defaultWidths = {}; const results = []; // Collect base measurements for (const baseFont of baseFonts) { d.style.fontFamily = baseFont; d.innerHTML = testString; defaultWidths[baseFont] = d.clientWidth; } // Test each font for (const font of fontList) { let detected = false; for (const baseFont of baseFonts) { d.style.fontFamily = `${font}, ${baseFont}`; d.innerHTML = testString; // If width is different, font is available if (d.clientWidth !== defaultWidths[baseFont]) { detected = true; break; } } // Only include available fonts if (detected) { results.push(font); } } document.body.removeChild(d); return results.sort().join(','); } catch (e) { return 'font_detection_unsupported'; } } /** * Generates a hash of the provided string using the specified algorithm. * @param {string} str - The string to hash * @param {string} algorithm - The hash algorithm to use (default: SHA-256) * @returns {Promise<string>} The hash of the string */ async hash(str, algorithm = 'SHA-256') { // Validate the algorithm is supported const validAlgorithms = ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']; const hashAlgorithm = validAlgorithms.includes(algorithm) ? algorithm : 'SHA-256'; const msgUint8 = new TextEncoder().encode(str); // Convert string to Uint8Array try { const hashBuffer = await crypto.subtle.digest(hashAlgorithm, msgUint8); // Hash the data return Array.from(new Uint8Array(hashBuffer)) // Convert hash to hexadecimal .map(b => b.toString(16).padStart(2, '0')) // Format as hex .join(''); } catch (e) { // Fallback to SHA-256 if the requested algorithm fails console.warn(`Hash algorithm ${algorithm} failed, falling back to SHA-256`); const fallbackBuffer = await crypto.subtle.digest('SHA-256', msgUint8); return Array.from(new Uint8Array(fallbackBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } } /** * Converts the fingerprint hash to various output formats. * @param {string} hash - The fingerprint hash to convert * @param {string} format - The output format (hex, base64, int, binary) * @returns {string|number} The formatted hash */ formatHash(hash, format = 'hex') { switch (format.toLowerCase()) { case 'base64': // Convert hex to base64 const raw = hash.match(/.{2}/g).map(h => parseInt(h, 16)); return btoa(String.fromCharCode.apply(null, raw)); case 'int': // Return first 53 bits (safe integer in JavaScript) return parseInt(hash.slice(0, 13), 16); case 'binary': // Convert to binary string return hash.split('').map(i => parseInt(i, 16).toString(2).padStart(4, '0')).join(''); case 'hex': default: return hash; } } /** * Creates a new component function that can be added to customComponents. * @param {Function} fn - Function that returns a fingerprint component value * @returns {Function} Properly formatted component function */ static createComponent(fn) { return async function() { try { return await fn.call(this); } catch (e) { console.error('Error in custom component:', e); return null; } }; } }