@zaplier/sdk 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core-ultra.min.js +10 -0
- package/dist/index.cjs +1615 -34
- package/dist/index.d.ts +11 -7
- package/dist/index.esm.js +1615 -34
- package/dist/v2/chunks/browser-apis-AyU2utpF.min.js +6 -0
- package/dist/v2/chunks/confidence-BslwbUCt.min.js +6 -0
- package/dist/v2/chunks/device-signals-2L_62qNZ.min.js +6 -0
- package/dist/v2/chunks/dom-blockers-C467-IRd.min.js +6 -0
- package/dist/v2/chunks/fingerprint-FfUEEIAd.min.js +6 -0
- package/dist/v2/chunks/hardware-9ikfSEs-.min.js +6 -0
- package/dist/v2/chunks/incognito-CkKAdE8Z.min.js +6 -0
- package/dist/v2/chunks/math-Q4s6nkVD.min.js +6 -0
- package/dist/v2/chunks/plugins-enhanced-mUjU1EXe.min.js +6 -0
- package/dist/v2/chunks/session-replay-C5Tp0d16.min.js +6 -0
- package/dist/v2/chunks/storage-Bl_8oytT.min.js +6 -0
- package/dist/v2/chunks/system-DTjxyOZF.min.js +6 -0
- package/dist/v2/core.d.ts +7 -0
- package/dist/v2/core.js +2 -2
- package/dist/v2/core.min.js +2 -2
- package/dist/v2/modules/anti-adblock.js +2 -2
- package/dist/v2/modules/browser-apis-C32PGYAh.js +6 -0
- package/dist/v2/modules/browser-apis-DzzjRXFN.js +6 -0
- package/dist/v2/modules/confidence-CLylpqVh.js +6 -0
- package/dist/v2/modules/device-signals-ChNkNSJR.js +6 -0
- package/dist/v2/modules/device-signals-D-VQg-o6.js +6 -0
- package/dist/v2/modules/dom-blockers-BamKJxCs.js +6 -0
- package/dist/v2/modules/dom-blockers-D9M2aO9M.js +6 -0
- package/dist/v2/modules/fingerprint-CRCL21-p.js +6 -0
- package/dist/v2/modules/fingerprint-Ddq30bun.js +6 -0
- package/dist/v2/modules/fingerprint.js +2 -2
- package/dist/v2/modules/hardware-BxWqOjae.js +6 -0
- package/dist/v2/modules/heatmap.js +1 -1
- package/dist/v2/modules/incognito-CBxUhUOT.js +6 -0
- package/dist/v2/modules/incognito-DpuYoC8S.js +6 -0
- package/dist/v2/modules/math-B13vt1ND.js +6 -0
- package/dist/v2/modules/plugins-enhanced-CnBdC9_k.js +6 -0
- package/dist/v2/modules/plugins-enhanced-D5ft0k0e.js +6 -0
- package/dist/v2/modules/replay.js +2 -2
- package/dist/v2/modules/storage-D8dcMojB.js +6 -0
- package/dist/v2/modules/system-ZMflVbka.js +6 -0
- package/package.json +47 -22
- package/dist/index.cjs.map +0 -1
- package/dist/index.esm.js.map +0 -1
- package/dist/index.js +0 -3517
- package/dist/index.js.map +0 -1
- package/dist/sdk.js +0 -26960
- package/dist/sdk.js.map +0 -1
- package/dist/sdk.legacy.min.js +0 -6
- package/dist/sdk.min.js +0 -7
- package/dist/src/core.d.ts +0 -87
- package/dist/src/core.d.ts.map +0 -1
- package/dist/src/index.d.ts +0 -15
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/modules/anti-adblock.d.ts +0 -113
- package/dist/src/modules/anti-adblock.d.ts.map +0 -1
- package/dist/src/modules/auto-tracker.d.ts +0 -240
- package/dist/src/modules/auto-tracker.d.ts.map +0 -1
- package/dist/src/modules/bot-detection.d.ts +0 -15
- package/dist/src/modules/bot-detection.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/accessibility.d.ts +0 -155
- package/dist/src/modules/fingerprint/accessibility.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/audio.d.ts +0 -15
- package/dist/src/modules/fingerprint/audio.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/browser-apis.d.ts +0 -108
- package/dist/src/modules/fingerprint/browser-apis.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/browser.d.ts +0 -14
- package/dist/src/modules/fingerprint/browser.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/canvas.d.ts +0 -14
- package/dist/src/modules/fingerprint/canvas.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/confidence.d.ts +0 -89
- package/dist/src/modules/fingerprint/confidence.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/datetime-locale.d.ts +0 -76
- package/dist/src/modules/fingerprint/datetime-locale.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/device-signals.d.ts +0 -29
- package/dist/src/modules/fingerprint/device-signals.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/dom-blockers.d.ts +0 -56
- package/dist/src/modules/fingerprint/dom-blockers.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/font-preferences.d.ts +0 -70
- package/dist/src/modules/fingerprint/font-preferences.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/fonts-enhanced.d.ts +0 -43
- package/dist/src/modules/fingerprint/fonts-enhanced.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/fonts.d.ts +0 -14
- package/dist/src/modules/fingerprint/fonts.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/hardware.d.ts +0 -40
- package/dist/src/modules/fingerprint/hardware.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/hashing.d.ts +0 -29
- package/dist/src/modules/fingerprint/hashing.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/incognito.d.ts +0 -6
- package/dist/src/modules/fingerprint/incognito.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/math-enhanced.d.ts +0 -70
- package/dist/src/modules/fingerprint/math-enhanced.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/math.d.ts +0 -32
- package/dist/src/modules/fingerprint/math.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/plugins-enhanced.d.ts +0 -97
- package/dist/src/modules/fingerprint/plugins-enhanced.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/screen.d.ts +0 -15
- package/dist/src/modules/fingerprint/screen.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/storage.d.ts +0 -45
- package/dist/src/modules/fingerprint/storage.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/system.d.ts +0 -40
- package/dist/src/modules/fingerprint/system.d.ts.map +0 -1
- package/dist/src/modules/fingerprint/webgl.d.ts +0 -15
- package/dist/src/modules/fingerprint/webgl.d.ts.map +0 -1
- package/dist/src/modules/fingerprint.d.ts +0 -35
- package/dist/src/modules/fingerprint.d.ts.map +0 -1
- package/dist/src/modules/global-interface.d.ts +0 -157
- package/dist/src/modules/global-interface.d.ts.map +0 -1
- package/dist/src/modules/heatmap.d.ts +0 -145
- package/dist/src/modules/heatmap.d.ts.map +0 -1
- package/dist/src/modules/incognito-detection.d.ts +0 -23
- package/dist/src/modules/incognito-detection.d.ts.map +0 -1
- package/dist/src/modules/session-replay.d.ts +0 -93
- package/dist/src/modules/session-replay.d.ts.map +0 -1
- package/dist/src/modules/user-agent.d.ts +0 -35
- package/dist/src/modules/user-agent.d.ts.map +0 -1
- package/dist/src/modules/visitor-persistence.d.ts +0 -51
- package/dist/src/modules/visitor-persistence.d.ts.map +0 -1
- package/dist/src/sdk.d.ts +0 -289
- package/dist/src/sdk.d.ts.map +0 -1
- package/dist/src/types/config.d.ts +0 -44
- package/dist/src/types/config.d.ts.map +0 -1
- package/dist/src/types/detection.d.ts +0 -114
- package/dist/src/types/detection.d.ts.map +0 -1
- package/dist/src/types/events.d.ts +0 -174
- package/dist/src/types/events.d.ts.map +0 -1
- package/dist/src/types/fingerprint.d.ts +0 -252
- package/dist/src/types/fingerprint.d.ts.map +0 -1
- package/dist/src/types/index.d.ts +0 -83
- package/dist/src/types/index.d.ts.map +0 -1
- package/dist/src/types/methods.d.ts +0 -83
- package/dist/src/types/methods.d.ts.map +0 -1
- package/dist/src/types/visitor.d.ts +0 -90
- package/dist/src/types/visitor.d.ts.map +0 -1
- package/dist/src/utils/browser.d.ts +0 -114
- package/dist/src/utils/browser.d.ts.map +0 -1
- package/dist/src/utils/lazy-loader.d.ts +0 -60
- package/dist/src/utils/lazy-loader.d.ts.map +0 -1
- package/dist/src/utils/session-utils.d.ts +0 -58
- package/dist/src/utils/session-utils.d.ts.map +0 -1
- package/dist/src/utils/webgl-cache.d.ts +0 -43
- package/dist/src/utils/webgl-cache.d.ts.map +0 -1
- package/dist/v2/chunks/fingerprint-ergvAID6.js +0 -8461
- package/dist/v2/chunks/replay-CM8dTFmL.js +0 -12158
- package/dist/v2/chunks/session-replay-C_UkGoTj.js +0 -240
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*!
|
|
2
2
|
* Zaplier SDK v1.0.0
|
|
3
3
|
* Advanced privacy-first tracking with fingerprinting and security detection
|
|
4
|
-
* (c)
|
|
4
|
+
* (c) 2026 RabbitTracker Team
|
|
5
5
|
* https://zaplier.com
|
|
6
6
|
*/
|
|
7
7
|
'use strict';
|
|
@@ -14,6 +14,53 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
14
14
|
*
|
|
15
15
|
* Provides 128-bit hash output using the x64 variant of MurmurHash3
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* Stable JSON stringify — sorts object keys at every level so the resulting
|
|
19
|
+
* string is identical regardless of engine-specific key ordering.
|
|
20
|
+
* Based on fast-json-stable-stringify (ThumbmarkJS adaptation).
|
|
21
|
+
*/
|
|
22
|
+
function stableStringify(data) {
|
|
23
|
+
const seen = [];
|
|
24
|
+
function stringify(node) {
|
|
25
|
+
if (node && typeof node.toJSON === "function") {
|
|
26
|
+
node = node.toJSON();
|
|
27
|
+
}
|
|
28
|
+
if (node === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (typeof node === "number")
|
|
31
|
+
return Number.isFinite(node) ? String(node) : "null";
|
|
32
|
+
if (typeof node !== "object")
|
|
33
|
+
return JSON.stringify(node);
|
|
34
|
+
if (Array.isArray(node)) {
|
|
35
|
+
let out = "[";
|
|
36
|
+
for (let i = 0; i < node.length; i++) {
|
|
37
|
+
if (i)
|
|
38
|
+
out += ",";
|
|
39
|
+
out += stringify(node[i]) ?? "null";
|
|
40
|
+
}
|
|
41
|
+
return out + "]";
|
|
42
|
+
}
|
|
43
|
+
if (node === null)
|
|
44
|
+
return "null";
|
|
45
|
+
if (seen.indexOf(node) !== -1) {
|
|
46
|
+
throw new TypeError("Converting circular structure to JSON");
|
|
47
|
+
}
|
|
48
|
+
const idx = seen.push(node) - 1;
|
|
49
|
+
const keys = Object.keys(node).sort();
|
|
50
|
+
let out = "";
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const val = stringify(node[key]);
|
|
53
|
+
if (val === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
if (out)
|
|
56
|
+
out += ",";
|
|
57
|
+
out += JSON.stringify(key) + ":" + val;
|
|
58
|
+
}
|
|
59
|
+
seen.splice(idx, 1);
|
|
60
|
+
return "{" + out + "}";
|
|
61
|
+
}
|
|
62
|
+
return stringify(data) ?? "";
|
|
63
|
+
}
|
|
17
64
|
/**
|
|
18
65
|
* Adds two 64-bit numbers
|
|
19
66
|
*/
|
|
@@ -185,7 +232,7 @@ function hashFingerprint(components, debug = false) {
|
|
|
185
232
|
// CRITICAL: Do NOT use JSON.stringify(obj, keys) as it filters out nested properties!
|
|
186
233
|
// Use canonicalizeStable instead which handles deep sorting and normalization.
|
|
187
234
|
const canonicalized = canonicalizeStable(components);
|
|
188
|
-
const canonical =
|
|
235
|
+
const canonical = stableStringify(canonicalized);
|
|
189
236
|
if (debug) {
|
|
190
237
|
console.log("[RabbitTracker] hashFingerprint debug:", {
|
|
191
238
|
originalKeys: Object.keys(components),
|
|
@@ -298,7 +345,7 @@ function canonicalizeStable(input) {
|
|
|
298
345
|
*/
|
|
299
346
|
function hashStableCore(coreVector, debug = false) {
|
|
300
347
|
const canonicalized = canonicalizeStable(coreVector);
|
|
301
|
-
const canonical =
|
|
348
|
+
const canonical = stableStringify(canonicalized);
|
|
302
349
|
if (debug) {
|
|
303
350
|
console.log("[RabbitTracker] hashStableCore debug:", {
|
|
304
351
|
originalKeys: Object.keys(coreVector),
|
|
@@ -608,6 +655,14 @@ function getAudioContextCharacteristics() {
|
|
|
608
655
|
if ('baseLatency' in testContext) {
|
|
609
656
|
characteristics.baseLatency = testContext.baseLatency;
|
|
610
657
|
}
|
|
658
|
+
// Channel count mode of AudioBufferSourceNode
|
|
659
|
+
try {
|
|
660
|
+
const bufferSource = testContext.createBufferSource();
|
|
661
|
+
characteristics.channelCountMode = bufferSource.channelCountMode;
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
characteristics.channelCountMode = 'max';
|
|
665
|
+
}
|
|
611
666
|
// OfflineAudioContext doesn't have close method - it's automatically disposed after rendering
|
|
612
667
|
if ('close' in testContext && typeof testContext.close === 'function') {
|
|
613
668
|
testContext.close();
|
|
@@ -705,7 +760,8 @@ async function getAudioFingerprint() {
|
|
|
705
760
|
contextCharacteristics: contextCharacteristics,
|
|
706
761
|
nodeCapabilities: nodeCapabilities,
|
|
707
762
|
audioStackHash: audioStackHash,
|
|
708
|
-
supportedSampleRates: contextCharacteristics.supportedSampleRates || []
|
|
763
|
+
supportedSampleRates: contextCharacteristics.supportedSampleRates || [],
|
|
764
|
+
channelCountMode: contextCharacteristics.channelCountMode || 'max'
|
|
709
765
|
};
|
|
710
766
|
return {
|
|
711
767
|
value: result,
|
|
@@ -724,7 +780,8 @@ async function getAudioFingerprint() {
|
|
|
724
780
|
contextCharacteristics: {},
|
|
725
781
|
nodeCapabilities: {},
|
|
726
782
|
audioStackHash: "error",
|
|
727
|
-
supportedSampleRates: []
|
|
783
|
+
supportedSampleRates: [],
|
|
784
|
+
channelCountMode: 'max'
|
|
728
785
|
},
|
|
729
786
|
duration: performance.now() - startTime,
|
|
730
787
|
error: error instanceof Error ? error.message : "Audio fingerprinting failed",
|
|
@@ -1393,6 +1450,28 @@ async function getBrowserFingerprint() {
|
|
|
1393
1450
|
if (hardwareInfo.deviceMemory !== undefined) {
|
|
1394
1451
|
result.deviceMemory = hardwareInfo.deviceMemory;
|
|
1395
1452
|
}
|
|
1453
|
+
// productSub: "20030107" = Chrome/Safari, "20100101" = Firefox
|
|
1454
|
+
const productSub = navigator.productSub;
|
|
1455
|
+
if (typeof productSub === 'string' && productSub) {
|
|
1456
|
+
result.productSub = productSub;
|
|
1457
|
+
}
|
|
1458
|
+
// ApplePay version — exclusive to Safari + HTTPS + Apple device
|
|
1459
|
+
result.applePayVersion = (() => {
|
|
1460
|
+
try {
|
|
1461
|
+
if (typeof globalThis.ApplePaySession === 'undefined')
|
|
1462
|
+
return 0;
|
|
1463
|
+
if (typeof globalThis.ApplePaySession.supportsVersion !== 'function')
|
|
1464
|
+
return 0;
|
|
1465
|
+
for (let v = 15; v >= 1; v--) {
|
|
1466
|
+
if (globalThis.ApplePaySession.supportsVersion(v))
|
|
1467
|
+
return v;
|
|
1468
|
+
}
|
|
1469
|
+
return 0;
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
return 0;
|
|
1473
|
+
}
|
|
1474
|
+
})();
|
|
1396
1475
|
return {
|
|
1397
1476
|
value: result,
|
|
1398
1477
|
duration: endTime - startTime
|
|
@@ -1430,6 +1509,56 @@ function isBrowserFingerprintingAvailable() {
|
|
|
1430
1509
|
}
|
|
1431
1510
|
}
|
|
1432
1511
|
|
|
1512
|
+
/**
|
|
1513
|
+
* Common Pixels Anti-Randomization Utility
|
|
1514
|
+
* Based on ThumbmarkJS commonPixels implementation
|
|
1515
|
+
*
|
|
1516
|
+
* Browsers like Brave and Firefox (private mode) inject per-pixel random noise
|
|
1517
|
+
* into canvas/WebGL reads to prevent fingerprinting. This utility runs the same
|
|
1518
|
+
* render N times and selects the most frequent value per pixel channel, producing
|
|
1519
|
+
* a consensus ImageData that is stable across noise injections.
|
|
1520
|
+
*/
|
|
1521
|
+
/**
|
|
1522
|
+
* Returns the most frequent value in an array of numbers.
|
|
1523
|
+
*/
|
|
1524
|
+
function getMostFrequent(arr) {
|
|
1525
|
+
if (arr.length === 0)
|
|
1526
|
+
return 0;
|
|
1527
|
+
const freq = {};
|
|
1528
|
+
for (const n of arr) {
|
|
1529
|
+
freq[n] = (freq[n] ?? 0) + 1;
|
|
1530
|
+
}
|
|
1531
|
+
let best = arr[0];
|
|
1532
|
+
for (const key in freq) {
|
|
1533
|
+
const n = parseInt(key, 10);
|
|
1534
|
+
if (freq[n] > freq[best]) {
|
|
1535
|
+
best = n;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
return best;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Given N ImageData objects from the same canvas/scene, returns a new ImageData
|
|
1542
|
+
* where each byte is the most frequent value across all renders.
|
|
1543
|
+
* This cancels out per-pixel random noise injected by privacy-hardened browsers.
|
|
1544
|
+
*
|
|
1545
|
+
* @param images - Array of ImageData objects (must all have the same dimensions)
|
|
1546
|
+
* @param width - Canvas width in pixels
|
|
1547
|
+
* @param height - Canvas height in pixels
|
|
1548
|
+
*/
|
|
1549
|
+
function getCommonPixels(images, width, height) {
|
|
1550
|
+
const len = images[0].data.length;
|
|
1551
|
+
const finalData = new Uint8ClampedArray(len);
|
|
1552
|
+
for (let i = 0; i < len; i++) {
|
|
1553
|
+
const samples = [];
|
|
1554
|
+
for (const img of images) {
|
|
1555
|
+
samples.push(img.data[i]);
|
|
1556
|
+
}
|
|
1557
|
+
finalData[i] = getMostFrequent(samples);
|
|
1558
|
+
}
|
|
1559
|
+
return new ImageData(finalData, width, height);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1433
1562
|
/**
|
|
1434
1563
|
* Canvas Fingerprinting
|
|
1435
1564
|
* Based on FingerprintJS canvas component with incognito detection
|
|
@@ -1729,6 +1858,36 @@ function detectAdvancedInconsistencies(textHash, geometryHash, subPixelData, ctx
|
|
|
1729
1858
|
};
|
|
1730
1859
|
}
|
|
1731
1860
|
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Render a scene function onto a canvas N times and return the consensus ImageData.
|
|
1863
|
+
* Used to cancel out per-pixel noise injected by Brave/Firefox privacy mode.
|
|
1864
|
+
*/
|
|
1865
|
+
function renderConsensus(width, height, renderFn, runs = 3) {
|
|
1866
|
+
const images = [];
|
|
1867
|
+
for (let i = 0; i < runs; i++) {
|
|
1868
|
+
const c = document.createElement("canvas");
|
|
1869
|
+
c.width = width;
|
|
1870
|
+
c.height = height;
|
|
1871
|
+
const ctx = c.getContext("2d", { willReadFrequently: true });
|
|
1872
|
+
if (!ctx)
|
|
1873
|
+
continue;
|
|
1874
|
+
renderFn(ctx);
|
|
1875
|
+
try {
|
|
1876
|
+
images.push(ctx.getImageData(0, 0, width, height));
|
|
1877
|
+
}
|
|
1878
|
+
catch {
|
|
1879
|
+
// getImageData blocked — skip
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
if (images.length === 0)
|
|
1883
|
+
return "consensus-blocked";
|
|
1884
|
+
// Single render — no noise to cancel; hash directly
|
|
1885
|
+
if (images.length === 1) {
|
|
1886
|
+
return hash32(Array.from(images[0].data).join(","));
|
|
1887
|
+
}
|
|
1888
|
+
const consensus = getCommonPixels(images, width, height);
|
|
1889
|
+
return hash32(Array.from(consensus.data).join(","));
|
|
1890
|
+
}
|
|
1732
1891
|
/**
|
|
1733
1892
|
* Generate enhanced canvas fingerprint with multiple primitives and sub-pixel analysis
|
|
1734
1893
|
*/
|
|
@@ -1765,15 +1924,29 @@ async function getCanvasFingerprint() {
|
|
|
1765
1924
|
const subPixelAnalysis = analyzeSubPixels(ctx, canvas);
|
|
1766
1925
|
// ENHANCED: Advanced inconsistency detection
|
|
1767
1926
|
const inconsistencyAnalysis = detectAdvancedInconsistencies(textHash, geometryHash, subPixelAnalysis, ctx);
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1927
|
+
// ANTI-RANDOMIZATION: If browser injects canvas noise (Brave/Firefox/Samsung),
|
|
1928
|
+
// run the scene 3× and take the per-pixel consensus to cancel the noise out.
|
|
1929
|
+
const needsConsensus = inconsistencyAnalysis.isInconsistent || isBrave() || isSamsungInternet$1();
|
|
1930
|
+
let compositeHash;
|
|
1931
|
+
if (needsConsensus) {
|
|
1932
|
+
const w = canvas.width;
|
|
1933
|
+
const h = canvas.height;
|
|
1934
|
+
compositeHash = renderConsensus(w, h, (c) => {
|
|
1935
|
+
drawAdvancedText(c);
|
|
1936
|
+
c.globalCompositeOperation = "multiply";
|
|
1937
|
+
drawAdvancedGeometry(c);
|
|
1938
|
+
c.globalCompositeOperation = "source-over";
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
else {
|
|
1942
|
+
// Fast path: single composite render
|
|
1943
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1944
|
+
drawAdvancedText(ctx);
|
|
1945
|
+
ctx.globalCompositeOperation = "multiply";
|
|
1946
|
+
drawAdvancedGeometry(ctx);
|
|
1947
|
+
ctx.globalCompositeOperation = "source-over";
|
|
1948
|
+
compositeHash = hash32(canvas.toDataURL("image/png"));
|
|
1949
|
+
}
|
|
1777
1950
|
const endTime = performance.now();
|
|
1778
1951
|
const result = {
|
|
1779
1952
|
text: textHash,
|
|
@@ -1782,7 +1955,7 @@ async function getCanvasFingerprint() {
|
|
|
1782
1955
|
isInconsistent: inconsistencyAnalysis.isInconsistent,
|
|
1783
1956
|
// NEW FIELDS
|
|
1784
1957
|
subPixelAnalysis, // Sub-pixel characteristics for GPU differentiation
|
|
1785
|
-
compositeHash, // Combined text+geometry hash
|
|
1958
|
+
compositeHash, // Combined text+geometry hash (consensus if noisy browser)
|
|
1786
1959
|
inconsistencyConfidence: inconsistencyAnalysis.confidence,
|
|
1787
1960
|
blockingReasons: inconsistencyAnalysis.reasons,
|
|
1788
1961
|
};
|
|
@@ -2666,6 +2839,28 @@ function createProgram(gl, vertexShader, fragmentShader) {
|
|
|
2666
2839
|
}
|
|
2667
2840
|
return program;
|
|
2668
2841
|
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Read pixels from a WebGL context and return them as a fresh ImageData-like array.
|
|
2844
|
+
*/
|
|
2845
|
+
function readWebGLPixels(gl) {
|
|
2846
|
+
const pixels = new Uint8Array(gl.canvas.width * gl.canvas.height * 4);
|
|
2847
|
+
gl.readPixels(0, 0, gl.canvas.width, gl.canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
2848
|
+
return pixels;
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Hash a Uint8Array of pixel data into a hex string.
|
|
2852
|
+
*/
|
|
2853
|
+
function hashPixels(pixels) {
|
|
2854
|
+
let hash = 0;
|
|
2855
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
2856
|
+
const r = (pixels[i] ?? 0);
|
|
2857
|
+
const g = (pixels[i + 1] ?? 0) * 256;
|
|
2858
|
+
const b = (pixels[i + 2] ?? 0) * 65536;
|
|
2859
|
+
const a = (pixels[i + 3] ?? 0) * 16777216;
|
|
2860
|
+
hash = (hash * 33 + r + g + b + a) >>> 0;
|
|
2861
|
+
}
|
|
2862
|
+
return hash.toString(16);
|
|
2863
|
+
}
|
|
2669
2864
|
/**
|
|
2670
2865
|
* Render 3D scene to generate GPU-specific hash
|
|
2671
2866
|
* Based on FingerprintJS methodology for maximum differentiation
|
|
@@ -2757,26 +2952,67 @@ function render3DScene(gl) {
|
|
|
2757
2952
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
2758
2953
|
// Draw triangles
|
|
2759
2954
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
2760
|
-
//
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
const
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2955
|
+
// ANTI-RANDOMIZATION: If browser injects per-pixel noise (Brave/Samsung),
|
|
2956
|
+
// render 3× on separate canvases and take per-channel consensus before hashing.
|
|
2957
|
+
let renderHash;
|
|
2958
|
+
const needsConsensus = isBrave() || isSamsungInternet$1();
|
|
2959
|
+
if (needsConsensus) {
|
|
2960
|
+
const w = gl.canvas.width;
|
|
2961
|
+
const h = gl.canvas.height;
|
|
2962
|
+
const pixelsArr = [readWebGLPixels(gl)];
|
|
2963
|
+
// Render 2 more times on separate canvases (each needs its own GL resources)
|
|
2964
|
+
for (let run = 0; run < 2; run++) {
|
|
2965
|
+
const c = document.createElement("canvas");
|
|
2966
|
+
c.width = w;
|
|
2967
|
+
c.height = h;
|
|
2968
|
+
const g = c.getContext("webgl");
|
|
2969
|
+
if (!g)
|
|
2970
|
+
continue;
|
|
2971
|
+
const vSrc = vertexShaderSource;
|
|
2972
|
+
const fSrc = fragmentShaderSource;
|
|
2973
|
+
const vs = createShader(g, g.VERTEX_SHADER, vSrc);
|
|
2974
|
+
const fs = createShader(g, g.FRAGMENT_SHADER, fSrc);
|
|
2975
|
+
if (!vs || !fs)
|
|
2976
|
+
continue;
|
|
2977
|
+
const prog = createProgram(g, vs, fs);
|
|
2978
|
+
if (!prog)
|
|
2979
|
+
continue;
|
|
2980
|
+
g.useProgram(prog);
|
|
2981
|
+
const buf = g.createBuffer();
|
|
2982
|
+
g.bindBuffer(g.ARRAY_BUFFER, buf);
|
|
2983
|
+
g.bufferData(g.ARRAY_BUFFER, vertices, g.STATIC_DRAW);
|
|
2984
|
+
const posLoc = g.getAttribLocation(prog, "a_position");
|
|
2985
|
+
const colLoc = g.getAttribLocation(prog, "a_color");
|
|
2986
|
+
const timeLoc = g.getUniformLocation(prog, "u_time");
|
|
2987
|
+
g.enableVertexAttribArray(posLoc);
|
|
2988
|
+
g.enableVertexAttribArray(colLoc);
|
|
2989
|
+
g.vertexAttribPointer(posLoc, 2, g.FLOAT, false, 20, 0);
|
|
2990
|
+
g.vertexAttribPointer(colLoc, 3, g.FLOAT, false, 20, 8);
|
|
2991
|
+
g.uniform1f(timeLoc, 1.23456789);
|
|
2992
|
+
g.enable(g.BLEND);
|
|
2993
|
+
g.blendFunc(g.SRC_ALPHA, g.ONE_MINUS_SRC_ALPHA);
|
|
2994
|
+
g.clearColor(0.1, 0.2, 0.3, 1.0);
|
|
2995
|
+
g.clear(g.COLOR_BUFFER_BIT);
|
|
2996
|
+
g.drawArrays(g.TRIANGLES, 0, 6);
|
|
2997
|
+
pixelsArr.push(readWebGLPixels(g));
|
|
2998
|
+
g.deleteProgram(prog);
|
|
2999
|
+
g.deleteShader(vs);
|
|
3000
|
+
g.deleteShader(fs);
|
|
3001
|
+
g.deleteBuffer(buf);
|
|
3002
|
+
}
|
|
3003
|
+
const images = pixelsArr.map((p) => new ImageData(new Uint8ClampedArray(p), w, h));
|
|
3004
|
+
const consensus = getCommonPixels(images, w, h);
|
|
3005
|
+
renderHash = hashPixels(new Uint8Array(consensus.data));
|
|
3006
|
+
}
|
|
3007
|
+
else {
|
|
3008
|
+
renderHash = hashPixels(readWebGLPixels(gl));
|
|
2773
3009
|
}
|
|
2774
3010
|
// Cleanup
|
|
2775
3011
|
gl.deleteProgram(program);
|
|
2776
3012
|
gl.deleteShader(vertexShader);
|
|
2777
3013
|
gl.deleteShader(fragmentShader);
|
|
2778
3014
|
gl.deleteBuffer(buffer);
|
|
2779
|
-
return
|
|
3015
|
+
return renderHash;
|
|
2780
3016
|
}
|
|
2781
3017
|
catch (error) {
|
|
2782
3018
|
// Fallback to basic parameters hash if rendering fails
|
|
@@ -3368,6 +3604,30 @@ function getColorScheme() {
|
|
|
3368
3604
|
return 'unknown';
|
|
3369
3605
|
}
|
|
3370
3606
|
}
|
|
3607
|
+
function getScriptingSupport() {
|
|
3608
|
+
try {
|
|
3609
|
+
for (const v of ['enabled', 'initial-only', 'none']) {
|
|
3610
|
+
if (safeMatchMedia(`(scripting: ${v})`)?.matches)
|
|
3611
|
+
return v;
|
|
3612
|
+
}
|
|
3613
|
+
return 'unknown';
|
|
3614
|
+
}
|
|
3615
|
+
catch {
|
|
3616
|
+
return 'unknown';
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
function getUpdateFrequency() {
|
|
3620
|
+
try {
|
|
3621
|
+
for (const v of ['fast', 'slow', 'none']) {
|
|
3622
|
+
if (safeMatchMedia(`(update: ${v})`)?.matches)
|
|
3623
|
+
return v;
|
|
3624
|
+
}
|
|
3625
|
+
return 'unknown';
|
|
3626
|
+
}
|
|
3627
|
+
catch {
|
|
3628
|
+
return 'unknown';
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3371
3631
|
function getAccessibilityFingerprint() {
|
|
3372
3632
|
// Original properties
|
|
3373
3633
|
const colorGamut = getColorGamut$1();
|
|
@@ -3388,6 +3648,9 @@ function getAccessibilityFingerprint() {
|
|
|
3388
3648
|
const anyHover = getAnyHover();
|
|
3389
3649
|
const anyPointer = getAnyPointer();
|
|
3390
3650
|
const colorScheme = getColorScheme();
|
|
3651
|
+
// ThumbmarkJS-inspired additions
|
|
3652
|
+
const scripting = getScriptingSupport();
|
|
3653
|
+
const updateFrequency = getUpdateFrequency();
|
|
3391
3654
|
// Determine if user has accessibility features enabled
|
|
3392
3655
|
const hasAccessibilityFeatures = Boolean(forcedColors ||
|
|
3393
3656
|
reducedMotion ||
|
|
@@ -3434,6 +3697,9 @@ function getAccessibilityFingerprint() {
|
|
|
3434
3697
|
anyHover,
|
|
3435
3698
|
anyPointer,
|
|
3436
3699
|
colorScheme,
|
|
3700
|
+
// ThumbmarkJS-inspired additions
|
|
3701
|
+
scripting,
|
|
3702
|
+
updateFrequency,
|
|
3437
3703
|
// Computed properties
|
|
3438
3704
|
hasAccessibilityFeatures,
|
|
3439
3705
|
displayCapabilities,
|
|
@@ -3526,6 +3792,8 @@ var accessibility = /*#__PURE__*/Object.freeze({
|
|
|
3526
3792
|
getReducedData: getReducedData,
|
|
3527
3793
|
getReducedMotion: getReducedMotion,
|
|
3528
3794
|
getReducedTransparency: getReducedTransparency,
|
|
3795
|
+
getScriptingSupport: getScriptingSupport,
|
|
3796
|
+
getUpdateFrequency: getUpdateFrequency,
|
|
3529
3797
|
isAccessibilityDetectionAvailable: isAccessibilityDetectionAvailable
|
|
3530
3798
|
});
|
|
3531
3799
|
|
|
@@ -4813,6 +5081,15 @@ function getEnhancedFontFingerprintHash(fingerprint) {
|
|
|
4813
5081
|
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=531915
|
|
4814
5082
|
*/
|
|
4815
5083
|
const M = Math; // Minification optimization
|
|
5084
|
+
// Midpoint-rule numerical integrator — same as ThumbmarkJS
|
|
5085
|
+
function integrate(f, a, b, n) {
|
|
5086
|
+
const h = (b - a) / n;
|
|
5087
|
+
let sum = 0;
|
|
5088
|
+
for (let i = 0; i < n; i++) {
|
|
5089
|
+
sum += f(a + (i + 0.5) * h);
|
|
5090
|
+
}
|
|
5091
|
+
return sum * h;
|
|
5092
|
+
}
|
|
4816
5093
|
const fallbackFn = () => 0;
|
|
4817
5094
|
/**
|
|
4818
5095
|
* Enhanced math fingerprinting with additional precision tests
|
|
@@ -4914,7 +5191,16 @@ function getMathFingerprint$1() {
|
|
|
4914
5191
|
float64: testFloat64(),
|
|
4915
5192
|
bigNumbers: testBigNumbers(),
|
|
4916
5193
|
smallNumbers: testSmallNumbers(),
|
|
4917
|
-
}
|
|
5194
|
+
},
|
|
5195
|
+
// Large-number edge cases — vary by engine (V8 vs SpiderMonkey vs JavaScriptCore)
|
|
5196
|
+
largeCos: M.cos(1e20),
|
|
5197
|
+
largeSin: M.sin(1e20),
|
|
5198
|
+
largeTan: M.tan(1e20),
|
|
5199
|
+
// Accumulated precision differences over 97 midpoints
|
|
5200
|
+
integratedAsin: integrate(M.asin, -1, 1, 97),
|
|
5201
|
+
integratedCos: integrate(M.cos, 0, M.PI, 97),
|
|
5202
|
+
integratedSin: integrate(M.sin, -M.PI, M.PI, 97),
|
|
5203
|
+
integratedTan: integrate(M.tan, 0, 2 * M.PI, 97),
|
|
4918
5204
|
};
|
|
4919
5205
|
}
|
|
4920
5206
|
/**
|
|
@@ -4983,6 +5269,1161 @@ function analyzeMathFingerprint(mathData) {
|
|
|
4983
5269
|
};
|
|
4984
5270
|
}
|
|
4985
5271
|
|
|
5272
|
+
/**
|
|
5273
|
+
* WebRTC Fingerprinting
|
|
5274
|
+
* Based on ThumbmarkJS WebRTC component
|
|
5275
|
+
*
|
|
5276
|
+
* WebRTC is one of the richest sources of entropy: the SDP offer exposes
|
|
5277
|
+
* codec lists, RTP extensions and ICE candidate type, which vary by browser
|
|
5278
|
+
* version, OS and hardware in a stable, non-randomizable way.
|
|
5279
|
+
*/
|
|
5280
|
+
/**
|
|
5281
|
+
* Extract codec descriptions from a WebRTC SDP string for a given media type.
|
|
5282
|
+
*/
|
|
5283
|
+
function parseCodecs(sdp, mediaType) {
|
|
5284
|
+
const match = sdp.match(new RegExp(`m=${mediaType} [^\\s]+ [^\\s]+ ([^\\n\\r]+)`));
|
|
5285
|
+
const descriptors = match ? match[1].split(" ") : [];
|
|
5286
|
+
return descriptors
|
|
5287
|
+
.map((descriptor) => {
|
|
5288
|
+
const matcher = new RegExp(`(rtpmap|fmtp|rtcp-fb):${descriptor} (.+)`, "g");
|
|
5289
|
+
const matches = [...sdp.matchAll(matcher)];
|
|
5290
|
+
if (!matches.length)
|
|
5291
|
+
return null;
|
|
5292
|
+
const desc = {};
|
|
5293
|
+
for (const m of matches) {
|
|
5294
|
+
const type = m[1];
|
|
5295
|
+
const data = m[2];
|
|
5296
|
+
const parts = data.split("/");
|
|
5297
|
+
if (type === "rtpmap") {
|
|
5298
|
+
desc.mimeType = `${mediaType}/${parts[0]}`;
|
|
5299
|
+
desc.clockRate = Number(parts[1]);
|
|
5300
|
+
if (mediaType === "audio")
|
|
5301
|
+
desc.channels = Number(parts[2]) || 1;
|
|
5302
|
+
}
|
|
5303
|
+
else if (type === "rtcp-fb") {
|
|
5304
|
+
desc.feedbackSupport ??
|
|
5305
|
+
(desc.feedbackSupport = []);
|
|
5306
|
+
desc.feedbackSupport.push(data);
|
|
5307
|
+
}
|
|
5308
|
+
else if (type === "fmtp") {
|
|
5309
|
+
desc.sdpFmtpLine = data;
|
|
5310
|
+
}
|
|
5311
|
+
}
|
|
5312
|
+
return desc;
|
|
5313
|
+
})
|
|
5314
|
+
.filter(Boolean);
|
|
5315
|
+
}
|
|
5316
|
+
/**
|
|
5317
|
+
* Collect WebRTC fingerprint by creating an offer and analysing its SDP.
|
|
5318
|
+
* Timeout defaults to 90% of global fingerprint timeout (≈4500 ms).
|
|
5319
|
+
*/
|
|
5320
|
+
async function getWebRTCFingerprint(timeout = 4500) {
|
|
5321
|
+
const startTime = performance.now();
|
|
5322
|
+
return new Promise((resolve) => {
|
|
5323
|
+
try {
|
|
5324
|
+
const RTC = window.RTCPeerConnection ||
|
|
5325
|
+
window.webkitRTCPeerConnection ||
|
|
5326
|
+
window.mozRTCPeerConnection;
|
|
5327
|
+
if (!RTC) {
|
|
5328
|
+
resolve({
|
|
5329
|
+
value: {
|
|
5330
|
+
supported: false,
|
|
5331
|
+
extensionsHash: "unsupported",
|
|
5332
|
+
audio: { count: 0, hash: "unsupported" },
|
|
5333
|
+
video: { count: 0, hash: "unsupported" },
|
|
5334
|
+
candidateType: null,
|
|
5335
|
+
},
|
|
5336
|
+
duration: performance.now() - startTime,
|
|
5337
|
+
});
|
|
5338
|
+
return;
|
|
5339
|
+
}
|
|
5340
|
+
const pc = new RTC({
|
|
5341
|
+
iceCandidatePoolSize: 1,
|
|
5342
|
+
iceServers: [],
|
|
5343
|
+
});
|
|
5344
|
+
pc.createDataChannel("");
|
|
5345
|
+
(async () => {
|
|
5346
|
+
try {
|
|
5347
|
+
const offer = await pc.createOffer({
|
|
5348
|
+
offerToReceiveAudio: true,
|
|
5349
|
+
offerToReceiveVideo: true,
|
|
5350
|
+
});
|
|
5351
|
+
await pc.setLocalDescription(offer);
|
|
5352
|
+
const sdp = offer.sdp ?? "";
|
|
5353
|
+
// RTP extensions — sorted for stability
|
|
5354
|
+
const extensions = [
|
|
5355
|
+
...new Set((sdp.match(/extmap:\d+ [^\n\r]+/g) ?? []).map((x) => x.replace(/extmap:\d+ /, ""))),
|
|
5356
|
+
].sort();
|
|
5357
|
+
const audioCodecs = parseCodecs(sdp, "audio");
|
|
5358
|
+
const videoCodecs = parseCodecs(sdp, "video");
|
|
5359
|
+
const partialResult = {
|
|
5360
|
+
extensionsHash: hash32(stableStringify(extensions)),
|
|
5361
|
+
audio: {
|
|
5362
|
+
count: audioCodecs.length,
|
|
5363
|
+
hash: hash32(stableStringify(audioCodecs)),
|
|
5364
|
+
},
|
|
5365
|
+
video: {
|
|
5366
|
+
count: videoCodecs.length,
|
|
5367
|
+
hash: hash32(stableStringify(videoCodecs)),
|
|
5368
|
+
},
|
|
5369
|
+
};
|
|
5370
|
+
// Wait for first ICE candidate (reveals candidateType)
|
|
5371
|
+
const iceTimeout = Math.floor(timeout * 0.9);
|
|
5372
|
+
const candidateType = await new Promise((resolveIce) => {
|
|
5373
|
+
const t = setTimeout(() => {
|
|
5374
|
+
pc.removeEventListener("icecandidate", onIce);
|
|
5375
|
+
pc.close();
|
|
5376
|
+
resolveIce(null);
|
|
5377
|
+
}, iceTimeout);
|
|
5378
|
+
const onIce = (evt) => {
|
|
5379
|
+
if (!evt.candidate?.candidate)
|
|
5380
|
+
return;
|
|
5381
|
+
clearTimeout(t);
|
|
5382
|
+
pc.removeEventListener("icecandidate", onIce);
|
|
5383
|
+
pc.close();
|
|
5384
|
+
resolveIce(evt.candidate.type ?? null);
|
|
5385
|
+
};
|
|
5386
|
+
pc.addEventListener("icecandidate", onIce);
|
|
5387
|
+
});
|
|
5388
|
+
resolve({
|
|
5389
|
+
value: {
|
|
5390
|
+
supported: true,
|
|
5391
|
+
...partialResult,
|
|
5392
|
+
candidateType,
|
|
5393
|
+
timedOut: candidateType === null,
|
|
5394
|
+
},
|
|
5395
|
+
duration: performance.now() - startTime,
|
|
5396
|
+
});
|
|
5397
|
+
}
|
|
5398
|
+
catch (err) {
|
|
5399
|
+
pc.close();
|
|
5400
|
+
resolve({
|
|
5401
|
+
value: {
|
|
5402
|
+
supported: true,
|
|
5403
|
+
extensionsHash: "offer-error",
|
|
5404
|
+
audio: { count: 0, hash: "offer-error" },
|
|
5405
|
+
video: { count: 0, hash: "offer-error" },
|
|
5406
|
+
candidateType: null,
|
|
5407
|
+
},
|
|
5408
|
+
duration: performance.now() - startTime,
|
|
5409
|
+
error: err instanceof Error ? err.message : "WebRTC offer failed",
|
|
5410
|
+
});
|
|
5411
|
+
}
|
|
5412
|
+
})();
|
|
5413
|
+
}
|
|
5414
|
+
catch (err) {
|
|
5415
|
+
resolve({
|
|
5416
|
+
value: {
|
|
5417
|
+
supported: false,
|
|
5418
|
+
extensionsHash: "error",
|
|
5419
|
+
audio: { count: 0, hash: "error" },
|
|
5420
|
+
video: { count: 0, hash: "error" },
|
|
5421
|
+
candidateType: null,
|
|
5422
|
+
},
|
|
5423
|
+
duration: performance.now() - startTime,
|
|
5424
|
+
error: err instanceof Error ? err.message : "WebRTC error",
|
|
5425
|
+
});
|
|
5426
|
+
}
|
|
5427
|
+
});
|
|
5428
|
+
}
|
|
5429
|
+
/**
|
|
5430
|
+
* Check if WebRTC fingerprinting is available in this environment.
|
|
5431
|
+
*/
|
|
5432
|
+
function isWebRTCAvailable() {
|
|
5433
|
+
try {
|
|
5434
|
+
return !!(window.RTCPeerConnection ||
|
|
5435
|
+
window.webkitRTCPeerConnection ||
|
|
5436
|
+
window.mozRTCPeerConnection);
|
|
5437
|
+
}
|
|
5438
|
+
catch {
|
|
5439
|
+
return false;
|
|
5440
|
+
}
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
/**
|
|
5444
|
+
* Permissions API Fingerprinting
|
|
5445
|
+
* Based on ThumbmarkJS permissions component
|
|
5446
|
+
*
|
|
5447
|
+
* Permission states (granted / denied / prompt) vary by browser, OS version,
|
|
5448
|
+
* site origin and user profile in a stable way — 25 signals for low cost.
|
|
5449
|
+
* To cancel out race conditions the query is run 3× in parallel and the
|
|
5450
|
+
* most-frequent value per permission is selected.
|
|
5451
|
+
*/
|
|
5452
|
+
// 25-item list from ThumbmarkJS
|
|
5453
|
+
const PERMISSION_KEYS = [
|
|
5454
|
+
"accelerometer",
|
|
5455
|
+
"accessibility",
|
|
5456
|
+
"accessibility-events",
|
|
5457
|
+
"ambient-light-sensor",
|
|
5458
|
+
"background-fetch",
|
|
5459
|
+
"background-sync",
|
|
5460
|
+
"bluetooth",
|
|
5461
|
+
"camera",
|
|
5462
|
+
"clipboard-read",
|
|
5463
|
+
"clipboard-write",
|
|
5464
|
+
"device-info",
|
|
5465
|
+
"display-capture",
|
|
5466
|
+
"geolocation",
|
|
5467
|
+
"gyroscope",
|
|
5468
|
+
"local-fonts",
|
|
5469
|
+
"magnetometer",
|
|
5470
|
+
"microphone",
|
|
5471
|
+
"midi",
|
|
5472
|
+
"nfc",
|
|
5473
|
+
"notifications",
|
|
5474
|
+
"payment-handler",
|
|
5475
|
+
"persistent-storage",
|
|
5476
|
+
"push",
|
|
5477
|
+
"speaker",
|
|
5478
|
+
"storage-access",
|
|
5479
|
+
"top-level-storage-access",
|
|
5480
|
+
"window-management",
|
|
5481
|
+
];
|
|
5482
|
+
/**
|
|
5483
|
+
* Query all permissions once and return the result map.
|
|
5484
|
+
*/
|
|
5485
|
+
async function queryPermissionsOnce() {
|
|
5486
|
+
const result = {};
|
|
5487
|
+
await Promise.allSettled(PERMISSION_KEYS.map(async (key) => {
|
|
5488
|
+
try {
|
|
5489
|
+
const status = await navigator.permissions.query({
|
|
5490
|
+
name: key,
|
|
5491
|
+
});
|
|
5492
|
+
result[key] = status.state;
|
|
5493
|
+
}
|
|
5494
|
+
catch {
|
|
5495
|
+
// Unsupported permission — omit
|
|
5496
|
+
}
|
|
5497
|
+
}));
|
|
5498
|
+
return result;
|
|
5499
|
+
}
|
|
5500
|
+
/**
|
|
5501
|
+
* Return the most-frequent string value across an array.
|
|
5502
|
+
*/
|
|
5503
|
+
function mostFrequent(values) {
|
|
5504
|
+
if (values.length === 0)
|
|
5505
|
+
return "unknown";
|
|
5506
|
+
const freq = {};
|
|
5507
|
+
for (const v of values)
|
|
5508
|
+
freq[v] = (freq[v] ?? 0) + 1;
|
|
5509
|
+
let best = values[0];
|
|
5510
|
+
for (const k in freq) {
|
|
5511
|
+
if (freq[k] > freq[best])
|
|
5512
|
+
best = k;
|
|
5513
|
+
}
|
|
5514
|
+
return best;
|
|
5515
|
+
}
|
|
5516
|
+
/**
|
|
5517
|
+
* Collect Permissions fingerprint.
|
|
5518
|
+
* Runs 3 parallel queries and returns the per-permission consensus to
|
|
5519
|
+
* eliminate any transient state fluctuations.
|
|
5520
|
+
*/
|
|
5521
|
+
async function getPermissionsFingerprint() {
|
|
5522
|
+
const startTime = performance.now();
|
|
5523
|
+
try {
|
|
5524
|
+
if (!navigator?.permissions?.query) {
|
|
5525
|
+
return {
|
|
5526
|
+
value: {},
|
|
5527
|
+
duration: performance.now() - startTime,
|
|
5528
|
+
error: "Permissions API not available",
|
|
5529
|
+
};
|
|
5530
|
+
}
|
|
5531
|
+
// Run 3 times in parallel for consensus
|
|
5532
|
+
const runs = await Promise.all([
|
|
5533
|
+
queryPermissionsOnce(),
|
|
5534
|
+
queryPermissionsOnce(),
|
|
5535
|
+
queryPermissionsOnce(),
|
|
5536
|
+
]);
|
|
5537
|
+
// Merge: pick most frequent value per key
|
|
5538
|
+
const merged = {};
|
|
5539
|
+
for (const key of PERMISSION_KEYS) {
|
|
5540
|
+
const values = runs
|
|
5541
|
+
.map((r) => r[key])
|
|
5542
|
+
.filter((v) => v !== undefined);
|
|
5543
|
+
if (values.length > 0) {
|
|
5544
|
+
merged[key] = mostFrequent(values);
|
|
5545
|
+
}
|
|
5546
|
+
}
|
|
5547
|
+
return {
|
|
5548
|
+
value: merged,
|
|
5549
|
+
duration: performance.now() - startTime,
|
|
5550
|
+
};
|
|
5551
|
+
}
|
|
5552
|
+
catch (err) {
|
|
5553
|
+
return {
|
|
5554
|
+
value: {},
|
|
5555
|
+
duration: performance.now() - startTime,
|
|
5556
|
+
error: err instanceof Error ? err.message : "Permissions fingerprinting failed",
|
|
5557
|
+
};
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5560
|
+
/**
|
|
5561
|
+
* Check if Permissions API fingerprinting is available.
|
|
5562
|
+
*/
|
|
5563
|
+
function isPermissionsAvailable() {
|
|
5564
|
+
try {
|
|
5565
|
+
return (typeof navigator !== "undefined" &&
|
|
5566
|
+
typeof navigator.permissions?.query === "function");
|
|
5567
|
+
}
|
|
5568
|
+
catch {
|
|
5569
|
+
return false;
|
|
5570
|
+
}
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
/**
|
|
5574
|
+
* Speech Synthesis Fingerprinting
|
|
5575
|
+
* Based on ThumbmarkJS speech component
|
|
5576
|
+
*
|
|
5577
|
+
* The list of system voices (Speech Synthesis API) is stable per OS/version/locale
|
|
5578
|
+
* and provides extra entropy at zero user-visible cost.
|
|
5579
|
+
* Voices may load asynchronously, so we listen for `voiceschanged` with an 800 ms fallback.
|
|
5580
|
+
*/
|
|
5581
|
+
const VOICE_LOAD_TIMEOUT_MS = 800;
|
|
5582
|
+
/**
|
|
5583
|
+
* Escape commas and backslashes inside a voice property string.
|
|
5584
|
+
*/
|
|
5585
|
+
function escapeValue(v) {
|
|
5586
|
+
return v.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* Build a canonical sorted signature array from a list of voices.
|
|
5590
|
+
*/
|
|
5591
|
+
function buildSignatures(voices) {
|
|
5592
|
+
return voices
|
|
5593
|
+
.map((v) => [
|
|
5594
|
+
escapeValue(v.voiceURI ?? ""),
|
|
5595
|
+
escapeValue(v.name ?? ""),
|
|
5596
|
+
escapeValue(v.lang ?? ""),
|
|
5597
|
+
v.localService ? "1" : "0",
|
|
5598
|
+
v.default ? "1" : "0",
|
|
5599
|
+
].join(","))
|
|
5600
|
+
.sort();
|
|
5601
|
+
}
|
|
5602
|
+
/**
|
|
5603
|
+
* Collect Speech Synthesis fingerprint.
|
|
5604
|
+
* Experimental: not included in `stableCoreHash` by default.
|
|
5605
|
+
*/
|
|
5606
|
+
async function getSpeechFingerprint() {
|
|
5607
|
+
const startTime = performance.now();
|
|
5608
|
+
return new Promise((resolve) => {
|
|
5609
|
+
try {
|
|
5610
|
+
if (typeof window === "undefined" ||
|
|
5611
|
+
!window.speechSynthesis ||
|
|
5612
|
+
typeof window.speechSynthesis.getVoices !== "function") {
|
|
5613
|
+
resolve({
|
|
5614
|
+
value: { supported: false, voiceCount: 0, voicesHash: "unsupported" },
|
|
5615
|
+
duration: performance.now() - startTime,
|
|
5616
|
+
error: "Speech Synthesis API not supported",
|
|
5617
|
+
});
|
|
5618
|
+
return;
|
|
5619
|
+
}
|
|
5620
|
+
let resolved = false;
|
|
5621
|
+
let timeoutHandle = null;
|
|
5622
|
+
const processVoices = (voices) => {
|
|
5623
|
+
if (resolved)
|
|
5624
|
+
return;
|
|
5625
|
+
resolved = true;
|
|
5626
|
+
if (timeoutHandle)
|
|
5627
|
+
clearTimeout(timeoutHandle);
|
|
5628
|
+
try {
|
|
5629
|
+
const signatures = buildSignatures(voices);
|
|
5630
|
+
resolve({
|
|
5631
|
+
value: {
|
|
5632
|
+
supported: true,
|
|
5633
|
+
voiceCount: voices.length,
|
|
5634
|
+
voicesHash: hash32(stableStringify(signatures)),
|
|
5635
|
+
},
|
|
5636
|
+
duration: performance.now() - startTime,
|
|
5637
|
+
});
|
|
5638
|
+
}
|
|
5639
|
+
catch (err) {
|
|
5640
|
+
resolve({
|
|
5641
|
+
value: { supported: true, voiceCount: 0, voicesHash: "error" },
|
|
5642
|
+
duration: performance.now() - startTime,
|
|
5643
|
+
error: err instanceof Error ? err.message : "Voice processing failed",
|
|
5644
|
+
});
|
|
5645
|
+
}
|
|
5646
|
+
};
|
|
5647
|
+
// Immediate attempt (works in Chrome)
|
|
5648
|
+
const voices = window.speechSynthesis.getVoices();
|
|
5649
|
+
if (voices.length > 0) {
|
|
5650
|
+
processVoices(voices);
|
|
5651
|
+
return;
|
|
5652
|
+
}
|
|
5653
|
+
// Fallback timeout (Firefox/Safari load voices asynchronously)
|
|
5654
|
+
timeoutHandle = setTimeout(() => {
|
|
5655
|
+
processVoices(window.speechSynthesis.getVoices());
|
|
5656
|
+
}, VOICE_LOAD_TIMEOUT_MS);
|
|
5657
|
+
// voiceschanged event (most browsers fire this when voices are ready)
|
|
5658
|
+
const onChanged = () => {
|
|
5659
|
+
window.speechSynthesis.removeEventListener("voiceschanged", onChanged);
|
|
5660
|
+
processVoices(window.speechSynthesis.getVoices());
|
|
5661
|
+
};
|
|
5662
|
+
window.speechSynthesis.addEventListener("voiceschanged", onChanged);
|
|
5663
|
+
}
|
|
5664
|
+
catch (err) {
|
|
5665
|
+
resolve({
|
|
5666
|
+
value: { supported: false, voiceCount: 0, voicesHash: "error" },
|
|
5667
|
+
duration: performance.now() - startTime,
|
|
5668
|
+
error: err instanceof Error ? err.message : "Speech synthesis error",
|
|
5669
|
+
});
|
|
5670
|
+
}
|
|
5671
|
+
});
|
|
5672
|
+
}
|
|
5673
|
+
/**
|
|
5674
|
+
* Check if Speech Synthesis fingerprinting is available.
|
|
5675
|
+
*/
|
|
5676
|
+
function isSpeechAvailable() {
|
|
5677
|
+
try {
|
|
5678
|
+
return (typeof window !== "undefined" &&
|
|
5679
|
+
typeof window.speechSynthesis?.getVoices === "function");
|
|
5680
|
+
}
|
|
5681
|
+
catch {
|
|
5682
|
+
return false;
|
|
5683
|
+
}
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5686
|
+
/**
|
|
5687
|
+
* Stabilization Rules for Fingerprint Components
|
|
5688
|
+
* Based on ThumbmarkJS stabilizationExclusionRules
|
|
5689
|
+
*
|
|
5690
|
+
* Certain components produce noisy / randomized output in specific browser
|
|
5691
|
+
* contexts (private mode, iframes, always-noisy browsers). This module defines
|
|
5692
|
+
* which components to exclude per context to maximize hash stability.
|
|
5693
|
+
*/
|
|
5694
|
+
/** Port of ThumbmarkJS stabilizationExclusionRules. */
|
|
5695
|
+
const stabilizationRules = {
|
|
5696
|
+
private: [
|
|
5697
|
+
{ exclude: ["canvas"], browsers: ["firefox", "safari", "brave"] },
|
|
5698
|
+
{ exclude: ["audio"], browsers: ["safari", "samsung"] },
|
|
5699
|
+
{ exclude: ["fonts"], browsers: ["firefox"] },
|
|
5700
|
+
{
|
|
5701
|
+
exclude: ["hardware", "pluginsEnhanced"],
|
|
5702
|
+
browsers: ["brave"],
|
|
5703
|
+
},
|
|
5704
|
+
],
|
|
5705
|
+
iframe: [
|
|
5706
|
+
{ exclude: ["system"], browsers: ["safari"] },
|
|
5707
|
+
{ exclude: ["permissions"] }, // All browsers
|
|
5708
|
+
],
|
|
5709
|
+
always: [
|
|
5710
|
+
{ exclude: ["speech"], browsers: ["brave", "firefox"] },
|
|
5711
|
+
],
|
|
5712
|
+
};
|
|
5713
|
+
/**
|
|
5714
|
+
* Detect the current browser name for rule matching.
|
|
5715
|
+
*/
|
|
5716
|
+
function getCurrentBrowser() {
|
|
5717
|
+
try {
|
|
5718
|
+
if (isBrave())
|
|
5719
|
+
return "brave";
|
|
5720
|
+
if (isFirefox())
|
|
5721
|
+
return "firefox";
|
|
5722
|
+
if (isSafari())
|
|
5723
|
+
return "safari";
|
|
5724
|
+
if (isSamsungInternet$1())
|
|
5725
|
+
return "samsung";
|
|
5726
|
+
// Edge / Chrome fall through as "other" — rules that target them can be added later
|
|
5727
|
+
return "other";
|
|
5728
|
+
}
|
|
5729
|
+
catch {
|
|
5730
|
+
return "other";
|
|
5731
|
+
}
|
|
5732
|
+
}
|
|
5733
|
+
/**
|
|
5734
|
+
* Detect whether the page is running inside an iframe.
|
|
5735
|
+
*/
|
|
5736
|
+
function isInIframe() {
|
|
5737
|
+
try {
|
|
5738
|
+
return window !== window.top;
|
|
5739
|
+
}
|
|
5740
|
+
catch {
|
|
5741
|
+
return true; // Cross-origin frame access throws — treat as iframe
|
|
5742
|
+
}
|
|
5743
|
+
}
|
|
5744
|
+
/**
|
|
5745
|
+
* Given the incognito likelihood (0-1) from `incognitoDetection`, decide
|
|
5746
|
+
* whether to treat the session as private mode.
|
|
5747
|
+
*/
|
|
5748
|
+
function isLikelyPrivate(incognitoLikelihood) {
|
|
5749
|
+
// Threshold: probability ≥ 0.6 → apply private-mode exclusions
|
|
5750
|
+
return (incognitoLikelihood ?? 0) >= 0.6;
|
|
5751
|
+
}
|
|
5752
|
+
/**
|
|
5753
|
+
* Returns the set of component keys that should be nulled out / excluded from
|
|
5754
|
+
* hashing given the current context.
|
|
5755
|
+
*
|
|
5756
|
+
* @param incognitoLikelihood - Output of incognito detection (0-1). Pass 0 if unavailable.
|
|
5757
|
+
*/
|
|
5758
|
+
function getExcludedComponents(incognitoLikelihood) {
|
|
5759
|
+
const browser = getCurrentBrowser();
|
|
5760
|
+
const excluded = new Set();
|
|
5761
|
+
const applyRules = (rules) => {
|
|
5762
|
+
for (const rule of rules) {
|
|
5763
|
+
if (!rule.browsers ||
|
|
5764
|
+
rule.browsers.includes(browser)) {
|
|
5765
|
+
for (const key of rule.exclude) {
|
|
5766
|
+
excluded.add(key);
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
};
|
|
5771
|
+
// Always-active exclusions
|
|
5772
|
+
applyRules(stabilizationRules.always);
|
|
5773
|
+
// Iframe exclusions
|
|
5774
|
+
if (isInIframe()) {
|
|
5775
|
+
applyRules(stabilizationRules.iframe);
|
|
5776
|
+
}
|
|
5777
|
+
// Private-mode exclusions
|
|
5778
|
+
if (isLikelyPrivate(incognitoLikelihood)) {
|
|
5779
|
+
applyRules(stabilizationRules.private);
|
|
5780
|
+
}
|
|
5781
|
+
return excluded;
|
|
5782
|
+
}
|
|
5783
|
+
|
|
5784
|
+
/**
|
|
5785
|
+
* CSS @supports Feature Vector Fingerprinting
|
|
5786
|
+
*
|
|
5787
|
+
* Tests ~40 modern CSS features via CSS.supports() to produce a stable
|
|
5788
|
+
* boolean vector (~50 bits of entropy). Síncrono, zero custo, estável por
|
|
5789
|
+
* versão de browser. Different browsers differ on ~10-15 features, making
|
|
5790
|
+
* this a strong discriminator for browser family + version.
|
|
5791
|
+
*/
|
|
5792
|
+
/**
|
|
5793
|
+
* CSS features to test — [key, CSS declaration or condition string]
|
|
5794
|
+
* For selector() and condition-text forms we use the single-argument overload.
|
|
5795
|
+
* For @layer we pass the conditionText form directly.
|
|
5796
|
+
*/
|
|
5797
|
+
const CSS_FEATURES = [
|
|
5798
|
+
// Layout moderno
|
|
5799
|
+
["aspect-ratio", "aspect-ratio: 1"],
|
|
5800
|
+
["container-type", "container-type: inline-size"],
|
|
5801
|
+
["content-visibility", "content-visibility: auto"],
|
|
5802
|
+
["field-sizing", "field-sizing: content"],
|
|
5803
|
+
// CSS Grid avançado
|
|
5804
|
+
["subgrid", "grid-template-columns: subgrid"],
|
|
5805
|
+
["masonry", "grid-template-rows: masonry"],
|
|
5806
|
+
// Seletores
|
|
5807
|
+
["has-selector", "selector(:has(*))"],
|
|
5808
|
+
["is-selector", "selector(:is(*))"],
|
|
5809
|
+
["where-selector", "selector(:where(*))"],
|
|
5810
|
+
["focus-visible", "selector(:focus-visible)"],
|
|
5811
|
+
// Cores modernas
|
|
5812
|
+
["color-mix", "color: color-mix(in srgb, red, blue)"],
|
|
5813
|
+
["oklch", "color: oklch(0.5 0.2 200)"],
|
|
5814
|
+
["relative-color", "color: hsl(from red h s l)"],
|
|
5815
|
+
// Visual
|
|
5816
|
+
["backdrop-filter", "backdrop-filter: blur(10px)"],
|
|
5817
|
+
["text-wrap-balance", "text-wrap: balance"],
|
|
5818
|
+
["text-wrap-pretty", "text-wrap: pretty"],
|
|
5819
|
+
["overscroll-behavior", "overscroll-behavior: none"],
|
|
5820
|
+
["scroll-behavior", "scroll-behavior: smooth"],
|
|
5821
|
+
// View transitions
|
|
5822
|
+
["view-transition", "view-transition-name: auto"],
|
|
5823
|
+
// Animações modernas
|
|
5824
|
+
["animation-timeline", "animation-timeline: scroll()"],
|
|
5825
|
+
["animation-range", "animation-range: 10% 90%"],
|
|
5826
|
+
// Scroll-driven
|
|
5827
|
+
["scroll-timeline", "scroll-timeline: --t block"],
|
|
5828
|
+
// Tipografia
|
|
5829
|
+
["font-variant-numeric", "font-variant-numeric: oldstyle-nums"],
|
|
5830
|
+
["hyphenate-character", "hyphenate-character: auto"],
|
|
5831
|
+
["text-decoration-skip-ink", "text-decoration-skip-ink: auto"],
|
|
5832
|
+
["hanging-punctuation", "hanging-punctuation: first"],
|
|
5833
|
+
// Transforms
|
|
5834
|
+
["individual-transform", "translate: 10px"],
|
|
5835
|
+
["rotate-property", "rotate: 45deg"],
|
|
5836
|
+
// Logical properties
|
|
5837
|
+
["margin-inline", "margin-inline: auto"],
|
|
5838
|
+
["inset", "inset: 0"],
|
|
5839
|
+
// Containment
|
|
5840
|
+
["contain", "contain: layout"],
|
|
5841
|
+
["contain-intrinsic-size", "contain-intrinsic-size: auto"],
|
|
5842
|
+
// Cascade
|
|
5843
|
+
["cascade-layers", "@layer"],
|
|
5844
|
+
["revert-layer", "color: revert-layer"],
|
|
5845
|
+
// Scrollbar
|
|
5846
|
+
["scrollbar-width", "scrollbar-width: thin"],
|
|
5847
|
+
["scrollbar-color", "scrollbar-color: red blue"],
|
|
5848
|
+
// Vendor webkit
|
|
5849
|
+
["webkit-text-stroke", "-webkit-text-stroke: 1px black"],
|
|
5850
|
+
["webkit-line-clamp", "-webkit-line-clamp: 2"],
|
|
5851
|
+
// Nesting CSS
|
|
5852
|
+
["css-nesting", "selector(& .child)"],
|
|
5853
|
+
// Forced colors
|
|
5854
|
+
["forced-colors", "(forced-colors: active)"],
|
|
5855
|
+
];
|
|
5856
|
+
/**
|
|
5857
|
+
* Test a single CSS feature via CSS.supports().
|
|
5858
|
+
* Returns false on any exception (API absent or invalid syntax).
|
|
5859
|
+
*/
|
|
5860
|
+
function testFeature(declaration) {
|
|
5861
|
+
try {
|
|
5862
|
+
// selector(...) and condition-text forms use the single-arg overload
|
|
5863
|
+
if (declaration.startsWith("selector(") ||
|
|
5864
|
+
declaration.startsWith("(") ||
|
|
5865
|
+
declaration.startsWith("@")) {
|
|
5866
|
+
return CSS.supports(declaration);
|
|
5867
|
+
}
|
|
5868
|
+
return CSS.supports(declaration);
|
|
5869
|
+
}
|
|
5870
|
+
catch {
|
|
5871
|
+
return false;
|
|
5872
|
+
}
|
|
5873
|
+
}
|
|
5874
|
+
/**
|
|
5875
|
+
* Produce a simple hex hash from the boolean feature vector.
|
|
5876
|
+
* Uses the feature keys + their boolean values for determinism.
|
|
5877
|
+
*/
|
|
5878
|
+
function hashFeatures(features) {
|
|
5879
|
+
const str = stableStringify(features);
|
|
5880
|
+
let h = 0x811c9dc5;
|
|
5881
|
+
for (let i = 0; i < str.length; i++) {
|
|
5882
|
+
h ^= str.charCodeAt(i);
|
|
5883
|
+
h = (Math.imul(h, 0x01000193) | 0) >>> 0;
|
|
5884
|
+
}
|
|
5885
|
+
return h.toString(16).padStart(8, "0");
|
|
5886
|
+
}
|
|
5887
|
+
/**
|
|
5888
|
+
* Collect CSS @supports feature vector fingerprint.
|
|
5889
|
+
*/
|
|
5890
|
+
function getCSSFeaturesFingerprint() {
|
|
5891
|
+
const startTime = performance.now();
|
|
5892
|
+
try {
|
|
5893
|
+
if (!isCSSFeaturesAvailable()) {
|
|
5894
|
+
return {
|
|
5895
|
+
value: { features: {}, supportedCount: 0, hash: "" },
|
|
5896
|
+
duration: performance.now() - startTime,
|
|
5897
|
+
error: "CSS.supports not available",
|
|
5898
|
+
};
|
|
5899
|
+
}
|
|
5900
|
+
const features = {};
|
|
5901
|
+
for (const [key, declaration] of CSS_FEATURES) {
|
|
5902
|
+
features[key] = testFeature(declaration);
|
|
5903
|
+
}
|
|
5904
|
+
const supportedCount = Object.values(features).filter(Boolean).length;
|
|
5905
|
+
const hash = hashFeatures(features);
|
|
5906
|
+
return {
|
|
5907
|
+
value: { features, supportedCount, hash },
|
|
5908
|
+
duration: performance.now() - startTime,
|
|
5909
|
+
};
|
|
5910
|
+
}
|
|
5911
|
+
catch (err) {
|
|
5912
|
+
return {
|
|
5913
|
+
value: { features: {}, supportedCount: 0, hash: "" },
|
|
5914
|
+
duration: performance.now() - startTime,
|
|
5915
|
+
error: err instanceof Error ? err.message : "CSS features fingerprinting failed",
|
|
5916
|
+
};
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5919
|
+
/**
|
|
5920
|
+
* Check if CSS @supports fingerprinting is available.
|
|
5921
|
+
*/
|
|
5922
|
+
function isCSSFeaturesAvailable() {
|
|
5923
|
+
try {
|
|
5924
|
+
return (typeof CSS !== "undefined" && typeof CSS.supports === "function");
|
|
5925
|
+
}
|
|
5926
|
+
catch {
|
|
5927
|
+
return false;
|
|
5928
|
+
}
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5931
|
+
/**
|
|
5932
|
+
* Media Codec Support Matrix Fingerprinting
|
|
5933
|
+
*
|
|
5934
|
+
* Tests video/audio codec support via HTMLMediaElement.canPlayType() and
|
|
5935
|
+
* MediaRecorder.isTypeSupported(). ~20 bits of entropy, stable per OS +
|
|
5936
|
+
* hardware (hardware decoders vary), synchronous, zero cost.
|
|
5937
|
+
*/
|
|
5938
|
+
// canPlayType — vídeo
|
|
5939
|
+
const VIDEO_TYPES = [
|
|
5940
|
+
'video/mp4; codecs="avc1.42E01E"', // H.264 Baseline
|
|
5941
|
+
'video/mp4; codecs="avc1.4D001E"', // H.264 Main
|
|
5942
|
+
'video/mp4; codecs="hev1.1.6.L93.B0"', // H.265/HEVC
|
|
5943
|
+
'video/mp4; codecs="vp09.00.10.08"', // VP9 in MP4
|
|
5944
|
+
'video/mp4; codecs="av01.0.01M.08"', // AV1 in MP4
|
|
5945
|
+
'video/webm; codecs="vp8"',
|
|
5946
|
+
'video/webm; codecs="vp9"',
|
|
5947
|
+
'video/webm; codecs="av01.0.01M.08"', // AV1 in WebM
|
|
5948
|
+
'video/ogg; codecs="theora"',
|
|
5949
|
+
];
|
|
5950
|
+
// canPlayType — áudio
|
|
5951
|
+
const AUDIO_TYPES = [
|
|
5952
|
+
'audio/mp4; codecs="mp4a.40.2"', // AAC-LC
|
|
5953
|
+
'audio/mp4; codecs="mp4a.40.5"', // HE-AAC
|
|
5954
|
+
'audio/mpeg', // MP3
|
|
5955
|
+
'audio/ogg; codecs="vorbis"',
|
|
5956
|
+
'audio/ogg; codecs="opus"',
|
|
5957
|
+
'audio/webm; codecs="opus"',
|
|
5958
|
+
'audio/wav; codecs="1"', // PCM
|
|
5959
|
+
'audio/flac',
|
|
5960
|
+
'audio/aac',
|
|
5961
|
+
];
|
|
5962
|
+
// MediaRecorder.isTypeSupported
|
|
5963
|
+
const RECORDER_TYPES = [
|
|
5964
|
+
'video/webm; codecs="vp8"',
|
|
5965
|
+
'video/webm; codecs="vp9"',
|
|
5966
|
+
'video/webm; codecs="av1"',
|
|
5967
|
+
'video/webm; codecs="vp9,opus"',
|
|
5968
|
+
'audio/webm; codecs="opus"',
|
|
5969
|
+
'audio/ogg; codecs="opus"',
|
|
5970
|
+
'video/mp4; codecs="avc1"',
|
|
5971
|
+
];
|
|
5972
|
+
/**
|
|
5973
|
+
* Produce a simple FNV-1a hex hash from the codec matrix.
|
|
5974
|
+
*/
|
|
5975
|
+
function hashCodecs(video, audio, recorder) {
|
|
5976
|
+
const str = stableStringify({ video, audio, recorder });
|
|
5977
|
+
let h = 0x811c9dc5;
|
|
5978
|
+
for (let i = 0; i < str.length; i++) {
|
|
5979
|
+
h ^= str.charCodeAt(i);
|
|
5980
|
+
h = (Math.imul(h, 0x01000193) | 0) >>> 0;
|
|
5981
|
+
}
|
|
5982
|
+
return h.toString(16).padStart(8, "0");
|
|
5983
|
+
}
|
|
5984
|
+
/**
|
|
5985
|
+
* Collect media codec support fingerprint.
|
|
5986
|
+
*/
|
|
5987
|
+
function getMediaCodecsFingerprint() {
|
|
5988
|
+
const startTime = performance.now();
|
|
5989
|
+
try {
|
|
5990
|
+
if (!isMediaCodecsAvailable()) {
|
|
5991
|
+
return {
|
|
5992
|
+
value: { video: {}, audio: {}, recorder: {}, hash: "" },
|
|
5993
|
+
duration: performance.now() - startTime,
|
|
5994
|
+
error: "document not available",
|
|
5995
|
+
};
|
|
5996
|
+
}
|
|
5997
|
+
// Test video codecs
|
|
5998
|
+
const videoEl = document.createElement("video");
|
|
5999
|
+
const video = {};
|
|
6000
|
+
for (const mime of VIDEO_TYPES) {
|
|
6001
|
+
try {
|
|
6002
|
+
video[mime] = videoEl.canPlayType(mime);
|
|
6003
|
+
}
|
|
6004
|
+
catch {
|
|
6005
|
+
video[mime] = "";
|
|
6006
|
+
}
|
|
6007
|
+
}
|
|
6008
|
+
// Test audio codecs
|
|
6009
|
+
const audioEl = document.createElement("audio");
|
|
6010
|
+
const audio = {};
|
|
6011
|
+
for (const mime of AUDIO_TYPES) {
|
|
6012
|
+
try {
|
|
6013
|
+
audio[mime] = audioEl.canPlayType(mime);
|
|
6014
|
+
}
|
|
6015
|
+
catch {
|
|
6016
|
+
audio[mime] = "";
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
// Test MediaRecorder support
|
|
6020
|
+
const recorder = {};
|
|
6021
|
+
const hasRecorder = typeof MediaRecorder !== "undefined" &&
|
|
6022
|
+
typeof MediaRecorder.isTypeSupported === "function";
|
|
6023
|
+
for (const mime of RECORDER_TYPES) {
|
|
6024
|
+
try {
|
|
6025
|
+
recorder[mime] = hasRecorder ? MediaRecorder.isTypeSupported(mime) : false;
|
|
6026
|
+
}
|
|
6027
|
+
catch {
|
|
6028
|
+
recorder[mime] = false;
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
const hash = hashCodecs(video, audio, recorder);
|
|
6032
|
+
return {
|
|
6033
|
+
value: { video, audio, recorder, hash },
|
|
6034
|
+
duration: performance.now() - startTime,
|
|
6035
|
+
};
|
|
6036
|
+
}
|
|
6037
|
+
catch (err) {
|
|
6038
|
+
return {
|
|
6039
|
+
value: { video: {}, audio: {}, recorder: {}, hash: "" },
|
|
6040
|
+
duration: performance.now() - startTime,
|
|
6041
|
+
error: err instanceof Error ? err.message : "Media codecs fingerprinting failed",
|
|
6042
|
+
};
|
|
6043
|
+
}
|
|
6044
|
+
}
|
|
6045
|
+
/**
|
|
6046
|
+
* Check if media codec fingerprinting is available.
|
|
6047
|
+
*/
|
|
6048
|
+
function isMediaCodecsAvailable() {
|
|
6049
|
+
try {
|
|
6050
|
+
return typeof document !== "undefined";
|
|
6051
|
+
}
|
|
6052
|
+
catch {
|
|
6053
|
+
return false;
|
|
6054
|
+
}
|
|
6055
|
+
}
|
|
6056
|
+
|
|
6057
|
+
/**
|
|
6058
|
+
* WebAssembly Feature Detection Fingerprinting
|
|
6059
|
+
*
|
|
6060
|
+
* Detects ~6–8 WebAssembly proposal flags via WebAssembly.validate() (sync,
|
|
6061
|
+
* does not execute the module). Stable per engine version. ~6 bits of entropy.
|
|
6062
|
+
*/
|
|
6063
|
+
// ─── Minimal WASM modules for feature detection ────────────────────────────
|
|
6064
|
+
// Each is the smallest valid module that uses the target feature/opcode.
|
|
6065
|
+
// WebAssembly.validate() returns false (instead of throwing) if invalid,
|
|
6066
|
+
// so any exception = feature not supported.
|
|
6067
|
+
// SIMD: v128.const opcode (0xFD 0x0F)
|
|
6068
|
+
// Module: one function returning v128 constant (all zeros)
|
|
6069
|
+
const SIMD_BYTES = new Uint8Array([
|
|
6070
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
|
|
6071
|
+
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b, // type section: () -> v128
|
|
6072
|
+
0x03, 0x02, 0x01, 0x00, // function section
|
|
6073
|
+
0x0a, 0x0a, 0x01, 0x08, 0x00, // code section
|
|
6074
|
+
0xfd, 0x0f, // v128.const
|
|
6075
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 16 bytes of immediate
|
|
6076
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
6077
|
+
0x0b, // end
|
|
6078
|
+
]);
|
|
6079
|
+
// Bulk memory: memory.copy (0xFC 0x0A)
|
|
6080
|
+
// Module: one function calling memory.copy(0, 0, n)
|
|
6081
|
+
const BULK_MEMORY_BYTES = new Uint8Array([
|
|
6082
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
|
|
6083
|
+
0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type: () -> ()
|
|
6084
|
+
0x03, 0x02, 0x01, 0x00, // function section
|
|
6085
|
+
0x05, 0x03, 0x01, 0x00, 0x00, // memory section (1 page min 0)
|
|
6086
|
+
0x0a, 0x0c, 0x01, 0x0a, 0x00, // code section
|
|
6087
|
+
0x41, 0x00, // i32.const 0
|
|
6088
|
+
0x41, 0x00, // i32.const 0
|
|
6089
|
+
0x41, 0x00, // i32.const 0
|
|
6090
|
+
0xfc, 0x0a, 0x00, 0x00, // memory.copy mem_idx=0 mem_idx=0
|
|
6091
|
+
0x0b, // end
|
|
6092
|
+
]);
|
|
6093
|
+
// Exception handling: tag section (section id 0x0d)
|
|
6094
|
+
// Module: just a tag section — engines without exception handling won't parse 0x0d
|
|
6095
|
+
const EXCEPTIONS_BYTES = new Uint8Array([
|
|
6096
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
|
|
6097
|
+
0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type section: () -> ()
|
|
6098
|
+
0x0d, 0x03, 0x01, 0x00, 0x00, // tag section: 1 tag of type 0
|
|
6099
|
+
]);
|
|
6100
|
+
// GC: rec type section (0x4E inside type section) — very new, Chrome 119+, FF 120+
|
|
6101
|
+
// Module: minimal module with an empty rec group
|
|
6102
|
+
const GC_BYTES = new Uint8Array([
|
|
6103
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
|
|
6104
|
+
0x01, 0x03, // type section, 3 bytes body
|
|
6105
|
+
0x01, // 1 entry
|
|
6106
|
+
0x4e, 0x00, // rec, 0 types in group
|
|
6107
|
+
]);
|
|
6108
|
+
/**
|
|
6109
|
+
* Safe wrapper around WebAssembly.validate — returns false on any exception.
|
|
6110
|
+
*/
|
|
6111
|
+
function validate(bytes) {
|
|
6112
|
+
try {
|
|
6113
|
+
return WebAssembly.validate(bytes);
|
|
6114
|
+
}
|
|
6115
|
+
catch {
|
|
6116
|
+
return false;
|
|
6117
|
+
}
|
|
6118
|
+
}
|
|
6119
|
+
/**
|
|
6120
|
+
* Collect WebAssembly feature flags.
|
|
6121
|
+
*/
|
|
6122
|
+
function getWasmFeaturesFingerprint() {
|
|
6123
|
+
const startTime = performance.now();
|
|
6124
|
+
try {
|
|
6125
|
+
const basic = typeof WebAssembly !== "undefined";
|
|
6126
|
+
if (!basic) {
|
|
6127
|
+
const value = {
|
|
6128
|
+
basic: false,
|
|
6129
|
+
validate: false,
|
|
6130
|
+
compile: false,
|
|
6131
|
+
simd: false,
|
|
6132
|
+
threads: false,
|
|
6133
|
+
bulkMemory: false,
|
|
6134
|
+
exceptions: false,
|
|
6135
|
+
gc: false,
|
|
6136
|
+
};
|
|
6137
|
+
return { value, duration: performance.now() - startTime };
|
|
6138
|
+
}
|
|
6139
|
+
const validateAvailable = typeof WebAssembly.validate === "function";
|
|
6140
|
+
const value = {
|
|
6141
|
+
basic: true,
|
|
6142
|
+
validate: validateAvailable,
|
|
6143
|
+
compile: typeof WebAssembly.compile === "function",
|
|
6144
|
+
simd: validateAvailable ? validate(SIMD_BYTES) : false,
|
|
6145
|
+
threads: typeof SharedArrayBuffer !== "undefined",
|
|
6146
|
+
bulkMemory: validateAvailable ? validate(BULK_MEMORY_BYTES) : false,
|
|
6147
|
+
exceptions: validateAvailable ? validate(EXCEPTIONS_BYTES) : false,
|
|
6148
|
+
gc: validateAvailable ? validate(GC_BYTES) : false,
|
|
6149
|
+
};
|
|
6150
|
+
return { value, duration: performance.now() - startTime };
|
|
6151
|
+
}
|
|
6152
|
+
catch (err) {
|
|
6153
|
+
return {
|
|
6154
|
+
value: {
|
|
6155
|
+
basic: false,
|
|
6156
|
+
validate: false,
|
|
6157
|
+
compile: false,
|
|
6158
|
+
simd: false,
|
|
6159
|
+
threads: false,
|
|
6160
|
+
bulkMemory: false,
|
|
6161
|
+
exceptions: false,
|
|
6162
|
+
gc: false,
|
|
6163
|
+
},
|
|
6164
|
+
duration: performance.now() - startTime,
|
|
6165
|
+
error: err instanceof Error ? err.message : "Wasm features fingerprinting failed",
|
|
6166
|
+
};
|
|
6167
|
+
}
|
|
6168
|
+
}
|
|
6169
|
+
/**
|
|
6170
|
+
* Check if WebAssembly feature detection is available.
|
|
6171
|
+
*/
|
|
6172
|
+
function isWasmAvailable() {
|
|
6173
|
+
try {
|
|
6174
|
+
return (typeof WebAssembly !== "undefined" &&
|
|
6175
|
+
typeof WebAssembly.validate === "function");
|
|
6176
|
+
}
|
|
6177
|
+
catch {
|
|
6178
|
+
return false;
|
|
6179
|
+
}
|
|
6180
|
+
}
|
|
6181
|
+
|
|
6182
|
+
/**
|
|
6183
|
+
* MathML Rendering Fingerprint
|
|
6184
|
+
*
|
|
6185
|
+
* Measures bounding box dimensions of rendered MathML structures to extract
|
|
6186
|
+
* font-metric entropy (~15 bits). Varies by OS (macOS vs Windows vs Linux),
|
|
6187
|
+
* browser engine (Gecko vs WebKit vs Blink), and system font version.
|
|
6188
|
+
*
|
|
6189
|
+
* Approach: inject hidden <math> elements into document.body, measure, remove.
|
|
6190
|
+
* No iframe needed — simpler than ThumbmarkJS approach.
|
|
6191
|
+
*/
|
|
6192
|
+
// Blackboard bold symbols × Greek sub/superscript pairs — from ThumbmarkJS
|
|
6193
|
+
const BB_SYMBOLS = ['𝔸', '𝔹', 'ℂ', '𝔻', '𝔼', '𝔽', '𝔾', 'ℍ', '𝕀', '𝕁', '𝕂', '𝕃'];
|
|
6194
|
+
const GREEK_PAIRS = [
|
|
6195
|
+
['β', 'ψ'], ['λ', 'ε'], ['ζ', 'α'], ['ξ', 'μ'], ['ρ', 'φ'], ['κ', 'τ'],
|
|
6196
|
+
['η', 'σ'], ['ι', 'ω'], ['γ', 'ν'], ['χ', 'δ'], ['θ', 'π'], ['υ', 'ο'],
|
|
6197
|
+
];
|
|
6198
|
+
// Complex structure 4: ∏ munderover wrapping all 12 BB×Greek mmultiscripts
|
|
6199
|
+
const _bbMrow = BB_SYMBOLS.map((b, i) => {
|
|
6200
|
+
const [g1, g2] = GREEK_PAIRS[i];
|
|
6201
|
+
return `<mmultiscripts><mi>${b}</mi><mi>${g1}</mi><mi>${g2}</mi></mmultiscripts>`;
|
|
6202
|
+
}).join('');
|
|
6203
|
+
// MathML structures to render — 16 total, inspired by ThumbmarkJS
|
|
6204
|
+
const MATHML_STRUCTURES = [
|
|
6205
|
+
// 1. Integral with fraction
|
|
6206
|
+
`<math><msubsup><mo>∫</mo><mi>a</mi><mi>b</mi></msubsup><mfrac><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow><mrow><mi>d</mi><mi>x</mi></mrow></mfrac></math>`,
|
|
6207
|
+
// 2. Fraction with pi and r squared
|
|
6208
|
+
`<math><mfrac><mrow><mi>π</mi><mo>×</mo><msup><mi>r</mi><mn>2</mn></msup></mrow><mrow><mn>4</mn></mrow></mfrac></math>`,
|
|
6209
|
+
// 3. Matrix with Greek letters
|
|
6210
|
+
`<math><mo>[</mo><mtable><mtr><mtd><mi>α</mi></mtd><mtd><mi>β</mi></mtd></mtr><mtr><mtd><mi>γ</mi></mtd><mtd><mi>δ</mi></mtd></mtr></mtable><mo>]</mo></math>`,
|
|
6211
|
+
// 4. ∏ munderover with 12 blackboard bold × Greek mmultiscripts (replaces simple ∑)
|
|
6212
|
+
`<math><munderover><mo>∏</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></munderover><mrow>${_bbMrow}</mrow></math>`,
|
|
6213
|
+
// 5–16. Individual blackboard bold symbol with Greek sub/superscript (one per symbol)
|
|
6214
|
+
...BB_SYMBOLS.map((b, i) => {
|
|
6215
|
+
const [g1, g2] = GREEK_PAIRS[i];
|
|
6216
|
+
return `<math><mmultiscripts><mi>${b}</mi><mi>${g1}</mi><mi>${g2}</mi></mmultiscripts></math>`;
|
|
6217
|
+
}),
|
|
6218
|
+
];
|
|
6219
|
+
// Font style properties to capture from the root <math> element
|
|
6220
|
+
const FONT_STYLE_PROPS = [
|
|
6221
|
+
"fontFamily",
|
|
6222
|
+
"fontSize",
|
|
6223
|
+
"fontWeight",
|
|
6224
|
+
"fontStyle",
|
|
6225
|
+
"lineHeight",
|
|
6226
|
+
"fontVariant",
|
|
6227
|
+
"fontStretch",
|
|
6228
|
+
"fontKerning",
|
|
6229
|
+
"fontFeatureSettings",
|
|
6230
|
+
"fontVariantNumeric",
|
|
6231
|
+
];
|
|
6232
|
+
/**
|
|
6233
|
+
* FNV-1a hash over a JSON string.
|
|
6234
|
+
*/
|
|
6235
|
+
function fnv1a(str) {
|
|
6236
|
+
let h = 0x811c9dc5;
|
|
6237
|
+
for (let i = 0; i < str.length; i++) {
|
|
6238
|
+
h ^= str.charCodeAt(i);
|
|
6239
|
+
h = (Math.imul(h, 0x01000193) | 0) >>> 0;
|
|
6240
|
+
}
|
|
6241
|
+
return h.toString(16).padStart(8, "0");
|
|
6242
|
+
}
|
|
6243
|
+
/**
|
|
6244
|
+
* Check if the browser renders MathML natively by measuring a trivial element.
|
|
6245
|
+
*/
|
|
6246
|
+
function isMathMLRendered() {
|
|
6247
|
+
try {
|
|
6248
|
+
const container = document.createElement("div");
|
|
6249
|
+
container.style.cssText = "position:absolute;visibility:hidden;top:-9999px;left:-9999px";
|
|
6250
|
+
container.innerHTML = "<math><mrow><mi>x</mi></mrow></math>";
|
|
6251
|
+
document.body.appendChild(container);
|
|
6252
|
+
const math = container.querySelector("math");
|
|
6253
|
+
const rect = math?.getBoundingClientRect();
|
|
6254
|
+
document.body.removeChild(container);
|
|
6255
|
+
return !!(rect && rect.width > 0 && rect.height > 0);
|
|
6256
|
+
}
|
|
6257
|
+
catch {
|
|
6258
|
+
return false;
|
|
6259
|
+
}
|
|
6260
|
+
}
|
|
6261
|
+
/**
|
|
6262
|
+
* Render a MathML string in a hidden container, measure dimensions, then remove.
|
|
6263
|
+
*/
|
|
6264
|
+
function measureMathML(mathHtml) {
|
|
6265
|
+
const container = document.createElement("div");
|
|
6266
|
+
container.style.cssText =
|
|
6267
|
+
"position:absolute;visibility:hidden;top:-9999px;left:-9999px;font-size:16px";
|
|
6268
|
+
container.innerHTML = mathHtml;
|
|
6269
|
+
document.body.appendChild(container);
|
|
6270
|
+
const math = container.querySelector("math");
|
|
6271
|
+
const rect = math?.getBoundingClientRect() ?? { width: 0, height: 0 };
|
|
6272
|
+
document.body.removeChild(container);
|
|
6273
|
+
return {
|
|
6274
|
+
width: Math.round(rect.width * 100) / 100,
|
|
6275
|
+
height: Math.round(rect.height * 100) / 100,
|
|
6276
|
+
};
|
|
6277
|
+
}
|
|
6278
|
+
/**
|
|
6279
|
+
* Capture computedStyle font properties of the <math> element.
|
|
6280
|
+
*/
|
|
6281
|
+
function captureFontStyle() {
|
|
6282
|
+
const container = document.createElement("div");
|
|
6283
|
+
container.style.cssText =
|
|
6284
|
+
"position:absolute;visibility:hidden;top:-9999px;left:-9999px;font-size:16px";
|
|
6285
|
+
container.innerHTML = "<math><mi>x</mi></math>";
|
|
6286
|
+
document.body.appendChild(container);
|
|
6287
|
+
const math = container.querySelector("math");
|
|
6288
|
+
const style = math ? window.getComputedStyle(math) : null;
|
|
6289
|
+
const result = {};
|
|
6290
|
+
for (const prop of FONT_STYLE_PROPS) {
|
|
6291
|
+
result[prop] = style ? style[prop] ?? "" : "";
|
|
6292
|
+
}
|
|
6293
|
+
document.body.removeChild(container);
|
|
6294
|
+
return result;
|
|
6295
|
+
}
|
|
6296
|
+
/**
|
|
6297
|
+
* Collect MathML rendering fingerprint.
|
|
6298
|
+
* Returns `supported: false` (without error) in browsers that don't render MathML.
|
|
6299
|
+
*/
|
|
6300
|
+
async function getMathMLFingerprint() {
|
|
6301
|
+
const startTime = performance.now();
|
|
6302
|
+
try {
|
|
6303
|
+
if (!isMathMLAvailable()) {
|
|
6304
|
+
return {
|
|
6305
|
+
value: { supported: false, dimensionsHash: "", fontStyleHash: "" },
|
|
6306
|
+
duration: performance.now() - startTime,
|
|
6307
|
+
error: "document not available",
|
|
6308
|
+
};
|
|
6309
|
+
}
|
|
6310
|
+
if (!isMathMLRendered()) {
|
|
6311
|
+
return {
|
|
6312
|
+
value: { supported: false, dimensionsHash: "", fontStyleHash: "" },
|
|
6313
|
+
duration: performance.now() - startTime,
|
|
6314
|
+
};
|
|
6315
|
+
}
|
|
6316
|
+
// Measure all structures
|
|
6317
|
+
const dimensions = MATHML_STRUCTURES.map((html) => measureMathML(html));
|
|
6318
|
+
const dimensionsHash = fnv1a(stableStringify(dimensions));
|
|
6319
|
+
// Capture font style info
|
|
6320
|
+
const fontStyle = captureFontStyle();
|
|
6321
|
+
const fontStyleHash = fnv1a(stableStringify(fontStyle));
|
|
6322
|
+
return {
|
|
6323
|
+
value: { supported: true, dimensionsHash, fontStyleHash },
|
|
6324
|
+
duration: performance.now() - startTime,
|
|
6325
|
+
};
|
|
6326
|
+
}
|
|
6327
|
+
catch (err) {
|
|
6328
|
+
return {
|
|
6329
|
+
value: { supported: false, dimensionsHash: "", fontStyleHash: "" },
|
|
6330
|
+
duration: performance.now() - startTime,
|
|
6331
|
+
error: err instanceof Error ? err.message : "MathML fingerprinting failed",
|
|
6332
|
+
};
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
/**
|
|
6336
|
+
* Check if MathML fingerprinting is available (requires DOM).
|
|
6337
|
+
*/
|
|
6338
|
+
function isMathMLAvailable() {
|
|
6339
|
+
try {
|
|
6340
|
+
return (typeof document !== "undefined" &&
|
|
6341
|
+
typeof document.createElement === "function" &&
|
|
6342
|
+
typeof document.body !== "undefined" &&
|
|
6343
|
+
document.body !== null);
|
|
6344
|
+
}
|
|
6345
|
+
catch {
|
|
6346
|
+
return false;
|
|
6347
|
+
}
|
|
6348
|
+
}
|
|
6349
|
+
|
|
6350
|
+
/**
|
|
6351
|
+
* User-Agent Client Hints Fingerprinting
|
|
6352
|
+
*
|
|
6353
|
+
* Uses navigator.userAgentData.getHighEntropyValues() to obtain precise
|
|
6354
|
+
* platform details (~30 bits in Chromium). Gracefully degrades to
|
|
6355
|
+
* `available: false` in Firefox and Safari which don't expose the API.
|
|
6356
|
+
*
|
|
6357
|
+
* Note: platformVersion can change with OS/browser updates — this component
|
|
6358
|
+
* contributes to fingerprintHash but NOT to stableCoreHash (see orchestrator).
|
|
6359
|
+
*/
|
|
6360
|
+
/**
|
|
6361
|
+
* Collect User-Agent Client Hints.
|
|
6362
|
+
*/
|
|
6363
|
+
async function getClientHintsFingerprint() {
|
|
6364
|
+
const startTime = performance.now();
|
|
6365
|
+
try {
|
|
6366
|
+
if (!isClientHintsAvailable()) {
|
|
6367
|
+
return {
|
|
6368
|
+
value: { available: false },
|
|
6369
|
+
duration: performance.now() - startTime,
|
|
6370
|
+
};
|
|
6371
|
+
}
|
|
6372
|
+
const uaData = navigator.userAgentData;
|
|
6373
|
+
const highEntropy = await uaData.getHighEntropyValues([
|
|
6374
|
+
"architecture",
|
|
6375
|
+
"bitness",
|
|
6376
|
+
"fullVersionList",
|
|
6377
|
+
"mobile",
|
|
6378
|
+
"model",
|
|
6379
|
+
"platformVersion",
|
|
6380
|
+
"formFactor",
|
|
6381
|
+
"uaFullVersion",
|
|
6382
|
+
]);
|
|
6383
|
+
// Normalize fullVersionList → brands
|
|
6384
|
+
const brands = highEntropy.fullVersionList?.map((b) => ({
|
|
6385
|
+
brand: b.brand ?? "",
|
|
6386
|
+
version: b.version ?? "",
|
|
6387
|
+
}));
|
|
6388
|
+
return {
|
|
6389
|
+
value: {
|
|
6390
|
+
available: true,
|
|
6391
|
+
brands,
|
|
6392
|
+
mobile: highEntropy.mobile,
|
|
6393
|
+
platform: uaData.platform,
|
|
6394
|
+
platformVersion: highEntropy.platformVersion,
|
|
6395
|
+
architecture: highEntropy.architecture,
|
|
6396
|
+
bitness: highEntropy.bitness,
|
|
6397
|
+
model: highEntropy.model,
|
|
6398
|
+
uaFullVersion: highEntropy.uaFullVersion,
|
|
6399
|
+
formFactor: highEntropy.formFactor,
|
|
6400
|
+
},
|
|
6401
|
+
duration: performance.now() - startTime,
|
|
6402
|
+
};
|
|
6403
|
+
}
|
|
6404
|
+
catch (err) {
|
|
6405
|
+
return {
|
|
6406
|
+
value: { available: false },
|
|
6407
|
+
duration: performance.now() - startTime,
|
|
6408
|
+
error: err instanceof Error ? err.message : "Client Hints fingerprinting failed",
|
|
6409
|
+
};
|
|
6410
|
+
}
|
|
6411
|
+
}
|
|
6412
|
+
/**
|
|
6413
|
+
* Check if User-Agent Client Hints API is available (Chromium-based browsers).
|
|
6414
|
+
*/
|
|
6415
|
+
function isClientHintsAvailable() {
|
|
6416
|
+
try {
|
|
6417
|
+
return (typeof navigator !== "undefined" &&
|
|
6418
|
+
"userAgentData" in navigator &&
|
|
6419
|
+
typeof navigator.userAgentData?.getHighEntropyValues ===
|
|
6420
|
+
"function");
|
|
6421
|
+
}
|
|
6422
|
+
catch {
|
|
6423
|
+
return false;
|
|
6424
|
+
}
|
|
6425
|
+
}
|
|
6426
|
+
|
|
4986
6427
|
/**
|
|
4987
6428
|
* Fingerprint Orchestrator
|
|
4988
6429
|
* Main fingerprinting engine that coordinates all components
|
|
@@ -5024,16 +6465,24 @@ function shouldIncludeComponent(component, options) {
|
|
|
5024
6465
|
return false;
|
|
5025
6466
|
}
|
|
5026
6467
|
}
|
|
5027
|
-
// New FingerprintJS components are enabled by default
|
|
5028
|
-
const
|
|
6468
|
+
// New FingerprintJS / ThumbmarkJS components are enabled by default
|
|
6469
|
+
const extendedComponents = [
|
|
5029
6470
|
"mathFingerprint",
|
|
5030
6471
|
"dateTimeLocale",
|
|
5031
6472
|
"accessibilityEnhanced",
|
|
5032
6473
|
"fontPreferences",
|
|
5033
6474
|
"enhancedFonts",
|
|
6475
|
+
"webrtc",
|
|
6476
|
+
"permissions",
|
|
6477
|
+
"speech",
|
|
6478
|
+
// High-entropy new components
|
|
6479
|
+
"cssFeatures",
|
|
6480
|
+
"mediaCodecs",
|
|
6481
|
+
"wasmFeatures",
|
|
6482
|
+
"mathml",
|
|
6483
|
+
"clientHints",
|
|
5034
6484
|
];
|
|
5035
|
-
|
|
5036
|
-
if (fingerprintJSComponents.includes(component)) {
|
|
6485
|
+
if (extendedComponents.includes(component)) {
|
|
5037
6486
|
return true;
|
|
5038
6487
|
}
|
|
5039
6488
|
return true;
|
|
@@ -5183,6 +6632,46 @@ async function collectFingerprint(options = {}) {
|
|
|
5183
6632
|
result,
|
|
5184
6633
|
})));
|
|
5185
6634
|
}
|
|
6635
|
+
// WebRTC Fingerprint (ThumbmarkJS-inspired)
|
|
6636
|
+
if (shouldIncludeComponent("webrtc", opts) && isWebRTCAvailable()) {
|
|
6637
|
+
parallelTasks.push(collectComponent("webrtc", () => getWebRTCFingerprint(opts.timeout), opts).then((result) => ({ component: "webrtc", result })));
|
|
6638
|
+
}
|
|
6639
|
+
// Permissions Fingerprint (ThumbmarkJS-inspired)
|
|
6640
|
+
if (shouldIncludeComponent("permissions", opts) && isPermissionsAvailable()) {
|
|
6641
|
+
parallelTasks.push(collectComponent("permissions", getPermissionsFingerprint, opts).then((result) => ({ component: "permissions", result })));
|
|
6642
|
+
}
|
|
6643
|
+
// Speech Synthesis Fingerprint (ThumbmarkJS-inspired, experimental)
|
|
6644
|
+
if (!opts.gdprMode && shouldIncludeComponent("speech", opts) && isSpeechAvailable()) {
|
|
6645
|
+
parallelTasks.push(collectComponent("speech", getSpeechFingerprint, opts).then((result) => ({
|
|
6646
|
+
component: "speech",
|
|
6647
|
+
result,
|
|
6648
|
+
})));
|
|
6649
|
+
}
|
|
6650
|
+
// CSS @supports Feature Vector (high entropy, synchronous)
|
|
6651
|
+
if (shouldIncludeComponent("cssFeatures", opts) && isCSSFeaturesAvailable()) {
|
|
6652
|
+
parallelTasks.push(collectComponent("cssFeatures", async () => getCSSFeaturesFingerprint(), opts).then((result) => ({ component: "cssFeatures", result })));
|
|
6653
|
+
}
|
|
6654
|
+
// Media Codec Support Matrix (synchronous, OS+hardware signal)
|
|
6655
|
+
if (shouldIncludeComponent("mediaCodecs", opts) && isMediaCodecsAvailable()) {
|
|
6656
|
+
parallelTasks.push(collectComponent("mediaCodecs", async () => getMediaCodecsFingerprint(), opts).then((result) => ({ component: "mediaCodecs", result })));
|
|
6657
|
+
}
|
|
6658
|
+
// WebAssembly Feature Flags (synchronous, engine signal)
|
|
6659
|
+
if (shouldIncludeComponent("wasmFeatures", opts) && isWasmAvailable()) {
|
|
6660
|
+
parallelTasks.push(collectComponent("wasmFeatures", async () => getWasmFeaturesFingerprint(), opts).then((result) => ({ component: "wasmFeatures", result })));
|
|
6661
|
+
}
|
|
6662
|
+
// MathML Rendering Fingerprint (async DOM, font-metric signal)
|
|
6663
|
+
if (shouldIncludeComponent("mathml", opts) && isMathMLAvailable()) {
|
|
6664
|
+
parallelTasks.push(collectComponent("mathml", getMathMLFingerprint, opts).then((result) => ({
|
|
6665
|
+
component: "mathml",
|
|
6666
|
+
result,
|
|
6667
|
+
})));
|
|
6668
|
+
}
|
|
6669
|
+
// Client Hints API (async, Chromium-only, precise platform data)
|
|
6670
|
+
if (!opts.gdprMode &&
|
|
6671
|
+
shouldIncludeComponent("clientHints", opts) &&
|
|
6672
|
+
isClientHintsAvailable()) {
|
|
6673
|
+
parallelTasks.push(collectComponent("clientHints", getClientHintsFingerprint, opts).then((result) => ({ component: "clientHints", result })));
|
|
6674
|
+
}
|
|
5186
6675
|
// Execute all parallel tasks and handle results
|
|
5187
6676
|
const parallelResults = await Promise.allSettled(parallelTasks);
|
|
5188
6677
|
parallelResults.forEach((promiseResult, index) => {
|
|
@@ -5563,6 +7052,28 @@ async function collectFingerprint(options = {}) {
|
|
|
5563
7052
|
});
|
|
5564
7053
|
}
|
|
5565
7054
|
}
|
|
7055
|
+
// STABILIZATION: Determine which components are noisy in the current context
|
|
7056
|
+
// and null them out before hashing to avoid hash instability.
|
|
7057
|
+
{
|
|
7058
|
+
const incognitoLikelihood = fingerprintData.incognitoDetection?.likelihood ?? 0;
|
|
7059
|
+
const excluded = getExcludedComponents(incognitoLikelihood);
|
|
7060
|
+
if (excluded.size > 0) {
|
|
7061
|
+
for (const key of excluded) {
|
|
7062
|
+
if (fingerprintData[key] !== undefined) {
|
|
7063
|
+
fingerprintData[key] = null;
|
|
7064
|
+
// Move from collected to failed list
|
|
7065
|
+
const idx = collectedComponents.indexOf(key);
|
|
7066
|
+
if (idx !== -1) {
|
|
7067
|
+
collectedComponents.splice(idx, 1);
|
|
7068
|
+
failedComponents.push({
|
|
7069
|
+
component: key,
|
|
7070
|
+
error: `excluded by stabilization rules`,
|
|
7071
|
+
});
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
}
|
|
7075
|
+
}
|
|
7076
|
+
}
|
|
5566
7077
|
// Generate hash from collected components
|
|
5567
7078
|
const componentValues = {};
|
|
5568
7079
|
collectedComponents.forEach((component) => {
|
|
@@ -6000,6 +7511,8 @@ async function collectFingerprint(options = {}) {
|
|
|
6000
7511
|
"accessibilityEnhanced", // Locale/accessibility (can change)
|
|
6001
7512
|
"domBlockers",
|
|
6002
7513
|
"pluginsEnhanced", // Privacy tools detection (can change)
|
|
7514
|
+
"speech", // Experimental — OS-specific, excluded from stableCoreHash
|
|
7515
|
+
"clientHints", // platformVersion changes with OS/browser updates
|
|
6003
7516
|
];
|
|
6004
7517
|
const stableComponentValues = {};
|
|
6005
7518
|
for (const [key, value] of Object.entries(componentValues)) {
|
|
@@ -6117,6 +7630,12 @@ function getAvailableComponents() {
|
|
|
6117
7630
|
available.push("screen");
|
|
6118
7631
|
if (isBrowserFingerprintingAvailable())
|
|
6119
7632
|
available.push("browser");
|
|
7633
|
+
if (isWebRTCAvailable())
|
|
7634
|
+
available.push("webrtc");
|
|
7635
|
+
if (isPermissionsAvailable())
|
|
7636
|
+
available.push("permissions");
|
|
7637
|
+
if (isSpeechAvailable())
|
|
7638
|
+
available.push("speech");
|
|
6120
7639
|
return available;
|
|
6121
7640
|
}
|
|
6122
7641
|
|
|
@@ -24847,6 +26366,18 @@ function getHardwareFingerprint() {
|
|
|
24847
26366
|
architecture: getArchitecture$1(),
|
|
24848
26367
|
maxTouchPoints: getMaxTouchPoints(),
|
|
24849
26368
|
platform: navigator.platform || 'unknown',
|
|
26369
|
+
jsHeapSizeLimit: performance.memory?.jsHeapSizeLimit ?? 0,
|
|
26370
|
+
architectureFloat32: (() => {
|
|
26371
|
+
try {
|
|
26372
|
+
const f = new Float32Array(1);
|
|
26373
|
+
f[0] = Infinity;
|
|
26374
|
+
f[0] -= f[0]; // NaN
|
|
26375
|
+
return new Uint8Array(f.buffer)[3] ?? -1;
|
|
26376
|
+
}
|
|
26377
|
+
catch {
|
|
26378
|
+
return -1;
|
|
26379
|
+
}
|
|
26380
|
+
})(),
|
|
24850
26381
|
};
|
|
24851
26382
|
}
|
|
24852
26383
|
|
|
@@ -26502,6 +28033,56 @@ const COMPONENT_CHARACTERISTICS = {
|
|
|
26502
28033
|
uniqueness: 0.82, // Good uniqueness across systems
|
|
26503
28034
|
spoofResistance: 0.7, // Moderate spoof resistance
|
|
26504
28035
|
},
|
|
28036
|
+
// ThumbmarkJS-inspired new components
|
|
28037
|
+
webrtc: {
|
|
28038
|
+
stability: 0.92, // Codec/extension lists are stable per browser version
|
|
28039
|
+
entropy: 0.85, // Rich source of entropy (codecs + RTP extensions)
|
|
28040
|
+
uniqueness: 0.8, // Good differentiation across browser+OS combos
|
|
28041
|
+
spoofResistance: 0.9, // Very hard to fake SDP offer
|
|
28042
|
+
},
|
|
28043
|
+
permissions: {
|
|
28044
|
+
stability: 0.88, // Permission states are stable per profile
|
|
28045
|
+
entropy: 0.75, // 25 signals provide good entropy
|
|
28046
|
+
uniqueness: 0.72, // Decent uniqueness across configurations
|
|
28047
|
+
spoofResistance: 0.8, // Hard to predict all 25 states
|
|
28048
|
+
},
|
|
28049
|
+
speech: {
|
|
28050
|
+
stability: 0.9, // Voice list is stable per OS/locale
|
|
28051
|
+
entropy: 0.7, // Varies by OS version and language
|
|
28052
|
+
uniqueness: 0.65, // Less unique but still informative
|
|
28053
|
+
spoofResistance: 0.85, // Hard to fake voice list
|
|
28054
|
+
},
|
|
28055
|
+
// High-entropy new components
|
|
28056
|
+
cssFeatures: {
|
|
28057
|
+
stability: 0.97, // CSS support flags are very stable per browser version
|
|
28058
|
+
entropy: 0.88, // ~50 bits from 40 boolean features
|
|
28059
|
+
uniqueness: 0.85, // Good differentiation across browser+version combos
|
|
28060
|
+
spoofResistance: 0.95, // Hard to fake CSS.supports() results
|
|
28061
|
+
},
|
|
28062
|
+
mediaCodecs: {
|
|
28063
|
+
stability: 0.95, // Codec support is stable per OS + hardware
|
|
28064
|
+
entropy: 0.82, // ~20 bits, varies by OS (HEVC on macOS, etc.)
|
|
28065
|
+
uniqueness: 0.78, // Good differentiation across OS/browser combos
|
|
28066
|
+
spoofResistance: 0.92, // Requires actual codec support to fake
|
|
28067
|
+
},
|
|
28068
|
+
wasmFeatures: {
|
|
28069
|
+
stability: 0.98, // Wasm proposal flags are stable per engine version
|
|
28070
|
+
entropy: 0.70, // ~6-8 bits from proposal flags
|
|
28071
|
+
uniqueness: 0.72, // Distinguishes engine generations well
|
|
28072
|
+
spoofResistance: 0.97, // validate() is nearly impossible to fake
|
|
28073
|
+
},
|
|
28074
|
+
mathml: {
|
|
28075
|
+
stability: 0.90, // Font rendering can change with OS/font updates
|
|
28076
|
+
entropy: 0.80, // ~15 bits from font metric variations
|
|
28077
|
+
uniqueness: 0.75, // Varies by OS, engine, and font stack
|
|
28078
|
+
spoofResistance: 0.88, // Pixel-level measurements are hard to fake
|
|
28079
|
+
},
|
|
28080
|
+
clientHints: {
|
|
28081
|
+
stability: 0.93, // platformVersion can change with OS updates
|
|
28082
|
+
entropy: 0.92, // ~30 bits in Chromium (platform, arch, model, etc.)
|
|
28083
|
+
uniqueness: 0.90, // Very precise in Chromium-based browsers
|
|
28084
|
+
spoofResistance: 0.85, // API can be overridden but requires effort
|
|
28085
|
+
},
|
|
26505
28086
|
};
|
|
26506
28087
|
/**
|
|
26507
28088
|
* New advanced components characteristics
|