'use strict'; // A visual similarity feature, never an authentication or phishing verdict. const SIZE = 32; const MAX_PIXELS = 16 * 1024 * 1024; const ALGORITHM = 'phash-dct32-v1'; const COSINES = Array.from({ length: 8 }, (_, frequency) => Float64Array.from({ length: SIZE }, (_, position) => Math.cos(Math.PI * (2 * position + 1) * frequency / (2 * SIZE)))); function invalid() { throw new Error('Invalid visual fingerprint input'); } function visualFingerprint({ rgba, width, height, region } = {}) { if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 8192 || height > 8192 || width * height > MAX_PIXELS) invalid(); if (!ArrayBuffer.isView(rgba) || rgba.BYTES_PER_ELEMENT !== 1 || rgba.byteLength !== width * height * 4) invalid(); if (typeof SharedArrayBuffer !== 'undefined' && rgba.buffer instanceof SharedArrayBuffer) invalid(); const pixels = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength); let left = 0, top = 0, right = width, bottom = height; if (region !== undefined) { if (!region || !['x', 'y', 'width', 'height'].every(key => Number.isInteger(region[key]) && Math.abs(region[key]) <= 32768) || region.width <= 0 || region.height <= 0) invalid(); left = Math.max(0, region.x); top = Math.max(0, region.y); right = Math.min(width, region.x + region.width); bottom = Math.min(height, region.y + region.height); if (left >= right || top >= bottom) invalid(); } function luminance(x, y) { const index = (y * width + x) * 4; const alpha = pixels[index + 3] / 255; return alpha * (0.299 * pixels[index] + 0.587 * pixels[index + 1] + 0.114 * pixels[index + 2]) + (1 - alpha) * 255; } const grid = new Float64Array(SIZE * SIZE); // Centered bilinear resampling of the entire supplied crop preserves geometry. for (let y = 0; y < SIZE; y++) { const py = Math.max(top, Math.min(bottom - 1, top + (y + 0.5) * (bottom - top) / SIZE - 0.5)); const y0 = Math.floor(py), y1 = Math.min(bottom - 1, y0 + 1), dy = py - y0; for (let x = 0; x < SIZE; x++) { const px = Math.max(left, Math.min(right - 1, left + (x + 0.5) * (right - left) / SIZE - 0.5)); const x0 = Math.floor(px), x1 = Math.min(right - 1, x0 + 1), dx = px - x0; grid[y * SIZE + x] = (1 - dy) * ((1 - dx) * luminance(x0, y0) + dx * luminance(x1, y0)) + dy * ((1 - dx) * luminance(x0, y1) + dx * luminance(x1, y1)); } } const mean = grid.reduce((sum, value) => sum + value, 0) / grid.length; const stddev = Math.sqrt(grid.reduce((sum, value) => sum + (value - mean) ** 2, 0) / grid.length); const histogram = new Uint16Array(16); let minimum = 255, maximum = 0, detail = 0; for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) { const value = grid[y * SIZE + x]; minimum = Math.min(minimum, value); maximum = Math.max(maximum, value); histogram[Math.min(15, Math.floor(value / 16))]++; if (x) detail += Math.abs(value - grid[y * SIZE + x - 1]); if (y) detail += Math.abs(value - grid[(y - 1) * SIZE + x]); } detail /= 2 * SIZE * (SIZE - 1); const entropy = histogram.reduce((sum, count) => { const probability = count / grid.length; return count ? sum - probability * Math.log2(probability) : sum; }, 0); const coefficients = []; // Orthonormal DCT-II, low-frequency 8x8 block. DC is deliberately excluded. for (let v = 0; v < 8; v++) for (let u = 0; u < 8; u++) { if (!u && !v) { coefficients.push(0); continue; } let coefficient = 0; for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) coefficient += (grid[y * SIZE + x] - mean) * COSINES[u][x] * COSINES[v][y]; coefficient *= (u ? Math.sqrt(2 / SIZE) : Math.sqrt(1 / SIZE)) * (v ? Math.sqrt(2 / SIZE) : Math.sqrt(1 / SIZE)); coefficients.push(Math.round(coefficient * 1e8) / 1e8); } const sorted = coefficients.slice(1).sort((a, b) => a - b), median = sorted[31]; let bits = 0n; for (let index = 1; index < coefficients.length; index++) if (coefficients[index] > median) bits |= 1n << BigInt(63 - index); const round = value => Math.round(value * 10000) / 10000; return { algorithm: ALGORITHM, hash: bits.toString(16).padStart(16, '0'), quality: { contrast: round(stddev), stddev: round(stddev), dynamicRange: round(maximum - minimum), entropy: round(entropy), detail: round(detail) }, usable: stddev >= 10 && maximum - minimum >= 32 && entropy >= 0.5 && detail >= 1 }; } function hammingDistance(left, right) { if (typeof left !== 'string' || typeof right !== 'string' || !/^[a-f0-9]{16}$/.test(left) || !/^[a-f0-9]{16}$/.test(right)) invalid(); let different = BigInt('0x' + left) ^ BigInt('0x' + right), count = 0; while (different) { different &= different - 1n; count++; } return count; } module.exports = { visualFingerprint, hammingDistance };