@magield/collectors 0.0.0-stage → 0.7.1
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/LICENSE +27 -0
- package/dist/behavior/recorder.d.ts +33 -0
- package/dist/behavior/recorder.js +303 -0
- package/dist/hash.d.ts +15 -0
- package/dist/hash.js +27 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/native.d.ts +39 -0
- package/dist/native.js +142 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +108 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +128 -0
- package/dist/signals/adblock.d.ts +2 -0
- package/dist/signals/adblock.js +135 -0
- package/dist/signals/apps.d.ts +2 -0
- package/dist/signals/apps.js +191 -0
- package/dist/signals/audio.d.ts +16 -0
- package/dist/signals/audio.js +63 -0
- package/dist/signals/automation.d.ts +2 -0
- package/dist/signals/automation.js +346 -0
- package/dist/signals/behavior.d.ts +17 -0
- package/dist/signals/behavior.js +31 -0
- package/dist/signals/capabilities.d.ts +2 -0
- package/dist/signals/capabilities.js +235 -0
- package/dist/signals/devices.d.ts +2 -0
- package/dist/signals/devices.js +119 -0
- package/dist/signals/display.d.ts +2 -0
- package/dist/signals/display.js +101 -0
- package/dist/signals/errors.d.ts +2 -0
- package/dist/signals/errors.js +84 -0
- package/dist/signals/features.d.ts +2 -0
- package/dist/signals/features.js +951 -0
- package/dist/signals/fonts.d.ts +2 -0
- package/dist/signals/fonts.js +128 -0
- package/dist/signals/integrity.d.ts +2 -0
- package/dist/signals/integrity.js +216 -0
- package/dist/signals/math.d.ts +2 -0
- package/dist/signals/math.js +43 -0
- package/dist/signals/navigator.d.ts +10 -0
- package/dist/signals/navigator.js +81 -0
- package/dist/signals/pointer.d.ts +2 -0
- package/dist/signals/pointer.js +93 -0
- package/dist/signals/render.d.ts +12 -0
- package/dist/signals/render.js +214 -0
- package/dist/signals/screen.d.ts +9 -0
- package/dist/signals/screen.js +80 -0
- package/dist/signals/storage.d.ts +33 -0
- package/dist/signals/storage.js +100 -0
- package/dist/signals/timezone.d.ts +9 -0
- package/dist/signals/timezone.js +63 -0
- package/dist/signals/webrtc.d.ts +2 -0
- package/dist/signals/webrtc.js +168 -0
- package/dist/stable-core.d.ts +128 -0
- package/dist/stable-core.js +187 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +15 -0
- package/package.json +31 -3
- package/README.md +0 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { hash, hashRecord } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* Canvas and WebGL: what the GPU and driver actually produce.
|
|
4
|
+
*
|
|
5
|
+
* These are the highest-entropy signals available to a page, because the output
|
|
6
|
+
* depends on the whole rendering stack — GPU, driver version, OS text
|
|
7
|
+
* rasteriser, font hinting. They are also the ones privacy browsers
|
|
8
|
+
* deliberately poison, so the *stability* of the result is collected alongside
|
|
9
|
+
* it: real hardware is deterministic, injected noise is not.
|
|
10
|
+
*/
|
|
11
|
+
export const canvasCollector = {
|
|
12
|
+
id: 'canvas',
|
|
13
|
+
wave: 'warm',
|
|
14
|
+
optional: true,
|
|
15
|
+
collect({ document }) {
|
|
16
|
+
const out = {};
|
|
17
|
+
const first = renderCanvas(document);
|
|
18
|
+
if (first === null) {
|
|
19
|
+
out['canvas.hash'] = null;
|
|
20
|
+
out['canvas.stable'] = null;
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
out['canvas.hash'] = hash(first);
|
|
24
|
+
// Rendered twice into two separate canvases. Identical output means real,
|
|
25
|
+
// deterministic hardware; differing output means the browser is
|
|
26
|
+
// randomising it, which is itself worth knowing and must not be mistaken
|
|
27
|
+
// for a new device on every visit.
|
|
28
|
+
const second = renderCanvas(document);
|
|
29
|
+
out['canvas.stable'] = second === first;
|
|
30
|
+
// Read twice from ONE canvas, with a `getImageData` between the reads.
|
|
31
|
+
//
|
|
32
|
+
// This is the check the one above misses, and it misses it in the case
|
|
33
|
+
// that matters. Brave farbles per session and per origin, so two separately
|
|
34
|
+
// rendered canvases agree and `canvas.stable` reads true — measured on two
|
|
35
|
+
// real Brave sessions, both of which reported stable while every other
|
|
36
|
+
// value they touched was being redrawn.
|
|
37
|
+
//
|
|
38
|
+
// A farbler that perturbs on each *read* is caught here instead, because
|
|
39
|
+
// this asks the same pixels twice rather than asking twice for the same
|
|
40
|
+
// drawing. The `getImageData` in between is not decoration: it is a second
|
|
41
|
+
// read path, and an implementation that noises one and not the other shows
|
|
42
|
+
// up as a difference between the two `toDataURL` calls surrounding it.
|
|
43
|
+
out['canvas.readStable'] = readStability(document);
|
|
44
|
+
// Whether text measurement lands on whole pixels.
|
|
45
|
+
//
|
|
46
|
+
// A real engine lays text out in fractional units — `measureText` on a
|
|
47
|
+
// proportional font returns values like 123.4560546875, and getting a whole
|
|
48
|
+
// number is a coincidence. Some anti-detect browsers round the result to
|
|
49
|
+
// defeat font fingerprinting, which removes the entropy and leaves a tell
|
|
50
|
+
// that is far harder to fake than the widths themselves.
|
|
51
|
+
out['canvas.textFractional'] = measureFractional(document);
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* How many of several measured strings had a fractional width.
|
|
57
|
+
*
|
|
58
|
+
* Several, not one: a single string genuinely can measure to a whole number,
|
|
59
|
+
* and a check built on one sample would fire on that coincidence. Null when
|
|
60
|
+
* the measurement could not be taken at all, which must not read as rounded.
|
|
61
|
+
*/
|
|
62
|
+
function measureFractional(document) {
|
|
63
|
+
try {
|
|
64
|
+
const context = document.createElement('canvas').getContext('2d');
|
|
65
|
+
if (context === null)
|
|
66
|
+
return null;
|
|
67
|
+
context.font = '11pt Arial';
|
|
68
|
+
let fractional = 0;
|
|
69
|
+
for (const sample of ['Magic Shield', 'mmmmmmmmmmlli', 'The quick brown fox', 'WWWWWWiiiiii']) {
|
|
70
|
+
const { width } = context.measureText(sample);
|
|
71
|
+
if (!Number.isFinite(width))
|
|
72
|
+
return null;
|
|
73
|
+
if (!Number.isInteger(width))
|
|
74
|
+
fractional++;
|
|
75
|
+
}
|
|
76
|
+
return fractional;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether one canvas returns the same bytes when read repeatedly.
|
|
84
|
+
*
|
|
85
|
+
* Distinct from rendering twice: this draws once and reads three times, with a
|
|
86
|
+
* `getImageData` between the two `toDataURL` calls so both read paths are
|
|
87
|
+
* exercised. Returns null when nothing could be measured, never false.
|
|
88
|
+
*/
|
|
89
|
+
function readStability(document) {
|
|
90
|
+
try {
|
|
91
|
+
const canvas = document.createElement('canvas');
|
|
92
|
+
canvas.width = 220;
|
|
93
|
+
canvas.height = 30;
|
|
94
|
+
const context = canvas.getContext('2d');
|
|
95
|
+
if (context === null)
|
|
96
|
+
return null;
|
|
97
|
+
context.textBaseline = 'alphabetic';
|
|
98
|
+
context.font = "14px 'Arial'";
|
|
99
|
+
context.fillStyle = '#f60';
|
|
100
|
+
context.fillRect(125, 1, 62, 20);
|
|
101
|
+
context.fillStyle = '#069';
|
|
102
|
+
context.fillText('Magic Shield read probe', 2, 15);
|
|
103
|
+
context.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
|
104
|
+
context.fillText('Magic Shield read probe', 4, 17);
|
|
105
|
+
const before = canvas.toDataURL();
|
|
106
|
+
// The second read path. Its own result is not compared — what matters is
|
|
107
|
+
// whether taking it changes what the first path returns next time.
|
|
108
|
+
context.getImageData(0, 0, canvas.width, canvas.height);
|
|
109
|
+
const after = canvas.toDataURL();
|
|
110
|
+
return before === after;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function renderCanvas(document) {
|
|
117
|
+
try {
|
|
118
|
+
const canvas = document.createElement('canvas');
|
|
119
|
+
canvas.width = 280;
|
|
120
|
+
canvas.height = 60;
|
|
121
|
+
const context = canvas.getContext('2d');
|
|
122
|
+
if (context === null)
|
|
123
|
+
return null;
|
|
124
|
+
// Text, curves and blending together: each exercises a different part of
|
|
125
|
+
// the rasteriser, so the result varies more between stacks than any one
|
|
126
|
+
// would alone.
|
|
127
|
+
context.textBaseline = 'alphabetic';
|
|
128
|
+
context.fillStyle = '#f60';
|
|
129
|
+
context.fillRect(100, 1, 62, 20);
|
|
130
|
+
context.fillStyle = '#069';
|
|
131
|
+
context.font = '11pt Arial';
|
|
132
|
+
context.fillText('Magic Shield 1/2/3 ☃', 2, 15);
|
|
133
|
+
context.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
|
134
|
+
context.font = '18pt Arial';
|
|
135
|
+
context.fillText('Magic Shield 1/2/3 ☃', 4, 45);
|
|
136
|
+
context.globalCompositeOperation = 'multiply';
|
|
137
|
+
context.fillStyle = 'rgb(255,0,255)';
|
|
138
|
+
context.beginPath();
|
|
139
|
+
context.arc(50, 50, 50, 0, Math.PI * 2, true);
|
|
140
|
+
context.closePath();
|
|
141
|
+
context.fill();
|
|
142
|
+
return canvas.toDataURL();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const WEBGL_PARAMETERS = [
|
|
149
|
+
'MAX_TEXTURE_SIZE',
|
|
150
|
+
'MAX_VIEWPORT_DIMS',
|
|
151
|
+
'MAX_VERTEX_ATTRIBS',
|
|
152
|
+
'MAX_VERTEX_UNIFORM_VECTORS',
|
|
153
|
+
'MAX_FRAGMENT_UNIFORM_VECTORS',
|
|
154
|
+
'MAX_VARYING_VECTORS',
|
|
155
|
+
'MAX_COMBINED_TEXTURE_IMAGE_UNITS',
|
|
156
|
+
'MAX_CUBE_MAP_TEXTURE_SIZE',
|
|
157
|
+
'MAX_RENDERBUFFER_SIZE',
|
|
158
|
+
'ALIASED_LINE_WIDTH_RANGE',
|
|
159
|
+
'ALIASED_POINT_SIZE_RANGE',
|
|
160
|
+
'RED_BITS',
|
|
161
|
+
'GREEN_BITS',
|
|
162
|
+
'BLUE_BITS',
|
|
163
|
+
'ALPHA_BITS',
|
|
164
|
+
'DEPTH_BITS',
|
|
165
|
+
'STENCIL_BITS',
|
|
166
|
+
];
|
|
167
|
+
export const webglCollector = {
|
|
168
|
+
id: 'webgl',
|
|
169
|
+
wave: 'warm',
|
|
170
|
+
optional: true,
|
|
171
|
+
collect({ document }) {
|
|
172
|
+
const out = {};
|
|
173
|
+
try {
|
|
174
|
+
const canvas = document.createElement('canvas');
|
|
175
|
+
const gl = (canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl'));
|
|
176
|
+
if (gl === null) {
|
|
177
|
+
out['webgl.vendor'] = null;
|
|
178
|
+
out['webgl.renderer'] = null;
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
// The unmasked strings name the actual GPU. SwiftShader, llvmpipe and
|
|
182
|
+
// Mesa here mean software rendering, which on a machine claiming to be a
|
|
183
|
+
// consumer laptop is a strong headless signal.
|
|
184
|
+
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
|
185
|
+
out['webgl.vendor'] = debug === null ? gl.getParameter(gl.VENDOR) : gl.getParameter(debug.UNMASKED_VENDOR_WEBGL);
|
|
186
|
+
out['webgl.renderer'] =
|
|
187
|
+
debug === null ? gl.getParameter(gl.RENDERER) : gl.getParameter(debug.UNMASKED_RENDERER_WEBGL);
|
|
188
|
+
out['webgl.version'] = gl.getParameter(gl.VERSION);
|
|
189
|
+
out['webgl.shadingLanguageVersion'] = gl.getParameter(gl.SHADING_LANGUAGE_VERSION);
|
|
190
|
+
const parameters = {};
|
|
191
|
+
for (const name of WEBGL_PARAMETERS) {
|
|
192
|
+
const constant = gl[name];
|
|
193
|
+
if (typeof constant !== 'number')
|
|
194
|
+
continue;
|
|
195
|
+
const value = gl.getParameter(constant);
|
|
196
|
+
parameters[name] = Array.isArray(value) || ArrayBuffer.isView(value) ? Array.from(value).join(',') : value;
|
|
197
|
+
}
|
|
198
|
+
out['webgl.parametersHash'] = hashRecord(parameters);
|
|
199
|
+
// Per-GPU floating point precision ranges. Harder to fake than the vendor
|
|
200
|
+
// strings, because a plausible set has to match a real device family.
|
|
201
|
+
const precision = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
|
|
202
|
+
if (precision !== null) {
|
|
203
|
+
out['webgl.fragmentHighFloat'] = `${precision.rangeMin},${precision.rangeMax},${precision.precision}`;
|
|
204
|
+
}
|
|
205
|
+
const extensions = gl.getSupportedExtensions() ?? [];
|
|
206
|
+
out['webgl.extensionCount'] = extensions.length;
|
|
207
|
+
out['webgl.extensionsHash'] = hash(extensions.slice().sort().join(','));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
out['webgl.vendor'] = null;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Collector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Screen geometry and the window's relationship to it.
|
|
4
|
+
*
|
|
5
|
+
* The derived values matter more than the raw ones. A headless browser
|
|
6
|
+
* classically reports zero browser chrome, and a non-integer devicePixelRatio
|
|
7
|
+
* is characteristic of real hardware in a way a round number is not.
|
|
8
|
+
*/
|
|
9
|
+
export declare const screenCollector: Collector;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen geometry and the window's relationship to it.
|
|
3
|
+
*
|
|
4
|
+
* The derived values matter more than the raw ones. A headless browser
|
|
5
|
+
* classically reports zero browser chrome, and a non-integer devicePixelRatio
|
|
6
|
+
* is characteristic of real hardware in a way a round number is not.
|
|
7
|
+
*/
|
|
8
|
+
export const screenCollector = {
|
|
9
|
+
id: 'screen',
|
|
10
|
+
wave: 'instant',
|
|
11
|
+
collect({ screen, window, document }) {
|
|
12
|
+
const out = {
|
|
13
|
+
'screen.width': screen.width ?? null,
|
|
14
|
+
'screen.height': screen.height ?? null,
|
|
15
|
+
'screen.availWidth': screen.availWidth ?? null,
|
|
16
|
+
'screen.availHeight': screen.availHeight ?? null,
|
|
17
|
+
'screen.colorDepth': screen.colorDepth ?? null,
|
|
18
|
+
'screen.pixelDepth': screen.pixelDepth ?? null,
|
|
19
|
+
'screen.orientation': screen.orientation?.type ?? null,
|
|
20
|
+
'screen.devicePixelRatio': window.devicePixelRatio ?? null,
|
|
21
|
+
'screen.innerWidth': window.innerWidth ?? null,
|
|
22
|
+
'screen.innerHeight': window.innerHeight ?? null,
|
|
23
|
+
'screen.outerWidth': window.outerWidth ?? null,
|
|
24
|
+
'screen.outerHeight': window.outerHeight ?? null,
|
|
25
|
+
};
|
|
26
|
+
// Browser chrome height. Zero on a real windowed browser is close to
|
|
27
|
+
// impossible: there is always a tab strip and an address bar.
|
|
28
|
+
//
|
|
29
|
+
// **Only valid while the document has focus.** Chrome does not maintain
|
|
30
|
+
// live window geometry for a backgrounded tab, and what it reports instead
|
|
31
|
+
// looks exactly like a headless browser. Measured on a real Chrome 152,
|
|
32
|
+
// same tab, three states:
|
|
33
|
+
//
|
|
34
|
+
// unfocused, not yet rendered outer 0 inner 962 -> -962
|
|
35
|
+
// unfocused, stale geometry outer 962 inner 962 -> 0
|
|
36
|
+
// focused outer 962 inner 906 -> 56
|
|
37
|
+
//
|
|
38
|
+
// A background tab is completely ordinary — a link opened with cmd+click, a
|
|
39
|
+
// prerendered page, a form the user switched away from. Reporting any of
|
|
40
|
+
// those as zero chrome flags real people, so when the window is not focused
|
|
41
|
+
// the measurement simply is not available.
|
|
42
|
+
const focused = safeHasFocus(document);
|
|
43
|
+
if (focused === true &&
|
|
44
|
+
typeof window.outerHeight === 'number' &&
|
|
45
|
+
typeof window.innerHeight === 'number' &&
|
|
46
|
+
window.outerHeight > 0) {
|
|
47
|
+
out['screen.chromeHeight'] = window.outerHeight - window.innerHeight;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
out['screen.chromeHeight'] = null;
|
|
51
|
+
}
|
|
52
|
+
out['screen.focused'] = focused;
|
|
53
|
+
// How far the viewport overruns the screen it claims to be on.
|
|
54
|
+
//
|
|
55
|
+
// Reported as a ratio rather than a verdict, because the honest causes are
|
|
56
|
+
// real and this collector must not decide. Browser zoom inflates
|
|
57
|
+
// innerWidth while screen.width holds, and a window dragged across two
|
|
58
|
+
// monitors is wider than the screen the page thinks it is on. Whether any
|
|
59
|
+
// given ratio means anything is the consistency matrix's problem, and the
|
|
60
|
+
// measurement below is what tells it where the line is.
|
|
61
|
+
if (typeof screen.width === 'number' && screen.width > 0 && typeof window.innerWidth === 'number') {
|
|
62
|
+
out['screen.viewportRatio'] = Math.round((window.innerWidth / screen.width) * 100) / 100;
|
|
63
|
+
}
|
|
64
|
+
// The taskbar or menu bar. A screen whose available area exactly equals its
|
|
65
|
+
// full area is usually a virtual display.
|
|
66
|
+
if (typeof screen.height === 'number' && typeof screen.availHeight === 'number') {
|
|
67
|
+
out['screen.reservedHeight'] = screen.height - screen.availHeight;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/** `hasFocus` can be absent or throw in embedded and hardened contexts. */
|
|
73
|
+
function safeHasFocus(document) {
|
|
74
|
+
try {
|
|
75
|
+
return typeof document.hasFocus === 'function' ? document.hasFocus() : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Collector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the storage subsystem will admit to.
|
|
4
|
+
*
|
|
5
|
+
* This was written to detect private windows and it does not, which is worth
|
|
6
|
+
* stating at the top rather than leaving for somebody to discover.
|
|
7
|
+
*
|
|
8
|
+
* The two classic techniques are a reduced quota and a missing
|
|
9
|
+
* `storageBuckets`. Both were shipped here and both were then measured against
|
|
10
|
+
* a real Chrome incognito window on the machine that produced
|
|
11
|
+
* `tools/fp-lab/captures/human-chrome.json`:
|
|
12
|
+
*
|
|
13
|
+
* store.quotaMb 10240 identical to the ordinary window
|
|
14
|
+
* store.bucketsMissing false the API is present
|
|
15
|
+
* every storage mechanism works
|
|
16
|
+
*
|
|
17
|
+
* Every other signal in the capture was byte-identical too — the same
|
|
18
|
+
* permission hash, the same voice hash, the same WebGPU limits. Chrome closed
|
|
19
|
+
* these vectors deliberately, and a `quotaSmall` threshold guessed from how
|
|
20
|
+
* Chromium used to behave would have reported `false` on every private window
|
|
21
|
+
* while looking like a working detector.
|
|
22
|
+
*
|
|
23
|
+
* So nothing here claims to detect private mode. What remains are the raw
|
|
24
|
+
* measurements, which are honest about something else: a quota and a set of
|
|
25
|
+
* working storage mechanisms describe how hardened or stripped an environment
|
|
26
|
+
* is, and a client where `localStorage` throws is not a private window, it is
|
|
27
|
+
* a container or a locked-down profile.
|
|
28
|
+
*
|
|
29
|
+
* If private-mode detection is wanted, it needs a technique nobody here has
|
|
30
|
+
* measured, and it should be built from a capture rather than from a
|
|
31
|
+
* description of how browsers once worked.
|
|
32
|
+
*/
|
|
33
|
+
export declare const storageCollector: Collector;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the storage subsystem will admit to.
|
|
3
|
+
*
|
|
4
|
+
* This was written to detect private windows and it does not, which is worth
|
|
5
|
+
* stating at the top rather than leaving for somebody to discover.
|
|
6
|
+
*
|
|
7
|
+
* The two classic techniques are a reduced quota and a missing
|
|
8
|
+
* `storageBuckets`. Both were shipped here and both were then measured against
|
|
9
|
+
* a real Chrome incognito window on the machine that produced
|
|
10
|
+
* `tools/fp-lab/captures/human-chrome.json`:
|
|
11
|
+
*
|
|
12
|
+
* store.quotaMb 10240 identical to the ordinary window
|
|
13
|
+
* store.bucketsMissing false the API is present
|
|
14
|
+
* every storage mechanism works
|
|
15
|
+
*
|
|
16
|
+
* Every other signal in the capture was byte-identical too — the same
|
|
17
|
+
* permission hash, the same voice hash, the same WebGPU limits. Chrome closed
|
|
18
|
+
* these vectors deliberately, and a `quotaSmall` threshold guessed from how
|
|
19
|
+
* Chromium used to behave would have reported `false` on every private window
|
|
20
|
+
* while looking like a working detector.
|
|
21
|
+
*
|
|
22
|
+
* So nothing here claims to detect private mode. What remains are the raw
|
|
23
|
+
* measurements, which are honest about something else: a quota and a set of
|
|
24
|
+
* working storage mechanisms describe how hardened or stripped an environment
|
|
25
|
+
* is, and a client where `localStorage` throws is not a private window, it is
|
|
26
|
+
* a container or a locked-down profile.
|
|
27
|
+
*
|
|
28
|
+
* If private-mode detection is wanted, it needs a technique nobody here has
|
|
29
|
+
* measured, and it should be built from a capture rather than from a
|
|
30
|
+
* description of how browsers once worked.
|
|
31
|
+
*/
|
|
32
|
+
export const storageCollector = {
|
|
33
|
+
id: 'store',
|
|
34
|
+
wave: 'deep',
|
|
35
|
+
optional: true,
|
|
36
|
+
async collect({ navigator, window }) {
|
|
37
|
+
const out = {};
|
|
38
|
+
// ── Quota ────────────────────────────────────────────────────────────
|
|
39
|
+
// A description of the environment, not of the window. Measured identical
|
|
40
|
+
// between an ordinary and a private Chrome window, so it says nothing
|
|
41
|
+
// about privacy — but a container with a tiny quota still looks different
|
|
42
|
+
// from a laptop, and that is worth a number.
|
|
43
|
+
try {
|
|
44
|
+
const estimate = navigator.storage?.estimate?.bind(navigator.storage);
|
|
45
|
+
if (estimate === undefined) {
|
|
46
|
+
out['store.quotaMb'] = null;
|
|
47
|
+
out['store.quotaSmall'] = null;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const { quota } = await estimate();
|
|
51
|
+
if (quota === undefined) {
|
|
52
|
+
out['store.quotaMb'] = null;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
out['store.quotaMb'] = Math.round(quota / (1024 * 1024));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
out['store.quotaMb'] = null;
|
|
61
|
+
}
|
|
62
|
+
// `storageBuckets` was probed here on the premise that Chromium omits it in
|
|
63
|
+
// a private window. Measured present in a real incognito window, so the
|
|
64
|
+
// premise is false and the signal is gone rather than left in reporting a
|
|
65
|
+
// constant. `features` covers the API's existence as one bit among 891,
|
|
66
|
+
// which is all it was ever worth.
|
|
67
|
+
// ── Which storage mechanisms actually work ───────────────────────────
|
|
68
|
+
// A profile with storage disabled, or a hardened container, fails these
|
|
69
|
+
// in a pattern that does not look like a private window.
|
|
70
|
+
for (const [key, probe] of [
|
|
71
|
+
['store.localStorage', () => testStorage(window, 'localStorage')],
|
|
72
|
+
['store.sessionStorage', () => testStorage(window, 'sessionStorage')],
|
|
73
|
+
['store.indexedDb', () => 'indexedDB' in window && window.indexedDB !== null],
|
|
74
|
+
['store.caches', () => 'caches' in window],
|
|
75
|
+
]) {
|
|
76
|
+
try {
|
|
77
|
+
out[key] = probe();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
out[key] = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
/** Writes and removes one key. Throwing is the answer, not an error. */
|
|
87
|
+
function testStorage(window, name) {
|
|
88
|
+
try {
|
|
89
|
+
const store = window[name];
|
|
90
|
+
if (store === undefined || store === null)
|
|
91
|
+
return false;
|
|
92
|
+
const probe = '__ms_probe__';
|
|
93
|
+
store.setItem(probe, '1');
|
|
94
|
+
store.removeItem(probe);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Collector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Time zone, established two independent ways.
|
|
4
|
+
*
|
|
5
|
+
* `Intl` reports a zone name; `Date` reveals the actual UTC offsets the engine
|
|
6
|
+
* applies. Spoofing tools routinely patch one and forget the other, so the
|
|
7
|
+
* disagreement between them is worth more than either value.
|
|
8
|
+
*/
|
|
9
|
+
export declare const timezoneCollector: Collector;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time zone, established two independent ways.
|
|
3
|
+
*
|
|
4
|
+
* `Intl` reports a zone name; `Date` reveals the actual UTC offsets the engine
|
|
5
|
+
* applies. Spoofing tools routinely patch one and forget the other, so the
|
|
6
|
+
* disagreement between them is worth more than either value.
|
|
7
|
+
*/
|
|
8
|
+
export const timezoneCollector = {
|
|
9
|
+
id: 'tz',
|
|
10
|
+
wave: 'instant',
|
|
11
|
+
collect() {
|
|
12
|
+
const out = {};
|
|
13
|
+
let declared = null;
|
|
14
|
+
try {
|
|
15
|
+
declared = Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
declared = null;
|
|
19
|
+
}
|
|
20
|
+
out['tz.timezone'] = declared;
|
|
21
|
+
try {
|
|
22
|
+
// January and July: their offsets differ wherever DST is observed, and by
|
|
23
|
+
// how much. That pair is a coarse fingerprint of the zone on its own.
|
|
24
|
+
const january = -new Date(Date.UTC(2024, 0, 15)).getTimezoneOffset();
|
|
25
|
+
const july = -new Date(Date.UTC(2024, 6, 15)).getTimezoneOffset();
|
|
26
|
+
out['tz.offsetJan'] = january;
|
|
27
|
+
out['tz.offsetJul'] = july;
|
|
28
|
+
out['tz.observesDst'] = january !== july;
|
|
29
|
+
if (declared !== null) {
|
|
30
|
+
// Does the named zone actually produce the offsets the engine applies?
|
|
31
|
+
const observed = offsetOf(declared, new Date(Date.UTC(2024, 0, 15)));
|
|
32
|
+
out['tz.consistent'] = observed === null ? null : observed === january;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
out['tz.offsetJan'] = null;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** Minutes east of UTC that a named zone was at a given instant. */
|
|
42
|
+
function offsetOf(timeZone, at) {
|
|
43
|
+
try {
|
|
44
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
45
|
+
timeZone,
|
|
46
|
+
hour12: false,
|
|
47
|
+
year: 'numeric',
|
|
48
|
+
month: '2-digit',
|
|
49
|
+
day: '2-digit',
|
|
50
|
+
hour: '2-digit',
|
|
51
|
+
minute: '2-digit',
|
|
52
|
+
second: '2-digit',
|
|
53
|
+
});
|
|
54
|
+
const parts = {};
|
|
55
|
+
for (const part of formatter.formatToParts(at))
|
|
56
|
+
parts[part.type] = part.value;
|
|
57
|
+
const asUtc = Date.UTC(Number(parts['year']), Number(parts['month']) - 1, Number(parts['day']), Number(parts['hour']), Number(parts['minute']), Number(parts['second']));
|
|
58
|
+
return Math.round((asUtc - at.getTime()) / 60_000);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|