@svrnsec/pulse 0.3.1 → 0.4.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/bin/svrnsec-pulse.js +7 -0
- package/index.d.ts +130 -0
- package/package.json +51 -24
- package/src/analysis/audio.js +213 -0
- package/src/analysis/coherence.js +502 -0
- package/src/analysis/heuristic.js +428 -0
- package/src/analysis/jitter.js +446 -0
- package/src/analysis/llm.js +472 -0
- package/src/analysis/provider.js +248 -0
- package/src/analysis/trustScore.js +331 -0
- package/src/cli/args.js +36 -0
- package/src/cli/commands/scan.js +192 -0
- package/src/cli/runner.js +157 -0
- package/src/collector/adaptive.js +200 -0
- package/src/collector/bio.js +287 -0
- package/src/collector/canvas.js +239 -0
- package/src/collector/dram.js +203 -0
- package/src/collector/enf.js +311 -0
- package/src/collector/entropy.js +195 -0
- package/src/collector/gpu.js +245 -0
- package/src/collector/sabTimer.js +191 -0
- package/src/fingerprint.js +475 -0
- package/src/index.js +342 -0
- package/src/integrations/react-native.js +459 -0
- package/src/proof/challenge.js +249 -0
- package/src/terminal.js +263 -0
- package/src/update-notifier.js +264 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — Bio-Binding Layer
|
|
3
|
+
*
|
|
4
|
+
* Captures mouse-movement micro-stutters and keystroke-cadence dynamics
|
|
5
|
+
* WHILE the hardware entropy probe is running. Computes the
|
|
6
|
+
* "Interference Coefficient": how much human input jitters hardware timing.
|
|
7
|
+
*
|
|
8
|
+
* PRIVACY NOTE: Only timing deltas are retained. No key labels, no raw
|
|
9
|
+
* (x, y) coordinates, no content of any kind is stored or transmitted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Internal state
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const MAX_EVENTS = 500; // rolling buffer cap
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// BioCollector class
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export class BioCollector {
|
|
21
|
+
constructor() {
|
|
22
|
+
this._mouseEvents = []; // { t: DOMHighResTimeStamp, dx, dy }
|
|
23
|
+
this._keyEvents = []; // { t, type: 'down'|'up', dwell: ms|null }
|
|
24
|
+
this._lastKey = {}; // keyCode → { downAt: t }
|
|
25
|
+
this._lastMouse = null; // { t, x, y }
|
|
26
|
+
this._startTime = null;
|
|
27
|
+
this._active = false;
|
|
28
|
+
|
|
29
|
+
// Bound handlers (needed for removeEventListener)
|
|
30
|
+
this._onMouseMove = this._onMouseMove.bind(this);
|
|
31
|
+
this._onKeyDown = this._onKeyDown.bind(this);
|
|
32
|
+
this._onKeyUp = this._onKeyUp.bind(this);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
start() {
|
|
38
|
+
if (this._active) return;
|
|
39
|
+
this._active = true;
|
|
40
|
+
this._startTime = performance.now();
|
|
41
|
+
|
|
42
|
+
if (typeof window !== 'undefined') {
|
|
43
|
+
window.addEventListener('pointermove', this._onMouseMove, { passive: true });
|
|
44
|
+
window.addEventListener('keydown', this._onKeyDown, { passive: true });
|
|
45
|
+
window.addEventListener('keyup', this._onKeyUp, { passive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop() {
|
|
50
|
+
if (!this._active) return;
|
|
51
|
+
this._active = false;
|
|
52
|
+
|
|
53
|
+
if (typeof window !== 'undefined') {
|
|
54
|
+
window.removeEventListener('pointermove', this._onMouseMove);
|
|
55
|
+
window.removeEventListener('keydown', this._onKeyDown);
|
|
56
|
+
window.removeEventListener('keyup', this._onKeyUp);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Event handlers ────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
_onMouseMove(e) {
|
|
63
|
+
if (!this._active) return;
|
|
64
|
+
const t = e.timeStamp ?? performance.now();
|
|
65
|
+
const cur = { t, x: e.clientX, y: e.clientY };
|
|
66
|
+
|
|
67
|
+
if (this._lastMouse) {
|
|
68
|
+
const dt = t - this._lastMouse.t;
|
|
69
|
+
const dx = cur.x - this._lastMouse.x;
|
|
70
|
+
const dy = cur.y - this._lastMouse.y;
|
|
71
|
+
// Only store the delta, not absolute position (privacy)
|
|
72
|
+
if (this._mouseEvents.length < MAX_EVENTS) {
|
|
73
|
+
this._mouseEvents.push({ t, dt, dx, dy,
|
|
74
|
+
pressure: e.pressure ?? 0,
|
|
75
|
+
pointerType: e.pointerType ?? 'mouse' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this._lastMouse = cur;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_onKeyDown(e) {
|
|
82
|
+
if (!this._active) return;
|
|
83
|
+
const t = e.timeStamp ?? performance.now();
|
|
84
|
+
// Store timestamp keyed by code (NOT key label)
|
|
85
|
+
this._lastKey[e.code] = { downAt: t };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_onKeyUp(e) {
|
|
89
|
+
if (!this._active) return;
|
|
90
|
+
const t = e.timeStamp ?? performance.now();
|
|
91
|
+
const rec = this._lastKey[e.code];
|
|
92
|
+
const dwell = rec ? (t - rec.downAt) : null;
|
|
93
|
+
delete this._lastKey[e.code];
|
|
94
|
+
|
|
95
|
+
if (this._keyEvents.length < MAX_EVENTS) {
|
|
96
|
+
// Only dwell time; key identity NOT stored.
|
|
97
|
+
this._keyEvents.push({ t, dwell });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── snapshot ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns a privacy-preserving statistical snapshot of collected bio signals.
|
|
105
|
+
* Raw events are summarised; nothing identifiable is included in the output.
|
|
106
|
+
*
|
|
107
|
+
* @param {number[]} computationTimings - entropy probe timing array
|
|
108
|
+
* @returns {BioSnapshot}
|
|
109
|
+
*/
|
|
110
|
+
snapshot(computationTimings = []) {
|
|
111
|
+
const now = performance.now();
|
|
112
|
+
const durationMs = this._startTime != null ? (now - this._startTime) : 0;
|
|
113
|
+
|
|
114
|
+
// ── Mouse statistics ────────────────────────────────────────────────
|
|
115
|
+
const iei = this._mouseEvents.map(e => e.dt);
|
|
116
|
+
const velocities = this._mouseEvents.map(e =>
|
|
117
|
+
e.dt > 0 ? Math.hypot(e.dx, e.dy) / e.dt : 0
|
|
118
|
+
);
|
|
119
|
+
const pressure = this._mouseEvents.map(e => e.pressure);
|
|
120
|
+
const angJerk = _computeAngularJerk(this._mouseEvents);
|
|
121
|
+
|
|
122
|
+
const mouseStats = {
|
|
123
|
+
sampleCount: iei.length,
|
|
124
|
+
ieiMean: _mean(iei),
|
|
125
|
+
ieiCV: _cv(iei),
|
|
126
|
+
velocityP50: _percentile(velocities, 50),
|
|
127
|
+
velocityP95: _percentile(velocities, 95),
|
|
128
|
+
angularJerkMean: _mean(angJerk),
|
|
129
|
+
pressureVariance: _variance(pressure),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ── Keyboard statistics ───────────────────────────────────────────────
|
|
133
|
+
const dwellTimes = this._keyEvents.filter(e => e.dwell != null).map(e => e.dwell);
|
|
134
|
+
const iki = [];
|
|
135
|
+
for (let i = 1; i < this._keyEvents.length; i++) {
|
|
136
|
+
iki.push(this._keyEvents[i].t - this._keyEvents[i - 1].t);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const keyStats = {
|
|
140
|
+
sampleCount: dwellTimes.length,
|
|
141
|
+
dwellMean: _mean(dwellTimes),
|
|
142
|
+
dwellCV: _cv(dwellTimes),
|
|
143
|
+
ikiMean: _mean(iki),
|
|
144
|
+
ikiCV: _cv(iki),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ── Interference Coefficient ──────────────────────────────────────────
|
|
148
|
+
// Cross-correlate input event density with computation timing deviations.
|
|
149
|
+
// A real human on real hardware creates measurable CPU-scheduling pressure
|
|
150
|
+
// that perturbs the entropy probe's timing.
|
|
151
|
+
const interferenceCoefficient = _computeInterference(
|
|
152
|
+
this._mouseEvents,
|
|
153
|
+
this._keyEvents,
|
|
154
|
+
computationTimings,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
mouse: mouseStats,
|
|
159
|
+
keyboard: keyStats,
|
|
160
|
+
interferenceCoefficient,
|
|
161
|
+
durationMs,
|
|
162
|
+
hasActivity: iei.length > 5 || dwellTimes.length > 2,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @typedef {object} BioSnapshot
|
|
169
|
+
* @property {object} mouse
|
|
170
|
+
* @property {object} keyboard
|
|
171
|
+
* @property {number} interferenceCoefficient – [−1, 1]; higher = more human
|
|
172
|
+
* @property {number} durationMs
|
|
173
|
+
* @property {boolean} hasActivity
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Statistical helpers (private)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function _mean(arr) {
|
|
181
|
+
if (!arr.length) return 0;
|
|
182
|
+
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _variance(arr) {
|
|
186
|
+
if (arr.length < 2) return 0;
|
|
187
|
+
const m = _mean(arr);
|
|
188
|
+
return arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _cv(arr) {
|
|
192
|
+
if (!arr.length) return 0;
|
|
193
|
+
const m = _mean(arr);
|
|
194
|
+
if (m === 0) return 0;
|
|
195
|
+
return Math.sqrt(_variance(arr)) / Math.abs(m);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _percentile(sorted, p) {
|
|
199
|
+
const arr = [...sorted].sort((a, b) => a - b);
|
|
200
|
+
if (!arr.length) return 0;
|
|
201
|
+
const idx = (p / 100) * (arr.length - 1);
|
|
202
|
+
const lo = Math.floor(idx);
|
|
203
|
+
const hi = Math.ceil(idx);
|
|
204
|
+
return arr[lo] + (arr[hi] - arr[lo]) * (idx - lo);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Angular jerk: second derivative of movement direction (radians / s²) */
|
|
208
|
+
function _computeAngularJerk(events) {
|
|
209
|
+
if (events.length < 3) return [];
|
|
210
|
+
const angles = [];
|
|
211
|
+
for (let i = 0; i < events.length; i++) {
|
|
212
|
+
const { dx, dy } = events[i];
|
|
213
|
+
angles.push(Math.atan2(dy, dx));
|
|
214
|
+
}
|
|
215
|
+
const d1 = [];
|
|
216
|
+
for (let i = 1; i < angles.length; i++) {
|
|
217
|
+
const dt = events[i].dt || 1;
|
|
218
|
+
d1.push((angles[i] - angles[i - 1]) / dt);
|
|
219
|
+
}
|
|
220
|
+
const d2 = [];
|
|
221
|
+
for (let i = 1; i < d1.length; i++) {
|
|
222
|
+
const dt = events[i].dt || 1;
|
|
223
|
+
d2.push(Math.abs((d1[i] - d1[i - 1]) / dt));
|
|
224
|
+
}
|
|
225
|
+
return d2;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Interference Coefficient
|
|
230
|
+
*
|
|
231
|
+
* For each computation sample, check whether an input event occurred within
|
|
232
|
+
* ±16 ms (one animation frame). Build two parallel series:
|
|
233
|
+
* X[i] = 1 if input near sample i, else 0
|
|
234
|
+
* Y[i] = deviation of timing[i] from mean timing
|
|
235
|
+
* Return the Pearson correlation between X and Y.
|
|
236
|
+
* A real human on real hardware produces positive correlation (input events
|
|
237
|
+
* cause measurable CPU scheduling perturbations).
|
|
238
|
+
*/
|
|
239
|
+
function _computeInterference(mouseEvents, keyEvents, timings) {
|
|
240
|
+
if (!timings.length) return 0;
|
|
241
|
+
|
|
242
|
+
const allInputTimes = [
|
|
243
|
+
...mouseEvents.map(e => e.t),
|
|
244
|
+
...keyEvents.map(e => e.t),
|
|
245
|
+
].sort((a, b) => a - b);
|
|
246
|
+
|
|
247
|
+
if (!allInputTimes.length) return 0;
|
|
248
|
+
|
|
249
|
+
const WINDOW_MS = 16;
|
|
250
|
+
const meanTiming = _mean(timings);
|
|
251
|
+
|
|
252
|
+
// We need absolute timestamps for the probe samples.
|
|
253
|
+
// We don't have them directly – use relative index spacing as a proxy.
|
|
254
|
+
// The entropy probe runs for ~(mean * n) ms starting at collectedAt.
|
|
255
|
+
// This is a statistical approximation; the exact alignment improves
|
|
256
|
+
// when callers pass `collectedAt` from the entropy result.
|
|
257
|
+
// For now we distribute samples evenly across the collection window.
|
|
258
|
+
const first = allInputTimes[0];
|
|
259
|
+
const last = allInputTimes[allInputTimes.length - 1];
|
|
260
|
+
const span = Math.max(last - first, 1);
|
|
261
|
+
|
|
262
|
+
const X = timings.map((_, i) => {
|
|
263
|
+
const tSample = first + (i / timings.length) * span;
|
|
264
|
+
return allInputTimes.some(t => Math.abs(t - tSample) < WINDOW_MS) ? 1 : 0;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const Y = timings.map(t => t - meanTiming);
|
|
268
|
+
|
|
269
|
+
return _pearson(X, Y);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _pearson(X, Y) {
|
|
273
|
+
const n = X.length;
|
|
274
|
+
if (n < 2) return 0;
|
|
275
|
+
const mx = _mean(X);
|
|
276
|
+
const my = _mean(Y);
|
|
277
|
+
let num = 0, da = 0, db = 0;
|
|
278
|
+
for (let i = 0; i < n; i++) {
|
|
279
|
+
const a = X[i] - mx;
|
|
280
|
+
const b = Y[i] - my;
|
|
281
|
+
num += a * b;
|
|
282
|
+
da += a * a;
|
|
283
|
+
db += b * b;
|
|
284
|
+
}
|
|
285
|
+
const denom = Math.sqrt(da * db);
|
|
286
|
+
return denom < 1e-14 ? 0 : num / denom;
|
|
287
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — GPU Canvas Fingerprint
|
|
3
|
+
*
|
|
4
|
+
* Collects device-class signals from WebGL and 2D Canvas rendering.
|
|
5
|
+
* The exact pixel values of GPU-rendered scenes are vendor/driver-specific
|
|
6
|
+
* due to floating-point rounding in shader execution. Virtual machines
|
|
7
|
+
* expose software renderers (LLVMpipe, SwiftShader, Microsoft Basic Render
|
|
8
|
+
* Driver) whose strings and output pixels are well-known and enumerable.
|
|
9
|
+
*
|
|
10
|
+
* NO persistent identifier is generated – only a content hash is retained.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { blake3Hex } from '../proof/fingerprint.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Known software-renderer substrings (VM / headless environment indicators)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const SOFTWARE_RENDERER_PATTERNS = [
|
|
19
|
+
'llvmpipe', 'swiftshader', 'softpipe', 'mesa offscreen',
|
|
20
|
+
'microsoft basic render', 'vmware svga', 'virtualbox',
|
|
21
|
+
'parallels', 'angle (', 'google swiftshader',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// collectCanvasFingerprint
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns {Promise<CanvasFingerprint>}
|
|
30
|
+
*/
|
|
31
|
+
export async function collectCanvasFingerprint() {
|
|
32
|
+
const result = {
|
|
33
|
+
webglRenderer: null,
|
|
34
|
+
webglVendor: null,
|
|
35
|
+
webglVersion: null,
|
|
36
|
+
webglPixelHash: null,
|
|
37
|
+
canvas2dHash: null,
|
|
38
|
+
extensionCount: 0,
|
|
39
|
+
extensions: [],
|
|
40
|
+
isSoftwareRenderer: false,
|
|
41
|
+
available: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (typeof document === 'undefined' && typeof OffscreenCanvas === 'undefined') {
|
|
45
|
+
// Node.js / server-side with no DOM – skip gracefully.
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── WebGL fingerprint ────────────────────────────────────────────────────
|
|
50
|
+
try {
|
|
51
|
+
const canvas = _createCanvas(512, 512);
|
|
52
|
+
let gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
|
53
|
+
|
|
54
|
+
if (gl) {
|
|
55
|
+
result.webglVersion = gl instanceof WebGL2RenderingContext ? 2 : 1;
|
|
56
|
+
result.available = true;
|
|
57
|
+
|
|
58
|
+
// Renderer info
|
|
59
|
+
const dbgInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
60
|
+
if (dbgInfo) {
|
|
61
|
+
result.webglRenderer = gl.getParameter(dbgInfo.UNMASKED_RENDERER_WEBGL);
|
|
62
|
+
result.webglVendor = gl.getParameter(dbgInfo.UNMASKED_VENDOR_WEBGL);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Extension list (fingerprints driver capabilities)
|
|
66
|
+
const exts = gl.getSupportedExtensions() ?? [];
|
|
67
|
+
result.extensions = exts;
|
|
68
|
+
result.extensionCount = exts.length;
|
|
69
|
+
|
|
70
|
+
// Software-renderer detection
|
|
71
|
+
const rendererLc = (result.webglRenderer ?? '').toLowerCase();
|
|
72
|
+
result.isSoftwareRenderer = SOFTWARE_RENDERER_PATTERNS.some(p =>
|
|
73
|
+
rendererLc.includes(p)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// ── Render a Mandelbrot fragment scene ───────────────────────────────
|
|
77
|
+
// Floating-point precision differences in the GPU's shader ALU cause
|
|
78
|
+
// per-pixel rounding variations that are stable per device but differ
|
|
79
|
+
// across GPU vendors, driver versions, and software renderers.
|
|
80
|
+
const pixels = _renderMandelbrot(gl, canvas);
|
|
81
|
+
result.webglPixelHash = pixels ? blake3Hex(pixels) : null;
|
|
82
|
+
|
|
83
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
84
|
+
}
|
|
85
|
+
} catch (_) {
|
|
86
|
+
// WebGL blocked (privacy settings, etc.) – continue with 2D canvas.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 2D Canvas fingerprint ────────────────────────────────────────────────
|
|
90
|
+
try {
|
|
91
|
+
const c2 = _createCanvas(200, 50);
|
|
92
|
+
const ctx2 = c2.getContext('2d');
|
|
93
|
+
|
|
94
|
+
if (ctx2) {
|
|
95
|
+
// Text rendering differences: font hinting, subpixel AA, emoji rasterisation
|
|
96
|
+
ctx2.textBaseline = 'top';
|
|
97
|
+
ctx2.font = '14px Arial, sans-serif';
|
|
98
|
+
ctx2.fillStyle = 'rgba(102,204,0,0.7)';
|
|
99
|
+
ctx2.fillText('Cwm fjordbank glyphs vext quiz 🎯', 2, 5);
|
|
100
|
+
|
|
101
|
+
// Shadow compositing (driver-specific blur kernel)
|
|
102
|
+
ctx2.shadowBlur = 10;
|
|
103
|
+
ctx2.shadowColor = 'blue';
|
|
104
|
+
ctx2.fillStyle = 'rgba(255,0,255,0.5)';
|
|
105
|
+
ctx2.fillRect(100, 25, 80, 20);
|
|
106
|
+
|
|
107
|
+
// Bezier curve (Bézier precision varies per 2D canvas implementation)
|
|
108
|
+
ctx2.beginPath();
|
|
109
|
+
ctx2.moveTo(10, 40);
|
|
110
|
+
ctx2.bezierCurveTo(30, 0, 70, 80, 160, 30);
|
|
111
|
+
ctx2.strokeStyle = 'rgba(0,0,255,0.8)';
|
|
112
|
+
ctx2.lineWidth = 1.5;
|
|
113
|
+
ctx2.stroke();
|
|
114
|
+
|
|
115
|
+
const dataUrl = c2.toDataURL('image/png');
|
|
116
|
+
// Hash the data URL (not storing raw image data)
|
|
117
|
+
const enc = new TextEncoder().encode(dataUrl);
|
|
118
|
+
result.canvas2dHash = blake3Hex(enc);
|
|
119
|
+
|
|
120
|
+
result.available = true;
|
|
121
|
+
}
|
|
122
|
+
} catch (_) {
|
|
123
|
+
// 2D canvas blocked.
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @typedef {object} CanvasFingerprint
|
|
131
|
+
* @property {string|null} webglRenderer
|
|
132
|
+
* @property {string|null} webglVendor
|
|
133
|
+
* @property {1|2|null} webglVersion
|
|
134
|
+
* @property {string|null} webglPixelHash
|
|
135
|
+
* @property {string|null} canvas2dHash
|
|
136
|
+
* @property {number} extensionCount
|
|
137
|
+
* @property {string[]} extensions
|
|
138
|
+
* @property {boolean} isSoftwareRenderer
|
|
139
|
+
* @property {boolean} available
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Internal helpers
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function _createCanvas(w, h) {
|
|
147
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
148
|
+
return new OffscreenCanvas(w, h);
|
|
149
|
+
}
|
|
150
|
+
const c = document.createElement('canvas');
|
|
151
|
+
c.width = w;
|
|
152
|
+
c.height = h;
|
|
153
|
+
return c;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Render a Mandelbrot set fragment using WebGL and read back pixels.
|
|
158
|
+
* The number of iterations is fixed (100) so that rounding differences in
|
|
159
|
+
* the smooth-colouring formula are the primary source of per-GPU variation.
|
|
160
|
+
*
|
|
161
|
+
* @param {WebGLRenderingContext} gl
|
|
162
|
+
* @param {HTMLCanvasElement|OffscreenCanvas} canvas
|
|
163
|
+
* @returns {Uint8Array|null}
|
|
164
|
+
*/
|
|
165
|
+
function _renderMandelbrot(gl, canvas) {
|
|
166
|
+
const W = canvas.width;
|
|
167
|
+
const H = canvas.height;
|
|
168
|
+
|
|
169
|
+
// Vertex shader – full-screen quad
|
|
170
|
+
const vsSource = `
|
|
171
|
+
attribute vec4 a_pos;
|
|
172
|
+
void main() { gl_Position = a_pos; }
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
// Fragment shader – Mandelbrot with smooth colouring
|
|
176
|
+
// Floating-point precision in the escape-radius and log() calls differs
|
|
177
|
+
// between GPU vendors / drivers, producing per-device pixel signatures.
|
|
178
|
+
const fsSource = `
|
|
179
|
+
precision highp float;
|
|
180
|
+
uniform vec2 u_res;
|
|
181
|
+
void main() {
|
|
182
|
+
vec2 uv = (gl_FragCoord.xy / u_res - 0.5) * 3.5;
|
|
183
|
+
uv.x -= 0.5;
|
|
184
|
+
vec2 c = uv;
|
|
185
|
+
vec2 z = vec2(0.0);
|
|
186
|
+
float n = 0.0;
|
|
187
|
+
for (int i = 0; i < 100; i++) {
|
|
188
|
+
if (dot(z, z) > 4.0) break;
|
|
189
|
+
z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
|
|
190
|
+
n += 1.0;
|
|
191
|
+
}
|
|
192
|
+
float smooth_n = n - log2(log2(dot(z,z))) + 4.0;
|
|
193
|
+
float t = smooth_n / 100.0;
|
|
194
|
+
gl_FragColor = vec4(0.5 + 0.5*cos(6.28318*t + vec3(0.0, 0.4, 0.7)), 1.0);
|
|
195
|
+
}
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
const vs = _compileShader(gl, gl.VERTEX_SHADER, vsSource);
|
|
199
|
+
const fs = _compileShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
|
200
|
+
if (!vs || !fs) return null;
|
|
201
|
+
|
|
202
|
+
const prog = gl.createProgram();
|
|
203
|
+
gl.attachShader(prog, vs);
|
|
204
|
+
gl.attachShader(prog, fs);
|
|
205
|
+
gl.linkProgram(prog);
|
|
206
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
|
|
207
|
+
|
|
208
|
+
gl.useProgram(prog);
|
|
209
|
+
|
|
210
|
+
// Full-screen quad
|
|
211
|
+
const buf = gl.createBuffer();
|
|
212
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
213
|
+
gl.bufferData(gl.ARRAY_BUFFER,
|
|
214
|
+
new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
|
215
|
+
const loc = gl.getAttribLocation(prog, 'a_pos');
|
|
216
|
+
gl.enableVertexAttribArray(loc);
|
|
217
|
+
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
218
|
+
|
|
219
|
+
const resLoc = gl.getUniformLocation(prog, 'u_res');
|
|
220
|
+
gl.uniform2f(resLoc, W, H);
|
|
221
|
+
|
|
222
|
+
gl.viewport(0, 0, W, H);
|
|
223
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
224
|
+
|
|
225
|
+
// Read back a 64×64 centre crop (reduces data without losing discriminating power)
|
|
226
|
+
const x0 = Math.floor((W - 64) / 2);
|
|
227
|
+
const y0 = Math.floor((H - 64) / 2);
|
|
228
|
+
const pixels = new Uint8Array(64 * 64 * 4);
|
|
229
|
+
gl.readPixels(x0, y0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
230
|
+
|
|
231
|
+
return pixels;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _compileShader(gl, type, source) {
|
|
235
|
+
const s = gl.createShader(type);
|
|
236
|
+
gl.shaderSource(s, source);
|
|
237
|
+
gl.compileShader(s);
|
|
238
|
+
return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
|
|
239
|
+
}
|