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