@svrnsec/pulse 0.3.0 → 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/dist/pulse.cjs.js +2137 -205
- package/dist/pulse.cjs.js.map +1 -1
- package/dist/pulse.esm.js +2129 -206
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +258 -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/proof/fingerprint.js +61 -5
- package/src/terminal.js +263 -0
- package/src/update-notifier.js +264 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — Entropy Collector
|
|
3
|
+
*
|
|
4
|
+
* Bridges the Rust/WASM matrix-multiply probe into JavaScript.
|
|
5
|
+
* The WASM module is lazily initialised once and cached for subsequent calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { collectEntropyAdaptive } from './adaptive.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// WASM loader (lazy singleton)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
let _wasmModule = null;
|
|
14
|
+
let _initPromise = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initialise (or return the cached) WASM module.
|
|
18
|
+
* Works in browsers (via fetch), in Electron (Node.js context), and in
|
|
19
|
+
* Jest/Vitest via a manual WASM path override.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} [wasmPath] – override path/URL to the .wasm binary
|
|
22
|
+
*/
|
|
23
|
+
async function initWasm(wasmPath) {
|
|
24
|
+
if (_wasmModule) return _wasmModule;
|
|
25
|
+
if (_initPromise) return _initPromise;
|
|
26
|
+
|
|
27
|
+
_initPromise = (async () => {
|
|
28
|
+
// Dynamic import so bundlers can tree-shake this for server-only builds.
|
|
29
|
+
const { default: init, run_entropy_probe, run_memory_probe, compute_autocorrelation } =
|
|
30
|
+
await import('../../pkg/pulse_core.js');
|
|
31
|
+
|
|
32
|
+
const url = wasmPath ?? new URL('../../pkg/pulse_core_bg.wasm', import.meta.url).href;
|
|
33
|
+
await init(url);
|
|
34
|
+
|
|
35
|
+
_wasmModule = { run_entropy_probe, run_memory_probe, compute_autocorrelation };
|
|
36
|
+
return _wasmModule;
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
return _initPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// collectEntropy
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the WASM entropy probe and return raw timing data.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} opts
|
|
50
|
+
* @param {number} [opts.iterations=200] - number of matrix-multiply rounds
|
|
51
|
+
* @param {number} [opts.matrixSize=64] - N for the N×N matrices
|
|
52
|
+
* @param {number} [opts.memSizeKb=512] - size of the memory bandwidth probe
|
|
53
|
+
* @param {number} [opts.memIterations=50]
|
|
54
|
+
* @param {boolean} [opts.phased=true] - run cold/load/hot phases for entropy-jitter ratio
|
|
55
|
+
* @param {string} [opts.wasmPath] - optional custom WASM binary path
|
|
56
|
+
*
|
|
57
|
+
* @returns {Promise<EntropyResult>}
|
|
58
|
+
*/
|
|
59
|
+
export async function collectEntropy(opts = {}) {
|
|
60
|
+
const {
|
|
61
|
+
iterations = 200,
|
|
62
|
+
matrixSize = 64,
|
|
63
|
+
memSizeKb = 512,
|
|
64
|
+
memIterations = 50,
|
|
65
|
+
phased = true,
|
|
66
|
+
adaptive = false,
|
|
67
|
+
adaptiveThreshold = 0.85,
|
|
68
|
+
onBatch,
|
|
69
|
+
wasmPath,
|
|
70
|
+
} = opts;
|
|
71
|
+
|
|
72
|
+
const wasm = await initWasm(wasmPath);
|
|
73
|
+
const t_start = Date.now();
|
|
74
|
+
|
|
75
|
+
let phases = null;
|
|
76
|
+
let timings, resolutionProbe, checksum, timerGranularityMs;
|
|
77
|
+
let _adaptiveInfo = null;
|
|
78
|
+
|
|
79
|
+
// ── Adaptive mode: smart early exit, fastest for obvious VMs ──────────
|
|
80
|
+
if (adaptive) {
|
|
81
|
+
const r = await collectEntropyAdaptive(wasm, {
|
|
82
|
+
minIterations: 50,
|
|
83
|
+
maxIterations: iterations,
|
|
84
|
+
batchSize: 25,
|
|
85
|
+
vmThreshold: adaptiveThreshold,
|
|
86
|
+
hwThreshold: 0.80,
|
|
87
|
+
hwMinIterations: 75,
|
|
88
|
+
matrixSize,
|
|
89
|
+
onBatch,
|
|
90
|
+
});
|
|
91
|
+
timings = r.timings;
|
|
92
|
+
resolutionProbe = r.resolutionProbe ?? [];
|
|
93
|
+
checksum = r.checksum;
|
|
94
|
+
timerGranularityMs = r.timerGranularityMs;
|
|
95
|
+
_adaptiveInfo = { earlyExit: r.earlyExit, batches: r.batches, elapsedMs: r.elapsedMs };
|
|
96
|
+
|
|
97
|
+
// ── Phased collection: cold → load → hot ──────────────────────────────
|
|
98
|
+
// Each phase runs a separate WASM probe. On real hardware, sustained load
|
|
99
|
+
// increases thermal noise so Phase 3 (hot) entropy is measurably higher
|
|
100
|
+
// than Phase 1 (cold). A VM's hypervisor clock is insensitive to guest
|
|
101
|
+
// thermal state, so all three phases return nearly identical entropy.
|
|
102
|
+
} else if (phased && iterations >= 60) {
|
|
103
|
+
const coldN = Math.floor(iterations * 0.25); // ~25% cold
|
|
104
|
+
const loadN = Math.floor(iterations * 0.50); // ~50% sustained load
|
|
105
|
+
const hotN = iterations - coldN - loadN; // ~25% hot
|
|
106
|
+
|
|
107
|
+
const cold = wasm.run_entropy_probe(coldN, matrixSize);
|
|
108
|
+
const load = wasm.run_entropy_probe(loadN, matrixSize);
|
|
109
|
+
const hot = wasm.run_entropy_probe(hotN, matrixSize);
|
|
110
|
+
|
|
111
|
+
const coldTimings = Array.from(cold.timings);
|
|
112
|
+
const loadTimings = Array.from(load.timings);
|
|
113
|
+
const hotTimings = Array.from(hot.timings);
|
|
114
|
+
|
|
115
|
+
timings = [...coldTimings, ...loadTimings, ...hotTimings];
|
|
116
|
+
resolutionProbe = Array.from(cold.resolution_probe);
|
|
117
|
+
checksum = (cold.checksum + load.checksum + hot.checksum).toString();
|
|
118
|
+
|
|
119
|
+
const { detectQuantizationEntropy } = await import('../analysis/jitter.js');
|
|
120
|
+
const coldQE = detectQuantizationEntropy(coldTimings);
|
|
121
|
+
const hotQE = detectQuantizationEntropy(hotTimings);
|
|
122
|
+
|
|
123
|
+
phases = {
|
|
124
|
+
cold: { n: coldN, timings: coldTimings, qe: coldQE, mean: _mean(coldTimings) },
|
|
125
|
+
load: { n: loadN, timings: loadTimings, qe: detectQuantizationEntropy(loadTimings), mean: _mean(loadTimings) },
|
|
126
|
+
hot: { n: hotN, timings: hotTimings, qe: hotQE, mean: _mean(hotTimings) },
|
|
127
|
+
// The key signal: entropy growth under load.
|
|
128
|
+
// Real silicon: hotQE / coldQE typically 1.05 – 1.40
|
|
129
|
+
// VM: hotQE / coldQE typically 0.95 – 1.05 (flat)
|
|
130
|
+
entropyJitterRatio: coldQE > 0 ? hotQE / coldQE : 1.0,
|
|
131
|
+
};
|
|
132
|
+
} else {
|
|
133
|
+
// Single-phase fallback (fewer iterations or phased disabled)
|
|
134
|
+
const result = wasm.run_entropy_probe(iterations, matrixSize);
|
|
135
|
+
timings = Array.from(result.timings);
|
|
136
|
+
resolutionProbe = Array.from(result.resolution_probe);
|
|
137
|
+
checksum = result.checksum.toString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Timer resolution (non-adaptive path only — adaptive computes its own) ─
|
|
141
|
+
if (!adaptive) {
|
|
142
|
+
const resDeltas = [];
|
|
143
|
+
for (let i = 1; i < resolutionProbe.length; i++) {
|
|
144
|
+
const d = resolutionProbe[i] - resolutionProbe[i - 1];
|
|
145
|
+
if (d > 0) resDeltas.push(d);
|
|
146
|
+
}
|
|
147
|
+
timerGranularityMs = resDeltas.length
|
|
148
|
+
? resDeltas.reduce((a, b) => Math.min(a, b), Infinity)
|
|
149
|
+
: null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Autocorrelation at diagnostic lags ────────────────────────────────
|
|
153
|
+
// Extended lags catch long-period steal-time rhythms (Xen: ~150 iters)
|
|
154
|
+
const lags = [1, 2, 3, 5, 10, 25, 50];
|
|
155
|
+
const autocorrelations = {};
|
|
156
|
+
for (const lag of lags) {
|
|
157
|
+
if (lag < timings.length) {
|
|
158
|
+
autocorrelations[`lag${lag}`] = wasm.compute_autocorrelation(timings, lag);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Secondary probe: memory bandwidth jitter ───────────────────────────
|
|
163
|
+
const memTimings = Array.from(wasm.run_memory_probe(memSizeKb, memIterations));
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
timings,
|
|
167
|
+
resolutionProbe,
|
|
168
|
+
timerGranularityMs,
|
|
169
|
+
autocorrelations,
|
|
170
|
+
memTimings,
|
|
171
|
+
phases,
|
|
172
|
+
checksum,
|
|
173
|
+
collectedAt: t_start,
|
|
174
|
+
iterations: timings.length, // actual count (adaptive may differ from requested)
|
|
175
|
+
matrixSize,
|
|
176
|
+
adaptive: _adaptiveInfo, // null in non-adaptive mode
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _mean(arr) {
|
|
181
|
+
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @typedef {object} EntropyResult
|
|
186
|
+
* @property {number[]} timings - per-iteration wall-clock deltas (ms)
|
|
187
|
+
* @property {number[]} resolutionProbe - raw successive perf.now() readings
|
|
188
|
+
* @property {number|null} timerGranularityMs - effective timer resolution
|
|
189
|
+
* @property {object} autocorrelations - { lag1, lag2, lag3, lag5, lag10 }
|
|
190
|
+
* @property {number[]} memTimings - memory-probe timings (ms)
|
|
191
|
+
* @property {string} checksum - proof the computation ran
|
|
192
|
+
* @property {number} collectedAt - Date.now() at probe start
|
|
193
|
+
* @property {number} iterations
|
|
194
|
+
* @property {number} matrixSize
|
|
195
|
+
*/
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — WebGPU Thermal Variance Probe
|
|
3
|
+
*
|
|
4
|
+
* Runs a compute shader on the GPU and measures dispatch timing variance.
|
|
5
|
+
*
|
|
6
|
+
* Why this works
|
|
7
|
+
* ──────────────
|
|
8
|
+
* Real consumer GPUs (GTX 1650, RX 6600, M2 GPU) have thermal noise in shader
|
|
9
|
+
* execution timing that increases under sustained load — the same thermodynamic
|
|
10
|
+
* principle as the CPU probe but in silicon designed for parallel throughput.
|
|
11
|
+
*
|
|
12
|
+
* Cloud VMs with software GPU emulation (SwiftShader, llvmpipe, Mesa's softpipe)
|
|
13
|
+
* execute shaders on the CPU and produce near-deterministic timing — flat CV,
|
|
14
|
+
* no thermal growth across phases, no dispatch jitter.
|
|
15
|
+
*
|
|
16
|
+
* VMs with GPU passthrough (rare in practice, requires dedicated hardware) pass
|
|
17
|
+
* this check — which is correct, they have real GPU silicon.
|
|
18
|
+
*
|
|
19
|
+
* Signals
|
|
20
|
+
* ───────
|
|
21
|
+
* gpuPresent false = WebGPU absent = software renderer = high VM probability
|
|
22
|
+
* isSoftware true = SwiftShader/llvmpipe detected by adapter info
|
|
23
|
+
* dispatchCV coefficient of variation across dispatch timings
|
|
24
|
+
* thermalGrowth (hotDispatchMean - coldDispatchMean) / coldDispatchMean
|
|
25
|
+
* vendorString GPU vendor from adapter info (Intel, NVIDIA, AMD, Apple, etc.)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/* ─── WebGPU availability ────────────────────────────────────────────────── */
|
|
29
|
+
|
|
30
|
+
function isWebGPUAvailable() {
|
|
31
|
+
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ─── Software renderer detection ───────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
const SOFTWARE_RENDERER_PATTERNS = [
|
|
37
|
+
/swiftshader/i,
|
|
38
|
+
/llvmpipe/i,
|
|
39
|
+
/softpipe/i,
|
|
40
|
+
/microsoft basic render/i,
|
|
41
|
+
/angle \(.*software/i,
|
|
42
|
+
/cpu/i,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function detectSoftwareRenderer(adapterInfo) {
|
|
46
|
+
const desc = [
|
|
47
|
+
adapterInfo?.vendor ?? '',
|
|
48
|
+
adapterInfo?.device ?? '',
|
|
49
|
+
adapterInfo?.description ?? '',
|
|
50
|
+
adapterInfo?.architecture ?? '',
|
|
51
|
+
].join(' ');
|
|
52
|
+
|
|
53
|
+
return SOFTWARE_RENDERER_PATTERNS.some(p => p.test(desc));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ─── Compute shader ─────────────────────────────────────────────────────── */
|
|
57
|
+
|
|
58
|
+
// A compute workload that is trivially parallelisable but forces the GPU to
|
|
59
|
+
// actually execute — matrix-multiply on 64 × 64 tiles across 256 workgroups.
|
|
60
|
+
// Light enough that it doesn't block UI; heavy enough to generate thermal signal.
|
|
61
|
+
const SHADER_SRC = /* wgsl */ `
|
|
62
|
+
struct Matrix {
|
|
63
|
+
values: array<f32, 4096>, // 64x64
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
@group(0) @binding(0) var<storage, read> matA : Matrix;
|
|
67
|
+
@group(0) @binding(1) var<storage, read> matB : Matrix;
|
|
68
|
+
@group(0) @binding(2) var<storage, read_write> matC : Matrix;
|
|
69
|
+
|
|
70
|
+
@compute @workgroup_size(8, 8)
|
|
71
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
72
|
+
let row = gid.x;
|
|
73
|
+
let col = gid.y;
|
|
74
|
+
if (row >= 64u || col >= 64u) { return; }
|
|
75
|
+
|
|
76
|
+
var acc: f32 = 0.0;
|
|
77
|
+
for (var k = 0u; k < 64u; k++) {
|
|
78
|
+
acc += matA.values[row * 64u + k] * matB.values[k * 64u + col];
|
|
79
|
+
}
|
|
80
|
+
matC.values[row * 64u + col] = acc;
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
/* ─── collectGpuEntropy ─────────────────────────────────────────────────── */
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} [opts]
|
|
88
|
+
* @param {number} [opts.iterations=60] – dispatch rounds per phase
|
|
89
|
+
* @param {boolean} [opts.phased=true] – cold / load / hot phases
|
|
90
|
+
* @param {number} [opts.timeoutMs=8000] – hard abort if GPU stalls
|
|
91
|
+
* @returns {Promise<GpuEntropyResult>}
|
|
92
|
+
*/
|
|
93
|
+
export async function collectGpuEntropy(opts = {}) {
|
|
94
|
+
const { iterations = 60, phased = true, timeoutMs = 8000 } = opts;
|
|
95
|
+
|
|
96
|
+
if (!isWebGPUAvailable()) {
|
|
97
|
+
return _noGpu('WebGPU not available in this environment');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let adapter, device;
|
|
101
|
+
try {
|
|
102
|
+
adapter = await Promise.race([
|
|
103
|
+
navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }),
|
|
104
|
+
_timeout(timeoutMs, 'requestAdapter timed out'),
|
|
105
|
+
]);
|
|
106
|
+
if (!adapter) return _noGpu('No WebGPU adapter found');
|
|
107
|
+
|
|
108
|
+
device = await Promise.race([
|
|
109
|
+
adapter.requestDevice(),
|
|
110
|
+
_timeout(timeoutMs, 'requestDevice timed out'),
|
|
111
|
+
]);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return _noGpu(`WebGPU init failed: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const adapterInfo = adapter.info ?? {};
|
|
117
|
+
const isSoftware = detectSoftwareRenderer(adapterInfo);
|
|
118
|
+
|
|
119
|
+
// Compile the shader module once
|
|
120
|
+
const shaderModule = device.createShaderModule({ code: SHADER_SRC });
|
|
121
|
+
|
|
122
|
+
// Create persistent GPU buffers (64×64 float32 = 16 KB each)
|
|
123
|
+
const bufSize = 4096 * 4; // 4096 floats × 4 bytes
|
|
124
|
+
const bufA = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
125
|
+
const bufB = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
126
|
+
const bufC = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
|
127
|
+
|
|
128
|
+
// Seed with random data
|
|
129
|
+
const matData = new Float32Array(4096).map(() => Math.random());
|
|
130
|
+
device.queue.writeBuffer(bufA, 0, matData);
|
|
131
|
+
device.queue.writeBuffer(bufB, 0, matData);
|
|
132
|
+
|
|
133
|
+
const pipeline = device.createComputePipeline({
|
|
134
|
+
layout: 'auto',
|
|
135
|
+
compute: { module: shaderModule, entryPoint: 'main' },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const bindGroup = device.createBindGroup({
|
|
139
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
140
|
+
entries: [
|
|
141
|
+
{ binding: 0, resource: { buffer: bufA } },
|
|
142
|
+
{ binding: 1, resource: { buffer: bufB } },
|
|
143
|
+
{ binding: 2, resource: { buffer: bufC } },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Probe ──────────────────────────────────────────────────────────────
|
|
148
|
+
async function runPhase(n) {
|
|
149
|
+
const timings = [];
|
|
150
|
+
for (let i = 0; i < n; i++) {
|
|
151
|
+
const t0 = performance.now();
|
|
152
|
+
const encoder = device.createCommandEncoder();
|
|
153
|
+
const pass = encoder.beginComputePass();
|
|
154
|
+
pass.setPipeline(pipeline);
|
|
155
|
+
pass.setBindGroup(0, bindGroup);
|
|
156
|
+
pass.dispatchWorkgroups(8, 8); // 64 workgroups total
|
|
157
|
+
pass.end();
|
|
158
|
+
device.queue.submit([encoder.finish()]);
|
|
159
|
+
await device.queue.onSubmittedWorkDone();
|
|
160
|
+
const t1 = performance.now();
|
|
161
|
+
timings.push(t1 - t0);
|
|
162
|
+
}
|
|
163
|
+
return timings;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let coldTimings, loadTimings, hotTimings;
|
|
167
|
+
|
|
168
|
+
if (phased) {
|
|
169
|
+
coldTimings = await runPhase(Math.floor(iterations * 0.25));
|
|
170
|
+
loadTimings = await runPhase(Math.floor(iterations * 0.50));
|
|
171
|
+
hotTimings = await runPhase(iterations - coldTimings.length - loadTimings.length);
|
|
172
|
+
} else {
|
|
173
|
+
coldTimings = await runPhase(iterations);
|
|
174
|
+
loadTimings = [];
|
|
175
|
+
hotTimings = [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Cleanup
|
|
179
|
+
bufA.destroy(); bufB.destroy(); bufC.destroy();
|
|
180
|
+
device.destroy();
|
|
181
|
+
|
|
182
|
+
const allTimings = [...coldTimings, ...loadTimings, ...hotTimings];
|
|
183
|
+
const mean = _mean(allTimings);
|
|
184
|
+
const cv = mean > 0 ? _std(allTimings) / mean : 0;
|
|
185
|
+
|
|
186
|
+
const coldMean = _mean(coldTimings);
|
|
187
|
+
const hotMean = _mean(hotTimings.length ? hotTimings : coldTimings);
|
|
188
|
+
const thermalGrowth = coldMean > 0 ? (hotMean - coldMean) / coldMean : 0;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
gpuPresent: true,
|
|
192
|
+
isSoftware,
|
|
193
|
+
vendor: adapterInfo.vendor ?? 'unknown',
|
|
194
|
+
architecture: adapterInfo.architecture ?? 'unknown',
|
|
195
|
+
timings: allTimings,
|
|
196
|
+
dispatchCV: cv,
|
|
197
|
+
thermalGrowth,
|
|
198
|
+
coldMean,
|
|
199
|
+
hotMean,
|
|
200
|
+
// Heuristic: real GPU → thermalGrowth > 0.02 and CV > 0.04
|
|
201
|
+
// Software renderer → thermalGrowth ≈ 0, CV < 0.02
|
|
202
|
+
verdict: isSoftware ? 'software_renderer'
|
|
203
|
+
: thermalGrowth > 0.02 && cv > 0.04 ? 'real_gpu'
|
|
204
|
+
: thermalGrowth < 0 && cv < 0.02 ? 'virtual_gpu'
|
|
205
|
+
: 'ambiguous',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ─── helpers ────────────────────────────────────────────────────────────── */
|
|
210
|
+
|
|
211
|
+
function _noGpu(reason) {
|
|
212
|
+
return { gpuPresent: false, isSoftware: false, vendor: null,
|
|
213
|
+
architecture: null, timings: [], dispatchCV: 0,
|
|
214
|
+
thermalGrowth: 0, coldMean: 0, hotMean: 0,
|
|
215
|
+
verdict: 'no_gpu', reason };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _createBuffer(device, size, usage) {
|
|
219
|
+
return device.createBuffer({ size, usage });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _mean(arr) {
|
|
223
|
+
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _std(arr) {
|
|
227
|
+
const m = _mean(arr);
|
|
228
|
+
return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _timeout(ms, msg) {
|
|
232
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @typedef {object} GpuEntropyResult
|
|
237
|
+
* @property {boolean} gpuPresent
|
|
238
|
+
* @property {boolean} isSoftware
|
|
239
|
+
* @property {string|null} vendor
|
|
240
|
+
* @property {string|null} architecture
|
|
241
|
+
* @property {number[]} timings
|
|
242
|
+
* @property {number} dispatchCV
|
|
243
|
+
* @property {number} thermalGrowth
|
|
244
|
+
* @property {string} verdict 'real_gpu' | 'virtual_gpu' | 'software_renderer' | 'no_gpu' | 'ambiguous'
|
|
245
|
+
*/
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — SharedArrayBuffer Microsecond Timer
|
|
3
|
+
*
|
|
4
|
+
* Bypasses browser timer clamping (Brave 100µs cap, Firefox 20µs cap, Safari
|
|
5
|
+
* 1ms cap) using Atomics.wait() which is exempt from clamping because it maps
|
|
6
|
+
* directly to OS-level futex/semaphore primitives.
|
|
7
|
+
*
|
|
8
|
+
* Requirements
|
|
9
|
+
* ────────────
|
|
10
|
+
* The page must be served with Cross-Origin Isolation headers:
|
|
11
|
+
* Cross-Origin-Opener-Policy: same-origin
|
|
12
|
+
* Cross-Origin-Embedder-Policy: require-corp
|
|
13
|
+
*
|
|
14
|
+
* These are mandatory for security (Spectre mitigations) and are already
|
|
15
|
+
* required by WebGPU, WebAssembly threads, and SharedArrayBuffer in all
|
|
16
|
+
* modern browsers.
|
|
17
|
+
*
|
|
18
|
+
* What we measure
|
|
19
|
+
* ───────────────
|
|
20
|
+
* resolution the true timer resolution (pre-clamp) in microseconds
|
|
21
|
+
* isClamped true if performance.now() is artificially reduced
|
|
22
|
+
* clampAmount how much performance.now() was rounded (µs)
|
|
23
|
+
* highResTimings entropy probe timings at true microsecond resolution
|
|
24
|
+
*
|
|
25
|
+
* Why this matters
|
|
26
|
+
* ────────────────
|
|
27
|
+
* With 1ms clamping, a VM's flat distribution and a real device's noisy
|
|
28
|
+
* distribution can look similar — both get quantized to the same step.
|
|
29
|
+
* At 1µs resolution, the difference between EJR=1.01 and EJR=1.24 is
|
|
30
|
+
* unmistakable. This upgrade alone materially improves detection accuracy
|
|
31
|
+
* on Brave and Firefox where timer clamping was previously a confound.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/* ─── availability ───────────────────────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
export function isSabAvailable() {
|
|
37
|
+
return (
|
|
38
|
+
typeof SharedArrayBuffer !== 'undefined' &&
|
|
39
|
+
typeof Atomics !== 'undefined' &&
|
|
40
|
+
typeof Atomics.wait === 'function' &&
|
|
41
|
+
crossOriginIsolated === true // window flag set by COOP+COEP headers
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ─── Atomics-based high-resolution clock ───────────────────────────────── */
|
|
46
|
+
|
|
47
|
+
let _sab = null;
|
|
48
|
+
let _i32 = null;
|
|
49
|
+
|
|
50
|
+
function _initSab() {
|
|
51
|
+
if (!_sab) {
|
|
52
|
+
_sab = new SharedArrayBuffer(4);
|
|
53
|
+
_i32 = new Int32Array(_sab);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wait exactly `us` microseconds using Atomics.wait().
|
|
59
|
+
* Returns wall-clock elapsed in milliseconds.
|
|
60
|
+
* Much more accurate than setTimeout(fn, 0) or performance.now() loops.
|
|
61
|
+
*
|
|
62
|
+
* @param {number} us – microseconds to wait
|
|
63
|
+
* @returns {number} actual elapsed ms
|
|
64
|
+
*/
|
|
65
|
+
function _atomicsWait(us) {
|
|
66
|
+
_initSab();
|
|
67
|
+
const t0 = performance.now();
|
|
68
|
+
Atomics.wait(_i32, 0, 0, us / 1000); // Atomics.wait timeout is in ms
|
|
69
|
+
return performance.now() - t0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ─── measureClamp ───────────────────────────────────────────────────────── */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Determine the true timer resolution by comparing a series of
|
|
76
|
+
* sub-millisecond Atomics.wait() calls against performance.now() deltas.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{ isClamped: boolean, clampAmountUs: number, resolutionUs: number }}
|
|
79
|
+
*/
|
|
80
|
+
export function measureClamp() {
|
|
81
|
+
if (!isSabAvailable()) {
|
|
82
|
+
return { isClamped: false, clampAmountUs: 0, resolutionUs: 1000 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Measure the minimum non-zero performance.now() delta
|
|
86
|
+
const performanceDeltas = [];
|
|
87
|
+
for (let i = 0; i < 100; i++) {
|
|
88
|
+
const t0 = performance.now();
|
|
89
|
+
let t1 = t0;
|
|
90
|
+
while (t1 === t0) t1 = performance.now();
|
|
91
|
+
performanceDeltas.push((t1 - t0) * 1000); // convert to µs
|
|
92
|
+
}
|
|
93
|
+
performanceDeltas.sort((a, b) => a - b);
|
|
94
|
+
const perfResolutionUs = performanceDeltas[Math.floor(performanceDeltas.length * 0.1)]; // 10th percentile
|
|
95
|
+
|
|
96
|
+
// Measure actual OS timer resolution via Atomics.wait
|
|
97
|
+
const atomicsDeltas = [];
|
|
98
|
+
for (let i = 0; i < 20; i++) {
|
|
99
|
+
const elapsedMs = _atomicsWait(100); // wait 100µs
|
|
100
|
+
atomicsDeltas.push(Math.abs(elapsedMs * 1000 - 100)); // error from target
|
|
101
|
+
}
|
|
102
|
+
const atomicsErrorUs = atomicsDeltas.reduce((s, v) => s + v, 0) / atomicsDeltas.length;
|
|
103
|
+
const trueResolutionUs = Math.max(1, atomicsErrorUs);
|
|
104
|
+
|
|
105
|
+
const isClamped = perfResolutionUs > trueResolutionUs * 5;
|
|
106
|
+
const clampAmountUs = isClamped ? perfResolutionUs - trueResolutionUs : 0;
|
|
107
|
+
|
|
108
|
+
return { isClamped, clampAmountUs, resolutionUs: perfResolutionUs };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ─── collectHighResTimings ──────────────────────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Collect entropy probe timings at Atomics-level resolution.
|
|
115
|
+
* Falls back to performance.now() if SAB is unavailable.
|
|
116
|
+
*
|
|
117
|
+
* The probe itself is identical to the WASM matrix probe — CPU work unit
|
|
118
|
+
* timed with the highest available clock. The difference: on a clamped
|
|
119
|
+
* browser this replaces quantized 100µs buckets with true µs measurements.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {number} [opts.iterations=200]
|
|
123
|
+
* @param {number} [opts.matrixSize=32] – smaller than WASM probe (no SIMD here)
|
|
124
|
+
* @returns {{ timings: number[], usingAtomics: boolean, resolutionUs: number }}
|
|
125
|
+
*/
|
|
126
|
+
export function collectHighResTimings(opts = {}) {
|
|
127
|
+
const { iterations = 200, matrixSize = 32 } = opts;
|
|
128
|
+
|
|
129
|
+
const usingAtomics = isSabAvailable();
|
|
130
|
+
const clampInfo = usingAtomics ? measureClamp() : { resolutionUs: 1000 };
|
|
131
|
+
|
|
132
|
+
// Simple matrix multiply work unit (JS — no WASM needed for the clock probe)
|
|
133
|
+
const N = matrixSize;
|
|
134
|
+
const A = new Float64Array(N * N).map(() => Math.random());
|
|
135
|
+
const B = new Float64Array(N * N).map(() => Math.random());
|
|
136
|
+
const C = new Float64Array(N * N);
|
|
137
|
+
|
|
138
|
+
const timings = new Array(iterations);
|
|
139
|
+
|
|
140
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
141
|
+
C.fill(0);
|
|
142
|
+
|
|
143
|
+
if (usingAtomics) {
|
|
144
|
+
// ── Atomics path: start timing, do work, read Atomics-calibrated time ──
|
|
145
|
+
// We use a sliding window approach: measure with Atomics.wait(0) which
|
|
146
|
+
// returns immediately but the OS schedules give us a high-res timestamp
|
|
147
|
+
// via the before/after pattern on the shared memory notification.
|
|
148
|
+
_initSab();
|
|
149
|
+
|
|
150
|
+
const tAtomicsBefore = _getAtomicsTs();
|
|
151
|
+
for (let i = 0; i < N; i++) {
|
|
152
|
+
for (let k = 0; k < N; k++) {
|
|
153
|
+
const aik = A[i * N + k];
|
|
154
|
+
for (let j = 0; j < N; j++) C[i * N + j] += aik * B[k * N + j];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const tAtomicsAfter = _getAtomicsTs();
|
|
158
|
+
timings[iter] = (tAtomicsAfter - tAtomicsBefore) * 1000; // µs → ms
|
|
159
|
+
|
|
160
|
+
} else {
|
|
161
|
+
// ── Standard path: use performance.now() ──
|
|
162
|
+
const t0 = performance.now();
|
|
163
|
+
for (let i = 0; i < N; i++) {
|
|
164
|
+
for (let k = 0; k < N; k++) {
|
|
165
|
+
const aik = A[i * N + k];
|
|
166
|
+
for (let j = 0; j < N; j++) C[i * N + j] += aik * B[k * N + j];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
timings[iter] = performance.now() - t0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
timings,
|
|
175
|
+
usingAtomics,
|
|
176
|
+
resolutionUs: clampInfo.resolutionUs,
|
|
177
|
+
isClamped: clampInfo.isClamped ?? false,
|
|
178
|
+
clampAmountUs: clampInfo.clampAmountUs ?? 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ─── internal Atomics timestamp ─────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
// Use a write to shared memory + memory fence as a timestamp anchor.
|
|
185
|
+
// This forces the CPU to flush its store buffer, giving a hardware-ordered
|
|
186
|
+
// time reference that survives compiler reordering.
|
|
187
|
+
function _getAtomicsTs() {
|
|
188
|
+
_initSab();
|
|
189
|
+
Atomics.store(_i32, 0, Atomics.load(_i32, 0) + 1);
|
|
190
|
+
return performance.now();
|
|
191
|
+
}
|