@magield/collectors 0.20.0 → 0.21.0

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/registry.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { gpuTimingCollector } from './signals/gpu-timing.js';
1
2
  import { adblockCollector } from './signals/adblock.js';
2
3
  import { appsCollector } from './signals/apps.js';
3
4
  import { audioCollector } from './signals/audio.js';
@@ -76,7 +77,7 @@ export const COLLECTOR_IDS = ALL_COLLECTORS.map((c) => c.id);
76
77
  * The split is the product position, not a technical one. `docs/` says what
77
78
  * Magic Shield collects by default, and this is the list that is not in it.
78
79
  */
79
- export const INVASIVE_COLLECTORS = [webrtcCollector, adblockCollector, appsCollector];
80
+ export const INVASIVE_COLLECTORS = [webrtcCollector, adblockCollector, appsCollector, gpuTimingCollector];
80
81
  /**
81
82
  * Removed: localPortsCollector.
82
83
  *
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const gpuTimingCollector: Collector;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * How long this GPU takes to finish work stalled on each of a few points.
3
+ *
4
+ * A trial, in `INVASIVE_COLLECTORS`, and named `gputiming`: it costs a few
5
+ * hundred milliseconds of GPU and battery on the device, which is a decision
6
+ * for the page, not a default. Nothing scores it. It exists to answer one
7
+ * question on real traffic: can it tell apart phones of the same model, which
8
+ * is what a phone farm is and what every other signal here cannot?
9
+ *
10
+ * ## The idea
11
+ *
12
+ * DrawnApart (NDSS 2022, Laperdrix, Rudametkin, Oren et al.) showed that the
13
+ * execution units of one GPU model run at slightly different speeds, and that
14
+ * timing a draw in which one vertex does heavy work, moved from point to point,
15
+ * yields a vector particular to the physical chip. Canvas noise does not touch
16
+ * it, because it measures time rather than pixels. Reported accuracy on
17
+ * identical hardware ranged from about 36% to over 90% depending on the GPU,
18
+ * and nobody has measured it on low-end Android, which is the reason this is a
19
+ * trial collector and not a signal.
20
+ *
21
+ * ## What is sent
22
+ *
23
+ * gpu.timing the median time for each stall position, divided by their
24
+ * mean, three decimals, comma-joined: the shape, not the
25
+ * speed, so a busy or throttled phone still gives its own shape
26
+ * gpu.timingMs what the measurement cost on this device, to judge the trial
27
+ * gpu.supported false where there is no WebGL
28
+ *
29
+ * Bounded by its own deadline well inside the deep wave's budget: a slow GPU
30
+ * gets a partial measurement reported as null rather than a slow login.
31
+ */
32
+ /** Stall positions, and samples per position. */
33
+ const POINTS = 8;
34
+ const RUNS = 5;
35
+ /** The whole measurement, calibration included, in milliseconds. */
36
+ const DEADLINE_MS = 300;
37
+ /** What one stalled draw should cost, so the timer's coarseness stays small beside it. */
38
+ const TARGET_DRAW_MS = 4;
39
+ const VERTEX = `
40
+ attribute float a_index;
41
+ uniform float u_stall;
42
+ uniform int u_work;
43
+ void main() {
44
+ float acc = a_index;
45
+ if (abs(a_index - u_stall) < 0.5) {
46
+ for (int i = 0; i < 100000; i++) {
47
+ if (i >= u_work) break;
48
+ acc = sin(acc) * 1.0001 + cos(acc * 0.999);
49
+ }
50
+ }
51
+ gl_PointSize = 1.0;
52
+ gl_Position = vec4(acc * 1e-9, 0.0, 0.0, 1.0);
53
+ }`;
54
+ const FRAGMENT = `
55
+ precision mediump float;
56
+ void main() { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); }`;
57
+ export const gpuTimingCollector = {
58
+ id: 'gputiming',
59
+ wave: 'deep',
60
+ optional: true,
61
+ collect({ document, performance }) {
62
+ const out = {};
63
+ const started = performance.now();
64
+ let gl = null;
65
+ try {
66
+ const canvas = document.createElement('canvas');
67
+ canvas.width = 1;
68
+ canvas.height = 1;
69
+ gl = canvas.getContext('webgl');
70
+ }
71
+ catch {
72
+ gl = null;
73
+ }
74
+ if (gl === null) {
75
+ out['gpu.supported'] = false;
76
+ out['gpu.timing'] = null;
77
+ return out;
78
+ }
79
+ out['gpu.supported'] = true;
80
+ const program = link(gl);
81
+ if (program === undefined) {
82
+ out['gpu.timing'] = null;
83
+ return out;
84
+ }
85
+ gl.useProgram(program);
86
+ const buffer = gl.createBuffer();
87
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
88
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(Array.from({ length: POINTS }, (_, i) => i)), gl.STATIC_DRAW);
89
+ const index = gl.getAttribLocation(program, 'a_index');
90
+ gl.enableVertexAttribArray(index);
91
+ gl.vertexAttribPointer(index, 1, gl.FLOAT, false, 0, 0);
92
+ const stall = gl.getUniformLocation(program, 'u_stall');
93
+ const work = gl.getUniformLocation(program, 'u_work');
94
+ const context = gl;
95
+ const draw = (position, iterations) => {
96
+ context.uniform1f(stall, position);
97
+ context.uniform1i(work, iterations);
98
+ const t0 = performance.now();
99
+ context.drawArrays(context.POINTS, 0, POINTS);
100
+ context.finish();
101
+ return performance.now() - t0;
102
+ };
103
+ // Calibrate the work so one stalled draw costs about TARGET_DRAW_MS here:
104
+ // a fast GPU needs more of it to rise above the timer's resolution, a slow
105
+ // one less to stay inside the deadline.
106
+ let iterations = 2000;
107
+ for (let attempt = 0; attempt < 4; attempt++) {
108
+ const ms = Math.max(0.05, draw(0, iterations));
109
+ if (ms > TARGET_DRAW_MS * 0.5 && ms < TARGET_DRAW_MS * 2)
110
+ break;
111
+ iterations = Math.max(100, Math.min(100_000, Math.round((iterations * TARGET_DRAW_MS) / ms)));
112
+ if (performance.now() - started > DEADLINE_MS / 3)
113
+ break;
114
+ }
115
+ const samples = Array.from({ length: POINTS }, () => []);
116
+ // Interleaved, so a thermal or scheduling drift during the measurement
117
+ // falls on every position alike instead of on the last ones.
118
+ for (let run = 0; run < RUNS; run++) {
119
+ for (let position = 0; position < POINTS; position++) {
120
+ if (performance.now() - started > DEADLINE_MS) {
121
+ out['gpu.timing'] = null;
122
+ out['gpu.timingMs'] = Math.round(performance.now() - started);
123
+ return out;
124
+ }
125
+ samples[position]?.push(draw(position, iterations));
126
+ }
127
+ }
128
+ const medians = samples.map(median);
129
+ const mean = medians.reduce((sum, value) => sum + value, 0) / medians.length;
130
+ out['gpu.timing'] = mean > 0 ? medians.map((value) => (value / mean).toFixed(3)).join(',') : null;
131
+ out['gpu.timingMs'] = Math.round(performance.now() - started);
132
+ return out;
133
+ },
134
+ };
135
+ function link(gl) {
136
+ const compile = (type, source) => {
137
+ const shader = gl.createShader(type);
138
+ if (shader === null)
139
+ return undefined;
140
+ gl.shaderSource(shader, source);
141
+ gl.compileShader(shader);
142
+ return gl.getShaderParameter(shader, gl.COMPILE_STATUS) === true ? shader : undefined;
143
+ };
144
+ const vertex = compile(gl.VERTEX_SHADER, VERTEX);
145
+ const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT);
146
+ const program = gl.createProgram();
147
+ if (vertex === undefined || fragment === undefined || program === null)
148
+ return undefined;
149
+ gl.attachShader(program, vertex);
150
+ gl.attachShader(program, fragment);
151
+ gl.linkProgram(program);
152
+ return gl.getProgramParameter(program, gl.LINK_STATUS) === true ? program : undefined;
153
+ }
154
+ function median(values) {
155
+ const sorted = [...values].sort((a, b) => a - b);
156
+ return sorted[Math.floor(sorted.length / 2)] ?? 0;
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magield/collectors",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Browser signal collectors used by the Magic Shield web SDK",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "files": [