@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,168 @@
|
|
|
1
|
+
import { hash } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the WebRTC stack says about the network beneath the browser.
|
|
4
|
+
*
|
|
5
|
+
* In `INVASIVE_COLLECTORS` rather than the default set. No permission prompt is
|
|
6
|
+
* involved — candidate gathering needs none — but this reads the shape of
|
|
7
|
+
* somebody's home network, which is about the person rather than the browser.
|
|
8
|
+
*
|
|
9
|
+
* ## What it is for
|
|
10
|
+
*
|
|
11
|
+
* A candidate is a route the browser would accept media on, and the set of them
|
|
12
|
+
* describes the path out:
|
|
13
|
+
*
|
|
14
|
+
* `host` an address on the machine itself
|
|
15
|
+
* `srflx` the address a STUN server saw, which is the real public egress
|
|
16
|
+
* `relay` a TURN relay, meaning nothing direct worked
|
|
17
|
+
*
|
|
18
|
+
* The `srflx` address is the one that matters, and it is the reason this is
|
|
19
|
+
* worth collecting: it is observed by a third party rather than asserted by the
|
|
20
|
+
* client, so a proxy that rewrites HTTP headers does not move it. A request
|
|
21
|
+
* whose HTTP address is residential while its `srflx` address belongs to a
|
|
22
|
+
* datacentre is a contradiction the header cannot produce on its own.
|
|
23
|
+
*
|
|
24
|
+
* ## What it mostly gets, in practice
|
|
25
|
+
*
|
|
26
|
+
* Browsers have spent years closing this. Chrome replaces host candidates with
|
|
27
|
+
* an mDNS name — a random `.local` hostname regenerated per origin — so the
|
|
28
|
+
* classic "local IP leak" is gone by default. Firefox does the same under
|
|
29
|
+
* `resistFingerprinting`.
|
|
30
|
+
*
|
|
31
|
+
* That is recorded rather than worked around: `webrtc.mdns` says the
|
|
32
|
+
* obfuscation is active, which is itself a fact about the browser, and no local
|
|
33
|
+
* address is reported when there is none to report. What survives is the
|
|
34
|
+
* candidate *shape* — how many of each type, whether a relay was needed,
|
|
35
|
+
* whether gathering completed at all — and the public address, which is not
|
|
36
|
+
* obfuscated because a STUN server has to know it for the protocol to work.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* A public STUN server, used only to learn the reflexive address.
|
|
40
|
+
*
|
|
41
|
+
* This is an outbound request from the visitor's browser to a third party, and
|
|
42
|
+
* it is the reason this collector is opt-in even though it prompts for nothing.
|
|
43
|
+
* Google's is used because it is the one every WebRTC application already
|
|
44
|
+
* contacts, so it adds no new party to a connection the browser makes anyway —
|
|
45
|
+
* but a deployment that would rather not can run its own and change this line.
|
|
46
|
+
*/
|
|
47
|
+
const STUN = 'stun:stun.l.google.com:19302';
|
|
48
|
+
/** Short: gathering usually settles in well under this, and the wave has a budget. */
|
|
49
|
+
const GATHER_TIMEOUT_MS = 700;
|
|
50
|
+
export const webrtcCollector = {
|
|
51
|
+
id: 'webrtc',
|
|
52
|
+
wave: 'deep',
|
|
53
|
+
optional: true,
|
|
54
|
+
async collect({ window }) {
|
|
55
|
+
const out = {};
|
|
56
|
+
const Ctor = window.RTCPeerConnection ??
|
|
57
|
+
window.webkitRTCPeerConnection;
|
|
58
|
+
if (Ctor === undefined) {
|
|
59
|
+
// Present and null: the probe ran and the API is absent, which is a
|
|
60
|
+
// different fact from the collector never running.
|
|
61
|
+
out['webrtc.supported'] = false;
|
|
62
|
+
out['webrtc.candidates'] = null;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
out['webrtc.supported'] = true;
|
|
66
|
+
let connection;
|
|
67
|
+
try {
|
|
68
|
+
connection = new Ctor({ iceServers: [{ urls: STUN }] });
|
|
69
|
+
// A data channel is what makes the browser gather at all. Nothing is
|
|
70
|
+
// sent on it and no media device is touched.
|
|
71
|
+
connection.createDataChannel('probe');
|
|
72
|
+
const candidates = [];
|
|
73
|
+
const gathered = new Promise((resolve) => {
|
|
74
|
+
let settled = false;
|
|
75
|
+
const finish = () => {
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
settled = true;
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
connection?.addEventListener('icecandidate', (event) => {
|
|
82
|
+
// A null candidate is the end-of-gathering marker, not a failure.
|
|
83
|
+
if (event.candidate === null)
|
|
84
|
+
return finish();
|
|
85
|
+
if (event.candidate.candidate !== '')
|
|
86
|
+
candidates.push(event.candidate.candidate);
|
|
87
|
+
});
|
|
88
|
+
setTimeout(finish, GATHER_TIMEOUT_MS);
|
|
89
|
+
});
|
|
90
|
+
const offer = await connection.createOffer();
|
|
91
|
+
await connection.setLocalDescription(offer);
|
|
92
|
+
await gathered;
|
|
93
|
+
summarise(candidates, out);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
out['webrtc.candidates'] = null;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
try {
|
|
100
|
+
connection?.close();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// A connection that will not close is not worth a thrown signal.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
/** IPv4 and IPv6 both, since a candidate line carries either. */
|
|
110
|
+
const ADDRESS = /(?:\d{1,3}\.){3}\d{1,3}|(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}/i;
|
|
111
|
+
function summarise(candidates, out) {
|
|
112
|
+
out['webrtc.candidates'] = candidates.length;
|
|
113
|
+
if (candidates.length === 0) {
|
|
114
|
+
// Gathering produced nothing. Common in containers with no usable
|
|
115
|
+
// interface, and common behind a firewall that drops STUN — so it is
|
|
116
|
+
// reported as a count rather than inferred into a verdict.
|
|
117
|
+
out['webrtc.mdns'] = null;
|
|
118
|
+
out['webrtc.publicIp'] = null;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let host = 0;
|
|
122
|
+
let srflx = 0;
|
|
123
|
+
let relay = 0;
|
|
124
|
+
let mdns = 0;
|
|
125
|
+
const publicAddresses = new Set();
|
|
126
|
+
const localAddresses = new Set();
|
|
127
|
+
for (const line of candidates) {
|
|
128
|
+
const type = /\btyp\s+(\w+)/.exec(line)?.[1] ?? '';
|
|
129
|
+
if (type === 'host')
|
|
130
|
+
host++;
|
|
131
|
+
else if (type === 'srflx')
|
|
132
|
+
srflx++;
|
|
133
|
+
else if (type === 'relay')
|
|
134
|
+
relay++;
|
|
135
|
+
// Chrome substitutes a per-origin random hostname for host candidates, so
|
|
136
|
+
// the address field reads `<uuid>.local` rather than an address.
|
|
137
|
+
if (line.includes('.local')) {
|
|
138
|
+
mdns++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const address = ADDRESS.exec(line)?.[0];
|
|
142
|
+
if (address === undefined)
|
|
143
|
+
continue;
|
|
144
|
+
if (type === 'host')
|
|
145
|
+
localAddresses.add(address);
|
|
146
|
+
else if (type === 'srflx' || type === 'relay')
|
|
147
|
+
publicAddresses.add(address);
|
|
148
|
+
}
|
|
149
|
+
out['webrtc.host'] = host;
|
|
150
|
+
out['webrtc.srflx'] = srflx;
|
|
151
|
+
out['webrtc.relay'] = relay;
|
|
152
|
+
// True when the browser is obfuscating host candidates, which is the default
|
|
153
|
+
// on current Chrome. A fact about the browser, not a failure of the probe.
|
|
154
|
+
out['webrtc.mdns'] = mdns > 0;
|
|
155
|
+
// The reflexive address: observed by a third party rather than asserted by
|
|
156
|
+
// the client, which is what makes it worth comparing against the HTTP one.
|
|
157
|
+
const [first] = [...publicAddresses].sort();
|
|
158
|
+
out['webrtc.publicIp'] = first ?? null;
|
|
159
|
+
// Local addresses are hashed rather than reported. The count and stability
|
|
160
|
+
// are what a check would read — whether the machine has one interface or
|
|
161
|
+
// twelve, and whether that changed — and the addresses themselves describe
|
|
162
|
+
// somebody's home network without adding anything to that.
|
|
163
|
+
out['webrtc.localCount'] = localAddresses.size;
|
|
164
|
+
out['webrtc.localHash'] = localAddresses.size === 0 ? null : hash([...localAddresses].sort().join(','));
|
|
165
|
+
// No direct route was usable. Ordinary on a locked-down corporate network,
|
|
166
|
+
// and also what a relayed or heavily proxied connection looks like.
|
|
167
|
+
out['webrtc.relayOnly'] = relay > 0 && srflx === 0;
|
|
168
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { SignalValue } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The stable core: what a device still looks like after it clears storage.
|
|
4
|
+
*
|
|
5
|
+
* The device id lives in cookies, localStorage and IndexedDB, so it is gone the
|
|
6
|
+
* moment someone opens a private window or clears site data — and with it goes
|
|
7
|
+
* every limit keyed on the device, including the credential-stuffing defence.
|
|
8
|
+
* That is not a bug in the storage layer; it is what private browsing *is*.
|
|
9
|
+
*
|
|
10
|
+
* The answer is a second, weaker identifier built only from signals that do not
|
|
11
|
+
* depend on storage at all: the GPU, the rendering stack, the CPU class, the
|
|
12
|
+
* screen. Those are the same in a private window as outside one.
|
|
13
|
+
*
|
|
14
|
+
* ## What is deliberately excluded
|
|
15
|
+
*
|
|
16
|
+
* Anything that changes on its own. A browser version, a window size, a
|
|
17
|
+
* connection type — include those and the core shifts under a returning user,
|
|
18
|
+
* which is the failure that makes a fingerprint useless: it stops recognising
|
|
19
|
+
* the people it is supposed to recognise.
|
|
20
|
+
*
|
|
21
|
+
* ## The privacy tension, stated plainly
|
|
22
|
+
*
|
|
23
|
+
* This links a private window to ordinary browsing on the same machine. Someone
|
|
24
|
+
* using a private window is often specifically trying to prevent that. There is
|
|
25
|
+
* no version of this that is both effective against storage clearing and
|
|
26
|
+
* respectful of the intent behind it — the two are the same act.
|
|
27
|
+
*
|
|
28
|
+
* So it is: hashed with the tenant salt before it is stored, never shared
|
|
29
|
+
* between tenants, suppressed entirely under `consent: 'deferred'`, and
|
|
30
|
+
* individually disableable. And it is weaker evidence than a real device id by
|
|
31
|
+
* design — enough to notice one device trying forty accounts, not enough to
|
|
32
|
+
* treat as identity.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Signals that survive a storage wipe and do not drift.
|
|
36
|
+
*
|
|
37
|
+
* Ordered for readability; the hash sorts them.
|
|
38
|
+
*/
|
|
39
|
+
declare const STABLE_KEYS: readonly ["webgl.renderer", "webgl.vendor", "webgl.parametersHash", "webgl.fragmentHighFloat", "math.quirksHash", "err.nullAccessMessage", "screen.colorDepth", "screen.devicePixelRatio", "nav.hardwareConcurrency", "nav.deviceMemory", "nav.maxTouchPoints", "nav.platform", "tz.timezone"];
|
|
40
|
+
/**
|
|
41
|
+
* Keys left out, with the reason — so the next person to add one has to argue
|
|
42
|
+
* with something.
|
|
43
|
+
*
|
|
44
|
+
* nav.userAgent moves on every browser update
|
|
45
|
+
* features.hash moves on every browser update
|
|
46
|
+
* screen.innerWidth moves when the window is resized
|
|
47
|
+
* screen.chromeHeight needs focus, and moves with the toolbar
|
|
48
|
+
* nav.languages a user can change it, and some browsers reorder it
|
|
49
|
+
* canvas.stable a property of the browser's privacy settings, not the device
|
|
50
|
+
* nav.connectionType changes with the network, which is the point of it
|
|
51
|
+
*
|
|
52
|
+
* The next three were in the core until a measurement removed them. Safari 26,
|
|
53
|
+
* same machine, normal versus private browsing:
|
|
54
|
+
*
|
|
55
|
+
* canvas.hash 9cabb395 -> ec0c368d
|
|
56
|
+
* screen.width 1728 -> 1340
|
|
57
|
+
* screen.height 1117 -> 823
|
|
58
|
+
*
|
|
59
|
+
* Everything else was identical, so a core without these three would have
|
|
60
|
+
* matched — and with them it could not.
|
|
61
|
+
*
|
|
62
|
+
* canvas.hash Safari's fingerprinting protection seeds canvas noise **per
|
|
63
|
+
* session**. Two renders inside one session agree, so
|
|
64
|
+
* `canvas.stable` reports true and the noise check below does
|
|
65
|
+
* not fire. There is no in-session test that detects this;
|
|
66
|
+
* the only reliable answer is to keep rendered output out of
|
|
67
|
+
* a core that has to survive privacy modes.
|
|
68
|
+
*
|
|
69
|
+
* screen.width Safari private reports the *viewport* as the screen: 1340x823
|
|
70
|
+
* screen.height were exactly innerWidth and innerHeight. So these are not a
|
|
71
|
+
* different screen, they are a spoof — and one that moves
|
|
72
|
+
* again whenever the window is resized.
|
|
73
|
+
*/
|
|
74
|
+
export declare const EXCLUDED_FROM_CORE: readonly ["nav.userAgent", "features.hash", "screen.innerWidth", "screen.chromeHeight", "nav.languages", "canvas.stable", "nav.connectionType", "canvas.hash", "screen.width", "screen.height"];
|
|
75
|
+
export interface StableCore {
|
|
76
|
+
readonly hash: string;
|
|
77
|
+
/** How many of the stable keys were actually present. */
|
|
78
|
+
readonly present: number;
|
|
79
|
+
readonly total: number;
|
|
80
|
+
/** The exact values that went into the hash, so two captures can be diffed. */
|
|
81
|
+
readonly parts: Readonly<Record<string, SignalValue>>;
|
|
82
|
+
/** A randomised signal was detected and left out, lowering the core's entropy. */
|
|
83
|
+
readonly noiseExcluded: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Below this, the hash is too thin to relink on — a handful of common values
|
|
86
|
+
* would collide across unrelated devices, and a wrong link is worse than none.
|
|
87
|
+
*/
|
|
88
|
+
readonly usable: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Signals a browser may randomise between two reads in the same session.
|
|
92
|
+
*
|
|
93
|
+
* Kept as a guard even though canvas is no longer in the core, because the same
|
|
94
|
+
* per-read randomisation could appear on another signal — and because it
|
|
95
|
+
* catches Brave, which farbles per read rather than per session.
|
|
96
|
+
*
|
|
97
|
+
* It does **not** catch Safari, whose noise is seeded once per session: two
|
|
98
|
+
* renders agree, the check passes, and the value still differs the next time
|
|
99
|
+
* the browser is opened. That is why canvas was removed outright rather than
|
|
100
|
+
* guarded. An in-session test cannot detect a per-session seed.
|
|
101
|
+
*/
|
|
102
|
+
declare const NOISE_TARGETS: readonly ["canvas.hash"];
|
|
103
|
+
export declare function stableCore(signals: Readonly<Record<string, SignalValue>>): StableCore;
|
|
104
|
+
/**
|
|
105
|
+
* The machine, separated from the browser running on it.
|
|
106
|
+
*
|
|
107
|
+
* Two tiers because they decay at different rates, and the difference between
|
|
108
|
+
* them is itself information. A browser update changes the engine tier and
|
|
109
|
+
* leaves the hardware tier alone; a new laptop changes both. Hashing all
|
|
110
|
+
* thirteen keys together means any single change mints a brand-new identity,
|
|
111
|
+
* so a returning customer who updated Chrome looks exactly like a stranger.
|
|
112
|
+
*
|
|
113
|
+
* The hardware tier is what a device-keyed limit should key on: it survives a
|
|
114
|
+
* cleared profile *and* a browser update, which is the combination a
|
|
115
|
+
* credential-stuffing run produces on purpose.
|
|
116
|
+
*/
|
|
117
|
+
declare const HARDWARE_KEYS: readonly ["webgl.renderer", "webgl.vendor", "webgl.parametersHash", "webgl.fragmentHighFloat", "screen.colorDepth", "screen.devicePixelRatio", "nav.hardwareConcurrency", "nav.deviceMemory", "nav.maxTouchPoints"];
|
|
118
|
+
/** The build and its configuration: everything that moves when the browser does. */
|
|
119
|
+
declare const BROWSER_KEYS: readonly ["math.quirksHash", "err.nullAccessMessage", "nav.platform", "tz.timezone"];
|
|
120
|
+
export interface CoreTier {
|
|
121
|
+
readonly hash: string;
|
|
122
|
+
readonly present: number;
|
|
123
|
+
readonly total: number;
|
|
124
|
+
readonly usable: boolean;
|
|
125
|
+
}
|
|
126
|
+
export declare function hardwareCore(signals: Readonly<Record<string, SignalValue>>): CoreTier;
|
|
127
|
+
export declare function browserCore(signals: Readonly<Record<string, SignalValue>>): CoreTier;
|
|
128
|
+
export { STABLE_KEYS, NOISE_TARGETS, HARDWARE_KEYS, BROWSER_KEYS };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { hashRecord } from './hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* The stable core: what a device still looks like after it clears storage.
|
|
4
|
+
*
|
|
5
|
+
* The device id lives in cookies, localStorage and IndexedDB, so it is gone the
|
|
6
|
+
* moment someone opens a private window or clears site data — and with it goes
|
|
7
|
+
* every limit keyed on the device, including the credential-stuffing defence.
|
|
8
|
+
* That is not a bug in the storage layer; it is what private browsing *is*.
|
|
9
|
+
*
|
|
10
|
+
* The answer is a second, weaker identifier built only from signals that do not
|
|
11
|
+
* depend on storage at all: the GPU, the rendering stack, the CPU class, the
|
|
12
|
+
* screen. Those are the same in a private window as outside one.
|
|
13
|
+
*
|
|
14
|
+
* ## What is deliberately excluded
|
|
15
|
+
*
|
|
16
|
+
* Anything that changes on its own. A browser version, a window size, a
|
|
17
|
+
* connection type — include those and the core shifts under a returning user,
|
|
18
|
+
* which is the failure that makes a fingerprint useless: it stops recognising
|
|
19
|
+
* the people it is supposed to recognise.
|
|
20
|
+
*
|
|
21
|
+
* ## The privacy tension, stated plainly
|
|
22
|
+
*
|
|
23
|
+
* This links a private window to ordinary browsing on the same machine. Someone
|
|
24
|
+
* using a private window is often specifically trying to prevent that. There is
|
|
25
|
+
* no version of this that is both effective against storage clearing and
|
|
26
|
+
* respectful of the intent behind it — the two are the same act.
|
|
27
|
+
*
|
|
28
|
+
* So it is: hashed with the tenant salt before it is stored, never shared
|
|
29
|
+
* between tenants, suppressed entirely under `consent: 'deferred'`, and
|
|
30
|
+
* individually disableable. And it is weaker evidence than a real device id by
|
|
31
|
+
* design — enough to notice one device trying forty accounts, not enough to
|
|
32
|
+
* treat as identity.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Signals that survive a storage wipe and do not drift.
|
|
36
|
+
*
|
|
37
|
+
* Ordered for readability; the hash sorts them.
|
|
38
|
+
*/
|
|
39
|
+
const STABLE_KEYS = [
|
|
40
|
+
// Rendering stack. These are strings the driver reports, not rendered output,
|
|
41
|
+
// which is what makes them survive fingerprinting protection.
|
|
42
|
+
'webgl.renderer',
|
|
43
|
+
'webgl.vendor',
|
|
44
|
+
'webgl.parametersHash',
|
|
45
|
+
'webgl.fragmentHighFloat',
|
|
46
|
+
// Engine and libm quirks: constant for a given browser build on given hardware.
|
|
47
|
+
'math.quirksHash',
|
|
48
|
+
'err.nullAccessMessage',
|
|
49
|
+
// Physical machine — the properties privacy modes leave alone.
|
|
50
|
+
'screen.colorDepth',
|
|
51
|
+
'screen.devicePixelRatio',
|
|
52
|
+
'nav.hardwareConcurrency',
|
|
53
|
+
'nav.deviceMemory',
|
|
54
|
+
'nav.maxTouchPoints',
|
|
55
|
+
// Configuration a user rarely changes and never mid-session.
|
|
56
|
+
'nav.platform',
|
|
57
|
+
'tz.timezone',
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* Keys left out, with the reason — so the next person to add one has to argue
|
|
61
|
+
* with something.
|
|
62
|
+
*
|
|
63
|
+
* nav.userAgent moves on every browser update
|
|
64
|
+
* features.hash moves on every browser update
|
|
65
|
+
* screen.innerWidth moves when the window is resized
|
|
66
|
+
* screen.chromeHeight needs focus, and moves with the toolbar
|
|
67
|
+
* nav.languages a user can change it, and some browsers reorder it
|
|
68
|
+
* canvas.stable a property of the browser's privacy settings, not the device
|
|
69
|
+
* nav.connectionType changes with the network, which is the point of it
|
|
70
|
+
*
|
|
71
|
+
* The next three were in the core until a measurement removed them. Safari 26,
|
|
72
|
+
* same machine, normal versus private browsing:
|
|
73
|
+
*
|
|
74
|
+
* canvas.hash 9cabb395 -> ec0c368d
|
|
75
|
+
* screen.width 1728 -> 1340
|
|
76
|
+
* screen.height 1117 -> 823
|
|
77
|
+
*
|
|
78
|
+
* Everything else was identical, so a core without these three would have
|
|
79
|
+
* matched — and with them it could not.
|
|
80
|
+
*
|
|
81
|
+
* canvas.hash Safari's fingerprinting protection seeds canvas noise **per
|
|
82
|
+
* session**. Two renders inside one session agree, so
|
|
83
|
+
* `canvas.stable` reports true and the noise check below does
|
|
84
|
+
* not fire. There is no in-session test that detects this;
|
|
85
|
+
* the only reliable answer is to keep rendered output out of
|
|
86
|
+
* a core that has to survive privacy modes.
|
|
87
|
+
*
|
|
88
|
+
* screen.width Safari private reports the *viewport* as the screen: 1340x823
|
|
89
|
+
* screen.height were exactly innerWidth and innerHeight. So these are not a
|
|
90
|
+
* different screen, they are a spoof — and one that moves
|
|
91
|
+
* again whenever the window is resized.
|
|
92
|
+
*/
|
|
93
|
+
export const EXCLUDED_FROM_CORE = [
|
|
94
|
+
'nav.userAgent',
|
|
95
|
+
'features.hash',
|
|
96
|
+
'screen.innerWidth',
|
|
97
|
+
'screen.chromeHeight',
|
|
98
|
+
'nav.languages',
|
|
99
|
+
'canvas.stable',
|
|
100
|
+
'nav.connectionType',
|
|
101
|
+
'canvas.hash',
|
|
102
|
+
'screen.width',
|
|
103
|
+
'screen.height',
|
|
104
|
+
];
|
|
105
|
+
const MINIMUM_KEYS = 8;
|
|
106
|
+
/**
|
|
107
|
+
* Signals a browser may randomise between two reads in the same session.
|
|
108
|
+
*
|
|
109
|
+
* Kept as a guard even though canvas is no longer in the core, because the same
|
|
110
|
+
* per-read randomisation could appear on another signal — and because it
|
|
111
|
+
* catches Brave, which farbles per read rather than per session.
|
|
112
|
+
*
|
|
113
|
+
* It does **not** catch Safari, whose noise is seeded once per session: two
|
|
114
|
+
* renders agree, the check passes, and the value still differs the next time
|
|
115
|
+
* the browser is opened. That is why canvas was removed outright rather than
|
|
116
|
+
* guarded. An in-session test cannot detect a per-session seed.
|
|
117
|
+
*/
|
|
118
|
+
const NOISE_TARGETS = ['canvas.hash'];
|
|
119
|
+
export function stableCore(signals) {
|
|
120
|
+
const present = {};
|
|
121
|
+
// `canvas.stable` is the collector rendering twice and comparing. False means
|
|
122
|
+
// the browser is injecting noise, and we already know not to trust it.
|
|
123
|
+
const canvasRandomised = signals['canvas.stable'] === false;
|
|
124
|
+
for (const key of STABLE_KEYS) {
|
|
125
|
+
if (canvasRandomised && NOISE_TARGETS.includes(key))
|
|
126
|
+
continue;
|
|
127
|
+
const value = signals[key];
|
|
128
|
+
if (value !== undefined && value !== null && value !== '')
|
|
129
|
+
present[key] = value;
|
|
130
|
+
}
|
|
131
|
+
const count = Object.keys(present).length;
|
|
132
|
+
return {
|
|
133
|
+
hash: hashRecord(present),
|
|
134
|
+
present: count,
|
|
135
|
+
total: STABLE_KEYS.length,
|
|
136
|
+
parts: present,
|
|
137
|
+
noiseExcluded: canvasRandomised,
|
|
138
|
+
// A core built from three values is not an identifier, it is a coincidence
|
|
139
|
+
// waiting to happen. Saying so is cheaper than explaining a wrong link.
|
|
140
|
+
usable: count >= MINIMUM_KEYS,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The machine, separated from the browser running on it.
|
|
145
|
+
*
|
|
146
|
+
* Two tiers because they decay at different rates, and the difference between
|
|
147
|
+
* them is itself information. A browser update changes the engine tier and
|
|
148
|
+
* leaves the hardware tier alone; a new laptop changes both. Hashing all
|
|
149
|
+
* thirteen keys together means any single change mints a brand-new identity,
|
|
150
|
+
* so a returning customer who updated Chrome looks exactly like a stranger.
|
|
151
|
+
*
|
|
152
|
+
* The hardware tier is what a device-keyed limit should key on: it survives a
|
|
153
|
+
* cleared profile *and* a browser update, which is the combination a
|
|
154
|
+
* credential-stuffing run produces on purpose.
|
|
155
|
+
*/
|
|
156
|
+
const HARDWARE_KEYS = [
|
|
157
|
+
'webgl.renderer',
|
|
158
|
+
'webgl.vendor',
|
|
159
|
+
'webgl.parametersHash',
|
|
160
|
+
'webgl.fragmentHighFloat',
|
|
161
|
+
'screen.colorDepth',
|
|
162
|
+
'screen.devicePixelRatio',
|
|
163
|
+
'nav.hardwareConcurrency',
|
|
164
|
+
'nav.deviceMemory',
|
|
165
|
+
'nav.maxTouchPoints',
|
|
166
|
+
];
|
|
167
|
+
/** The build and its configuration: everything that moves when the browser does. */
|
|
168
|
+
const BROWSER_KEYS = ['math.quirksHash', 'err.nullAccessMessage', 'nav.platform', 'tz.timezone'];
|
|
169
|
+
/** Hardware needs fewer keys to be worth trusting, because each carries more. */
|
|
170
|
+
const MINIMUM_HARDWARE_KEYS = 5;
|
|
171
|
+
function tier(signals, keys, minimum) {
|
|
172
|
+
const present = {};
|
|
173
|
+
for (const key of keys) {
|
|
174
|
+
const value = signals[key];
|
|
175
|
+
if (value !== undefined && value !== null && value !== '')
|
|
176
|
+
present[key] = value;
|
|
177
|
+
}
|
|
178
|
+
const count = Object.keys(present).length;
|
|
179
|
+
return { hash: hashRecord(present), present: count, total: keys.length, usable: count >= minimum };
|
|
180
|
+
}
|
|
181
|
+
export function hardwareCore(signals) {
|
|
182
|
+
return tier(signals, HARDWARE_KEYS, MINIMUM_HARDWARE_KEYS);
|
|
183
|
+
}
|
|
184
|
+
export function browserCore(signals) {
|
|
185
|
+
return tier(signals, BROWSER_KEYS, 2);
|
|
186
|
+
}
|
|
187
|
+
export { STABLE_KEYS, NOISE_TARGETS, HARDWARE_KEYS, BROWSER_KEYS };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collectors run in three budgeted waves so a protected action is never
|
|
3
|
+
* delayed. A wave that exceeds its budget is abandoned and reported, never
|
|
4
|
+
* awaited — a slow fingerprint is worse than a missing one.
|
|
5
|
+
*
|
|
6
|
+
* See docs/signals.md section 1.
|
|
7
|
+
*/
|
|
8
|
+
export type Wave = 'instant' | 'warm' | 'deep';
|
|
9
|
+
/**
|
|
10
|
+
* How long each wave may take before the rest of it is skipped.
|
|
11
|
+
*
|
|
12
|
+
* Generous on purpose. A budget is a ceiling, not a cost: a fast device
|
|
13
|
+
* finishes the instant wave in a few milliseconds and never comes near it. The
|
|
14
|
+
* ceiling exists for the slow device, and the old one (instant 5 ms, warm
|
|
15
|
+
* 60 ms) was set against desktop timings — a mid-range phone runs these several
|
|
16
|
+
* times slower and lost half its signals, bot detection among them. The total a
|
|
17
|
+
* protected action waits is still capped by the SDK's `timeoutMs`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const WAVE_BUDGET_MS: Record<Wave, number>;
|
|
20
|
+
/** Everything a collector is allowed to touch, injected rather than global. */
|
|
21
|
+
export interface CollectorEnv {
|
|
22
|
+
readonly window: Window & typeof globalThis;
|
|
23
|
+
readonly navigator: Navigator;
|
|
24
|
+
readonly document: Document;
|
|
25
|
+
readonly screen: Screen;
|
|
26
|
+
readonly performance: Performance;
|
|
27
|
+
}
|
|
28
|
+
export type SignalValue = string | number | boolean | null;
|
|
29
|
+
export interface Collector {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly wave: Wave;
|
|
32
|
+
/**
|
|
33
|
+
* The tenant may switch this off without it counting as missing evidence.
|
|
34
|
+
*
|
|
35
|
+
* Disabling a required collector lowers confidence, because the signal is
|
|
36
|
+
* genuinely absent. Disabling an optional one does not: it is a choice the
|
|
37
|
+
* product offered, and charging confidence for taking it means penalising a
|
|
38
|
+
* tenant for using their own privacy control on every request thereafter.
|
|
39
|
+
*
|
|
40
|
+
* Says nothing about a collector that ran and found nothing. That is a real
|
|
41
|
+
* absence and still counts.
|
|
42
|
+
*/
|
|
43
|
+
readonly optional?: boolean;
|
|
44
|
+
collect(env: CollectorEnv): Promise<Record<string, SignalValue>> | Record<string, SignalValue>;
|
|
45
|
+
}
|
|
46
|
+
export interface CollectionResult {
|
|
47
|
+
readonly signals: Record<string, SignalValue>;
|
|
48
|
+
readonly durationMs: number;
|
|
49
|
+
/** Collectors that threw, timed out, or were disabled. */
|
|
50
|
+
readonly skipped: readonly string[];
|
|
51
|
+
readonly errors: readonly string[];
|
|
52
|
+
/** 0..1 — how much of the expected signal set was actually obtained. */
|
|
53
|
+
readonly confidence: number;
|
|
54
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long each wave may take before the rest of it is skipped.
|
|
3
|
+
*
|
|
4
|
+
* Generous on purpose. A budget is a ceiling, not a cost: a fast device
|
|
5
|
+
* finishes the instant wave in a few milliseconds and never comes near it. The
|
|
6
|
+
* ceiling exists for the slow device, and the old one (instant 5 ms, warm
|
|
7
|
+
* 60 ms) was set against desktop timings — a mid-range phone runs these several
|
|
8
|
+
* times slower and lost half its signals, bot detection among them. The total a
|
|
9
|
+
* protected action waits is still capped by the SDK's `timeoutMs`.
|
|
10
|
+
*/
|
|
11
|
+
export const WAVE_BUDGET_MS = {
|
|
12
|
+
instant: 60,
|
|
13
|
+
warm: 200,
|
|
14
|
+
deep: 400,
|
|
15
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magield/collectors",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Browser signal collectors used by the Magic Shield web SDK",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"!**/*.map",
|
|
9
|
+
"LICENSE"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org/"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.10.2",
|
|
24
|
+
"typescript": "^5.7.2",
|
|
25
|
+
"vitest": "^2.1.8"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"typecheck:tests": "tsc -p tsconfig.test.json",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"clean": "rm -rf dist .turbo"
|
|
33
|
+
}
|
|
6
34
|
}
|
package/README.md
DELETED