@svrnsec/pulse 0.3.1 → 0.5.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.
@@ -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
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @sovereign/pulse — DRAM Refresh Cycle Detector
3
+ *
4
+ * DDR4 DRAM refreshes every 7.8 ms (tREFI per JEDEC JESD79-4). During a
5
+ * refresh, the memory controller stalls all access requests for ~350 ns.
6
+ * In a tight sequential memory access loop this appears as a periodic
7
+ * slowdown — detectable as a ~128Hz peak in the autocorrelation of access
8
+ * timings.
9
+ *
10
+ * Virtual machines do not have physical DRAM. The hypervisor's memory
11
+ * subsystem does not reproduce the refresh cycle because:
12
+ * 1. The guest never touches real DRAM directly — there is always a
13
+ * hypervisor-controlled indirection layer.
14
+ * 2. EPT/NPT (Extended/Nested Page Tables) absorb the timing.
15
+ * 3. The hypervisor's memory balloon driver further smooths access latency.
16
+ *
17
+ * What we detect
18
+ * ──────────────
19
+ * refreshPeriodMs estimated DRAM refresh period (should be ~7.8ms on real DDR4)
20
+ * refreshPresent true if the ~7.8ms periodicity is statistically significant
21
+ * peakLag autocorrelation lag with the highest power (units: sample index)
22
+ * peakPower autocorrelation power at peakLag (0–1)
23
+ * verdict 'dram' | 'virtual' | 'ambiguous'
24
+ *
25
+ * Calibration
26
+ * ───────────
27
+ * We allocate a buffer large enough to exceed all CPU caches (typically
28
+ * L3 = 8–32 MB on consumer parts). Sequential reads then go to DRAM, not
29
+ * cache. The refresh stall is only visible when we're actually hitting DRAM —
30
+ * a cache-resident access loop shows no refresh signal.
31
+ *
32
+ * Buffer size: 64 MB — comfortably above L3 on all tested platforms.
33
+ * Sampling interval: ~1 ms per iteration (chosen to resolve 7.8ms at ≥8 pts).
34
+ * Total probe time: ~400 ms — well within the fingerprint collection window.
35
+ */
36
+
37
+ const DRAM_REFRESH_MS = 7.8; // JEDEC DDR4 nominal
38
+ const DRAM_REFRESH_SLACK = 1.5; // ±1.5 ms acceptable range for real hardware
39
+ const BUFFER_MB = 64; // must exceed L3 cache
40
+ const PROBE_ITERATIONS = 400; // ~400 ms total
41
+
42
+ /* ─── collectDramTimings ─────────────────────────────────────────────────── */
43
+
44
+ /**
45
+ * @param {object} [opts]
46
+ * @param {number} [opts.iterations=400]
47
+ * @param {number} [opts.bufferMb=64]
48
+ * @returns {{ timings: number[], refreshPeriodMs: number|null,
49
+ * refreshPresent: boolean, peakLag: number, peakPower: number,
50
+ * verdict: string }}
51
+ */
52
+ export function collectDramTimings(opts = {}) {
53
+ const {
54
+ iterations = PROBE_ITERATIONS,
55
+ bufferMb = BUFFER_MB,
56
+ } = opts;
57
+
58
+ // ── Allocate cache-busting buffer ────────────────────────────────────────
59
+ const nElements = (bufferMb * 1024 * 1024) / 8; // 64-bit doubles
60
+ let buf;
61
+
62
+ try {
63
+ buf = new Float64Array(nElements);
64
+ // Touch every cache line to ensure OS actually maps the pages
65
+ const stride = 64 / 8; // 64-byte cache lines, 8 bytes per element
66
+ for (let i = 0; i < nElements; i += stride) buf[i] = i;
67
+ } catch {
68
+ // Allocation failure (memory constrained) — cannot run this probe
69
+ return _noSignal('buffer allocation failed');
70
+ }
71
+
72
+ // ── Sequential access loop ───────────────────────────────────────────────
73
+ // Each iteration does a full sequential pass over `passElements` worth of
74
+ // the buffer. Pass size is tuned so each iteration takes ~1 ms wall-clock,
75
+ // giving us enough resolution to see the 7.8 ms refresh cycle.
76
+ //
77
+ // We start with a small pass and auto-calibrate to hit the 1 ms target.
78
+ const passElements = _calibratePassSize(buf);
79
+
80
+ const timings = new Float64Array(iterations);
81
+ let checksum = 0;
82
+
83
+ for (let iter = 0; iter < iterations; iter++) {
84
+ const t0 = performance.now();
85
+ for (let i = 0; i < passElements; i++) checksum += buf[i];
86
+ timings[iter] = performance.now() - t0;
87
+ }
88
+
89
+ // Prevent dead-code elimination
90
+ if (checksum === 0) buf[0] = 1;
91
+
92
+ // ── Autocorrelation over timings ─────────────────────────────────────────
93
+ // The refresh stall appears as elevated autocorrelation at lag ≈ 7.8 / Δt
94
+ // where Δt is the mean iteration time in ms.
95
+ const meanIterMs = _mean(timings);
96
+ if (meanIterMs <= 0) return _noSignal('zero mean iteration time');
97
+
98
+ const targetLag = Math.round(DRAM_REFRESH_MS / meanIterMs);
99
+ const maxLag = Math.min(Math.round(50 / meanIterMs), iterations >> 1);
100
+
101
+ const ac = _autocorr(Array.from(timings), maxLag);
102
+
103
+ // Find the peak in the range [targetLag ± slack]
104
+ const slackLags = Math.round(DRAM_REFRESH_SLACK / meanIterMs);
105
+ const lagLo = Math.max(1, targetLag - slackLags);
106
+ const lagHi = Math.min(maxLag, targetLag + slackLags);
107
+
108
+ let peakPower = -Infinity;
109
+ let peakLag = targetLag;
110
+ for (let l = lagLo; l <= lagHi; l++) {
111
+ if (ac[l - 1] > peakPower) {
112
+ peakPower = ac[l - 1];
113
+ peakLag = l;
114
+ }
115
+ }
116
+
117
+ // Baseline: average autocorrelation outside the refresh window
118
+ const baseline = _mean(
119
+ Array.from({ length: maxLag }, (_, i) => ac[i])
120
+ .filter((_, i) => i + 1 < lagLo || i + 1 > lagHi)
121
+ );
122
+
123
+ const snr = baseline > 0 ? peakPower / baseline : 0;
124
+ const refreshPresent = peakPower > 0.15 && snr > 1.8;
125
+ const refreshPeriodMs = refreshPresent ? peakLag * meanIterMs : null;
126
+
127
+ const verdict =
128
+ refreshPresent && refreshPeriodMs !== null &&
129
+ Math.abs(refreshPeriodMs - DRAM_REFRESH_MS) < DRAM_REFRESH_SLACK
130
+ ? 'dram'
131
+ : peakPower < 0.05
132
+ ? 'virtual'
133
+ : 'ambiguous';
134
+
135
+ return {
136
+ timings: Array.from(timings),
137
+ refreshPeriodMs,
138
+ refreshPresent,
139
+ peakLag,
140
+ peakPower: +peakPower.toFixed(4),
141
+ snr: +snr.toFixed(2),
142
+ meanIterMs: +meanIterMs.toFixed(3),
143
+ verdict,
144
+ };
145
+ }
146
+
147
+ /* ─── helpers ────────────────────────────────────────────────────────────── */
148
+
149
+ function _noSignal(reason) {
150
+ return {
151
+ timings: [], refreshPeriodMs: null, refreshPresent: false,
152
+ peakLag: 0, peakPower: 0, snr: 0, meanIterMs: 0,
153
+ verdict: 'ambiguous', reason,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Run a quick calibration pass to find how many elements to read per
159
+ * iteration so each iteration takes approximately 1 ms.
160
+ */
161
+ function _calibratePassSize(buf) {
162
+ const target = 1.0; // ms
163
+ let n = Math.min(100_000, buf.length);
164
+ let elapsed = 0;
165
+ let dummy = 0;
166
+
167
+ // Warm up
168
+ for (let i = 0; i < n; i++) dummy += buf[i];
169
+
170
+ // Measure
171
+ const t0 = performance.now();
172
+ for (let i = 0; i < n; i++) dummy += buf[i];
173
+ elapsed = performance.now() - t0;
174
+ if (dummy === 0) buf[0] = 1; // prevent DCE
175
+
176
+ if (elapsed <= 0) return n;
177
+ return Math.min(buf.length, Math.round(n * (target / elapsed)));
178
+ }
179
+
180
+ function _mean(arr) {
181
+ if (!arr.length) return 0;
182
+ return arr.reduce((s, v) => s + v, 0) / arr.length;
183
+ }
184
+
185
+ function _autocorr(data, maxLag) {
186
+ const n = data.length;
187
+ const mean = _mean(data);
188
+ let v = 0;
189
+ for (let i = 0; i < n; i++) v += (data[i] - mean) ** 2;
190
+ v /= n;
191
+
192
+ const result = new Float64Array(maxLag);
193
+ if (v < 1e-14) return result;
194
+
195
+ for (let lag = 1; lag <= maxLag; lag++) {
196
+ let cov = 0;
197
+ for (let i = 0; i < n - lag; i++) {
198
+ cov += (data[i] - mean) * (data[i + lag] - mean);
199
+ }
200
+ result[lag - 1] = cov / ((n - lag) * v);
201
+ }
202
+ return result;
203
+ }