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